mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8ec15223 | ||
|
|
c9534ef6f9 | ||
|
|
a6a489e0fa | ||
|
|
840ba5af33 | ||
|
|
3403b87641 | ||
|
|
bfbae5a7b3 | ||
|
|
58cce14a07 | ||
|
|
f9b02496c8 | ||
|
|
bfc2a74e4f | ||
|
|
780093d037 | ||
|
|
1873e948c3 | ||
|
|
735a243849 | ||
|
|
edada598c8 | ||
|
|
2527ce5de9 | ||
|
|
44a5ed1bc0 | ||
|
|
4c0e9b9f46 | ||
|
|
839d1ecfb1 | ||
|
|
593b328dbb | ||
|
|
4beca25ceb | ||
|
|
82ffce1474 | ||
|
|
4726ca0478 | ||
|
|
d979597361 | ||
|
|
070aed8ade | ||
|
|
8df100203c | ||
|
|
8fa9eed6a8 | ||
|
|
5692f7a68a | ||
|
|
57f0c859fc | ||
|
|
40282e3b74 | ||
|
|
dacc699293 | ||
|
|
c8638dee46 | ||
|
|
7dc45ff94e | ||
|
|
8248d075db | ||
|
|
d7152cdbdd | ||
|
|
89dc34df88 | ||
|
|
67ce6822ca | ||
|
|
194e9d5f5f | ||
|
|
5005bca353 | ||
|
|
e5dbb15c34 | ||
|
|
c90e433057 | ||
|
|
3ce77633c0 | ||
|
|
00a907c493 | ||
|
|
cf2f589615 | ||
|
|
463f536750 | ||
|
|
00a7de0171 | ||
|
|
efb792ff24 | ||
|
|
d8601478db | ||
|
|
66fc54421c | ||
|
|
6a27c26257 | ||
|
|
3ca82ea880 | ||
|
|
47dcc61e9b | ||
|
|
2bf111f456 | ||
|
|
aa6c1bf300 | ||
|
|
5281e67222 | ||
|
|
dbb53109f4 | ||
|
|
b015515f30 | ||
|
|
be88e14424 | ||
|
|
9e490ef473 | ||
|
|
bfb4246659 | ||
|
|
fbf96a3502 | ||
|
|
2a9e288dfe | ||
|
|
3460ca3cb9 | ||
|
|
9b45fc1172 | ||
|
|
8656549129 | ||
|
|
4c1f127549 | ||
|
|
13c951aa41 | ||
|
|
cd1fb61eb5 | ||
|
|
e79cb816e3 | ||
|
|
64901be67f | ||
|
|
9ce9d2235a | ||
|
|
06d5495b60 | ||
|
|
851a0ff50c | ||
|
|
3596ccf828 | ||
|
|
d1ae73a8a8 | ||
|
|
c661012754 | ||
|
|
0e19ea3062 | ||
|
|
ceae6d7b61 | ||
|
|
34f776b48b | ||
|
|
f7b027a295 | ||
|
|
6c880a6691 | ||
|
|
4636c78100 | ||
|
|
28c8c89a42 | ||
|
|
7899857201 | ||
|
|
9354b80a6e | ||
|
|
638af123ba | ||
|
|
42aa37cfc0 | ||
|
|
03302c751f | ||
|
|
246ea8ef61 | ||
|
|
f60b3c7920 | ||
|
|
e92899607a | ||
|
|
c930aa3713 | ||
|
|
4378944459 | ||
|
|
123384975e |
@@ -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 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_24.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 && \
|
||||
|
||||
+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": "^24.0.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
|
||||
|
||||
|
||||
+80
-9
@@ -240,6 +240,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
||||
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
||||
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
||||
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
@@ -632,20 +633,37 @@ nanobot agent -m "Reply with one short sentence."
|
||||
<details>
|
||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. `nanobot provider login` stores the OAuth session outside config. A `providers.openai_codex` block is optional and is only needed for provider-specific settings such as a proxy.
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
nanobot provider login openai-codex
|
||||
```
|
||||
|
||||
**2. Set model** (merge into `~/.nanobot/config.json`):
|
||||
If the machine running nanobot cannot open a graphical browser, copy the printed URL into a real browser. For remote SSH login, open the URL locally, then paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted.
|
||||
|
||||
**2. Optional proxy** (merge into `~/.nanobot/config.json` if Codex OAuth or Codex API traffic must use a proxy):
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai_codex": {
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The proxy applies to Codex OAuth token refresh, interactive token exchange, and Codex Responses API requests. It does not affect other providers; configure `proxy` separately on each supported provider that needs it.
|
||||
|
||||
**3. Set model** (merge into `~/.nanobot/config.json`):
|
||||
```json
|
||||
{
|
||||
"modelPresets": {
|
||||
"codex": {
|
||||
"provider": "openai_codex",
|
||||
"model": "openai-codex/gpt-5.1-codex"
|
||||
"model": "gpt-5.1-codex",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
@@ -656,7 +674,9 @@ nanobot provider login openai-codex
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chat:**
|
||||
Use `reasoningEffort` in the preset to send a Codex reasoning effort such as `"low"`, `"medium"`, `"high"`, or another value supported by the selected model. When `provider` is explicitly `openai_codex`, the model name does not need the `openai-codex/` prefix.
|
||||
|
||||
**4. Chat:**
|
||||
```bash
|
||||
nanobot agent -m "Hello!"
|
||||
|
||||
@@ -675,7 +695,17 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
||||
<details>
|
||||
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
||||
|
||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||
|
||||
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
||||
```bash
|
||||
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
||||
export NANOBOT_GITHUB_DEVICE_CODE_URL="https://ghe.example/login/device/code"
|
||||
export NANOBOT_GITHUB_ACCESS_TOKEN_URL="https://ghe.example/login/oauth/access_token"
|
||||
export NANOBOT_GITHUB_USER_URL="https://api.ghe.example/user"
|
||||
export NANOBOT_COPILOT_TOKEN_URL="https://api.ghe.example/copilot_internal/v2/token"
|
||||
export NANOBOT_COPILOT_BASE_URL="https://copilot-api.ghe.example"
|
||||
```
|
||||
|
||||
**1. Login:**
|
||||
```bash
|
||||
@@ -985,6 +1015,29 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
|
||||
}
|
||||
```
|
||||
|
||||
If a custom OpenAI-compatible endpoint exposes a provider-specific thinking toggle, set `thinkingStyle` so nanobot can translate `reasoningEffort` into the right request body. Supported styles are `thinking_type` (`{"thinking":{"type":"enabled"}}`), `enable_thinking` (`{"enable_thinking": true}`), and `reasoning_split` (`{"reasoning_split": true}`):
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"companyProxy": {
|
||||
"apiKey": "${COMPANY_PROXY_API_KEY}",
|
||||
"apiBase": "https://api.your-provider.com/v1",
|
||||
"thinkingStyle": "enable_thinking"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"company": {
|
||||
"provider": "companyProxy",
|
||||
"model": "served-model-name",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Leave `thinkingStyle` unset unless the endpoint explicitly documents one of those wire formats. `extraBody` is still applied last, so advanced users can override the generated value.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="local-providers"></a>
|
||||
@@ -1482,6 +1535,8 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
||||
}
|
||||
```
|
||||
|
||||
Telegram `richMessages` defaults to `false`. Enable it only to opt in to Bot API 10.1 `sendRichMessage` rendering; leave it disabled for Telegram Web clients that show unsupported-message errors for rich messages.
|
||||
|
||||
### Retry Behavior
|
||||
|
||||
Retry is intentionally simple.
|
||||
@@ -1827,9 +1882,9 @@ Use `enabledTools` to register only a subset of tools from an MCP server:
|
||||
|
||||
`enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`).
|
||||
|
||||
- Omit `enabledTools`, or set it to `["*"]`, to register all tools.
|
||||
- Set `enabledTools` to `[]` to register no tools from that server.
|
||||
- Set `enabledTools` to a non-empty list of names to register only that subset.
|
||||
- Omit `enabledTools`, or set it to `["*"]`, to register all capabilities (tools, resources, and prompts).
|
||||
- Set `enabledTools` to `[]` to register no tools from that server. Resources and prompts are also skipped, since they have no per-name filter.
|
||||
- Set `enabledTools` to a non-empty list of names to register only those tools — resources and prompts are not registered.
|
||||
|
||||
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
|
||||
|
||||
@@ -1938,7 +1993,9 @@ The gateway can run a protected heartbeat cron job that periodically checks `HEA
|
||||
}
|
||||
```
|
||||
|
||||
If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and delivers useful results to the most recently active chat target. If the file has no active tasks, the heartbeat is skipped silently.
|
||||
If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and sends only useful/actionable results to the most recently active chat target. If the file has no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
||||
|
||||
This is intentionally different from user-created cron jobs. A cron job created with the `cron` tool runs as a scheduled turn in its origin chat/session and normally delivers the result back to that channel. Use `HEARTBEAT.md` for recurring background checks that should not notify the user on every run.
|
||||
|
||||
The heartbeat job is backed by the same cron service as user-created reminders. It is stored under the active workspace (`<workspace>/cron/jobs.json`) and shows up in `cron(action="list")` as `heartbeat`, but it is system-managed and cannot be removed with the `cron` tool. Disable it through config and restart the gateway if you do not want periodic heartbeat checks.
|
||||
|
||||
@@ -1947,6 +2004,7 @@ The heartbeat job is backed by the same cron service as user-created reminders.
|
||||
| `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. |
|
||||
| `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. |
|
||||
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
|
||||
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
|
||||
|
||||
|
||||
## Subagent Concurrency
|
||||
@@ -1963,9 +2021,22 @@ By default, nanobot only allows one spawned subagent at a time. When the limit i
|
||||
}
|
||||
```
|
||||
|
||||
Subagents also stop immediately when one of their tools returns an execution error. That default keeps failures visible to the parent agent. If your subagent workflows use tools that can fail transiently and should be retried or worked around by the model, disable hard-stop behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"failOnToolError": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. |
|
||||
| `agents.defaults.failOnToolError` | `true` | Stop a spawned subagent when a tool execution fails. Set to `false` to return tool errors to the subagent model so it can recover within the same run. |
|
||||
|
||||
|
||||
## Auto Compact
|
||||
|
||||
@@ -61,9 +61,12 @@ These fields answer different questions:
|
||||
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
||||
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
||||
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
||||
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
|
||||
|
||||
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
||||
|
||||
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
||||
|
||||
## Common Provider Patterns
|
||||
|
||||
### OpenRouter Gateway
|
||||
@@ -293,6 +296,8 @@ If you have more than one custom OpenAI-compatible endpoint, give each endpoint
|
||||
|
||||
Custom provider keys are treated as direct OpenAI-compatible providers. `apiBase` is required because nanobot cannot know the endpoint URL. `apiKey` is optional for local servers or private proxies that do not require one. Choose a name that does not conflict with a built-in provider name or alias, such as `openai`, `openai-codex`, `github-copilot`, or `lm-studio`. Do not set `apiType` on custom provider keys; `apiType` is only for `providers.openai`.
|
||||
|
||||
If your custom endpoint documents a nonstandard thinking toggle, set `providers.<name>.thinkingStyle` to `thinking_type`, `enable_thinking`, or `reasoning_split`; nanobot then maps `reasoningEffort` onto that provider-specific request body. Leave it unset for ordinary OpenAI-compatible endpoints.
|
||||
|
||||
This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`.
|
||||
|
||||
### Ollama
|
||||
@@ -420,6 +425,32 @@ nanobot provider login github-copilot
|
||||
|
||||
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
|
||||
|
||||
For OpenAI Codex, add `providers.openai_codex.proxy` only when Codex OAuth/token refresh or Codex API requests must use a proxy:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai_codex": {
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
}
|
||||
},
|
||||
"modelPresets": {
|
||||
"codex": {
|
||||
"provider": "openai_codex",
|
||||
"model": "gpt-5.1-codex",
|
||||
"reasoningEffort": "high"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"modelPreset": "codex"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you run the login command on a remote/headless machine and open the authorization URL in a local browser, paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted. See [`configuration.md#providers`](./configuration.md#providers) for the full OAuth provider notes.
|
||||
|
||||
## Provider Resolution
|
||||
|
||||
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
|
||||
|
||||
+2
-3
@@ -326,11 +326,10 @@ python -m pip install -e .
|
||||
nanobot --version
|
||||
```
|
||||
|
||||
If you use WhatsApp, rebuild the local bridge after upgrading:
|
||||
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.nanobot/bridge
|
||||
nanobot channels login whatsapp
|
||||
python -m pip install -e ".[whatsapp]"
|
||||
```
|
||||
|
||||
## First-Run Troubleshooting
|
||||
|
||||
+6
-1
@@ -118,7 +118,12 @@ to perform that task.
|
||||
|
||||
Automations are scheduled agent turns. They should be created from the chat,
|
||||
channel, or session where they are supposed to run so nanobot keeps the correct
|
||||
target context.
|
||||
target context. When an automation runs, it normally delivers the result back to
|
||||
that linked chat.
|
||||
|
||||
For recurring background checks that should stay quiet unless there is something
|
||||
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
|
||||
instead of creating a chat automation.
|
||||
|
||||
Use the Automations view to:
|
||||
|
||||
|
||||
@@ -34,6 +34,26 @@ class AutoCompact:
|
||||
ts = datetime.fromisoformat(ts)
|
||||
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
|
||||
|
||||
def _has_compactable_idle_tail(self, key: str) -> bool:
|
||||
session = self.sessions.get_or_create(key)
|
||||
tail = list(session.messages[session.last_consolidated:])
|
||||
if not tail:
|
||||
return False
|
||||
probe = Session(
|
||||
key=session.key,
|
||||
messages=tail,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
result = probe.retain_recent_legal_suffix(
|
||||
self._RECENT_SUFFIX_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||
return bool(messages_to_remove)
|
||||
|
||||
@staticmethod
|
||||
def _format_summary(text: str, last_active: datetime) -> str:
|
||||
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
|
||||
@@ -52,7 +72,8 @@ class AutoCompact:
|
||||
continue
|
||||
if key in active_session_keys:
|
||||
continue
|
||||
if self._is_expired(info.get("updated_at"), now):
|
||||
updated_at = info.get("updated_at")
|
||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||
self._archiving.add(key)
|
||||
schedule_background(self._archive(key))
|
||||
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Model-message governance for agent runner requests.
|
||||
|
||||
This module owns model-facing message shaping and tool-result content normalization.
|
||||
It may return copied messages or persisted-result placeholders, but it must not
|
||||
mutate an existing session history list in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import (
|
||||
estimate_message_tokens,
|
||||
estimate_prompt_tokens_chain,
|
||||
find_legal_message_start,
|
||||
maybe_persist_tool_result,
|
||||
truncate_text,
|
||||
)
|
||||
from nanobot.utils.runtime import ensure_nonempty_tool_result
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
SNIP_SAFETY_BUFFER = 1024
|
||||
MICROCOMPACT_KEEP_RECENT = 10
|
||||
MICROCOMPACT_MIN_CHARS = 500
|
||||
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
|
||||
COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||
PLACEHOLDER_TEXTS = frozenset({
|
||||
"[Previous assistant message omitted.]",
|
||||
})
|
||||
|
||||
|
||||
def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
||||
"""Whether a persisted OpenAI-style tool_call carries a usable name.
|
||||
|
||||
Mirrors ``ToolCallRequest.has_valid_name`` for the dict shape stored in
|
||||
message history: a degenerate call with ``name=None`` / ``""`` cannot be
|
||||
executed and is rejected by upstream APIs if replayed.
|
||||
"""
|
||||
if not isinstance(tool_call, dict):
|
||||
return False
|
||||
fn = tool_call.get("function")
|
||||
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
|
||||
return isinstance(name, str) and bool(name)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContextGovernanceConfig:
|
||||
provider: LLMProvider
|
||||
model: str
|
||||
tools: Any
|
||||
workspace: Path | None
|
||||
session_key: str | None
|
||||
max_tool_result_chars: int
|
||||
context_window_tokens: int | None = None
|
||||
context_block_limit: int | None = None
|
||||
max_tokens: int | None = None
|
||||
inflight_start_index: int = 0
|
||||
|
||||
|
||||
class ContextGovernor:
|
||||
"""Prepare model-copy messages while preserving persisted history."""
|
||||
|
||||
def prepare_for_model(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = self.strip_placeholder_assistant_messages(messages)
|
||||
updated = self.strip_malformed_tool_calls(updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
updated = self.backfill_missing_tool_results(updated)
|
||||
updated = self.apply_tool_result_budget(config, updated)
|
||||
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
||||
updated = self.snip_history(config, updated)
|
||||
updated = self.drop_orphan_tool_results(updated)
|
||||
return self.backfill_missing_tool_results(updated)
|
||||
|
||||
@staticmethod
|
||||
def input_budget(config: ContextGovernanceConfig) -> int:
|
||||
if not config.context_window_tokens:
|
||||
return 0
|
||||
|
||||
provider_max_tokens = getattr(
|
||||
getattr(config.provider, "generation", None),
|
||||
"max_tokens",
|
||||
4096,
|
||||
)
|
||||
max_output = config.max_tokens if isinstance(config.max_tokens, int) else (
|
||||
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
|
||||
)
|
||||
budget = config.context_block_limit or (
|
||||
config.context_window_tokens - max_output - SNIP_SAFETY_BUFFER
|
||||
)
|
||||
return budget if budget > 0 else 0
|
||||
|
||||
@staticmethod
|
||||
def normalize_tool_result(
|
||||
config: ContextGovernanceConfig,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Any,
|
||||
) -> Any:
|
||||
result = ensure_nonempty_tool_result(tool_name, result)
|
||||
if tool_name in TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
|
||||
return result
|
||||
try:
|
||||
content = maybe_persist_tool_result(
|
||||
config.workspace,
|
||||
config.session_key,
|
||||
tool_call_id,
|
||||
result,
|
||||
max_chars=config.max_tool_result_chars,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Tool result persist failed for {} in {}; using raw result",
|
||||
tool_call_id,
|
||||
config.session_key or "default",
|
||||
)
|
||||
content = result
|
||||
if isinstance(content, str) and len(content) > config.max_tool_result_chars:
|
||||
return truncate_text(content, config.max_tool_result_chars)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def strip_placeholder_assistant_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Remove assistant messages that are compaction placeholders.
|
||||
|
||||
Messages like ``[Previous assistant message omitted.]`` carry no useful
|
||||
context for the model and can cause it to repeatedly attempt tool calls
|
||||
that previously failed, producing malformed responses in a loop.
|
||||
Consecutive same-role messages that result from removal are handled
|
||||
downstream by the provider's merge-consecutive logic. Only the
|
||||
model-facing copy is repaired; the persisted transcript is untouched
|
||||
(a copy is returned, or the same list object when nothing changes).
|
||||
"""
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant":
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
text = content if isinstance(content, str) else ""
|
||||
is_placeholder = text.strip() in PLACEHOLDER_TEXTS
|
||||
has_tool_calls = bool(msg.get("tool_calls"))
|
||||
if is_placeholder and not has_tool_calls:
|
||||
if updated is None:
|
||||
updated = list(messages[:idx])
|
||||
logger.debug(
|
||||
"Stripping placeholder assistant message from history: {!r}",
|
||||
text[:60],
|
||||
)
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def strip_malformed_tool_calls(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop persisted assistant tool_calls whose name is missing/non-string.
|
||||
|
||||
A degenerate tool call (``name=None`` or ``""``) that slipped into the
|
||||
saved history before this guard existed gets replayed on every turn and
|
||||
makes upstream APIs reject the whole request
|
||||
(``messages.content.N.tool_use.name: Input should be a valid string``),
|
||||
permanently wedging the session. Removing the bad call here lets the
|
||||
existing orphan-result cleanup drop its now-dangling tool result, so a
|
||||
polluted session self-heals on its next turn. The persisted transcript
|
||||
is left untouched; only the model-facing copy is repaired (a copy is
|
||||
returned, or the same list object when nothing changes).
|
||||
"""
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "assistant":
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
calls = msg.get("tool_calls")
|
||||
if not calls:
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
|
||||
if len(kept) == len(calls):
|
||||
if updated is not None:
|
||||
updated.append(msg)
|
||||
continue
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
logger.warning(
|
||||
"Stripping {} malformed tool_call(s) with missing/non-string "
|
||||
"name from assistant history before request",
|
||||
len(calls) - len(kept),
|
||||
)
|
||||
repaired = dict(msg)
|
||||
if kept:
|
||||
repaired["tool_calls"] = kept
|
||||
else:
|
||||
repaired.pop("tool_calls", None)
|
||||
# An assistant turn with neither content nor any valid tool call is
|
||||
# itself invalid upstream; drop it entirely in that case.
|
||||
has_content = bool(repaired.get("content"))
|
||||
if not kept and not has_content:
|
||||
continue
|
||||
updated.append(repaired)
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def drop_orphan_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop tool results that have no matching assistant tool_call earlier in history."""
|
||||
declared: set[str] = set()
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
declared.add(str(tc["id"]))
|
||||
if role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid and str(tid) not in declared:
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(dict(msg))
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def backfill_missing_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert synthetic error results for assistant tool_calls with missing tool outputs."""
|
||||
declared: list[tuple[int, str, str]] = []
|
||||
fulfilled: set[str] = set()
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
name = ""
|
||||
func = tc.get("function")
|
||||
if isinstance(func, dict):
|
||||
name = func.get("name", "")
|
||||
declared.append((idx, str(tc["id"]), name))
|
||||
elif role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid:
|
||||
fulfilled.add(str(tid))
|
||||
|
||||
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
|
||||
if not missing:
|
||||
return messages
|
||||
|
||||
updated = list(messages)
|
||||
offset = 0
|
||||
for assistant_idx, call_id, name in missing:
|
||||
insert_at = assistant_idx + 1 + offset
|
||||
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
|
||||
insert_at += 1
|
||||
updated.insert(insert_at, {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": BACKFILL_CONTENT,
|
||||
})
|
||||
offset += 1
|
||||
return updated
|
||||
|
||||
def apply_tool_result_budget(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = messages
|
||||
for idx, message in enumerate(messages):
|
||||
if message.get("role") != "tool":
|
||||
continue
|
||||
normalized = self.normalize_tool_result(
|
||||
config,
|
||||
str(message.get("tool_call_id") or f"tool_{idx}"),
|
||||
str(message.get("name") or "tool"),
|
||||
message.get("content"),
|
||||
)
|
||||
if normalized != message.get("content"):
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = normalized
|
||||
return updated
|
||||
|
||||
def compact_inflight_overflow(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compact in-flight tool results only when the request would overflow."""
|
||||
budget = self.input_budget(config)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
tools = config.tools.get_definitions()
|
||||
updated = self._apply_recorded_compactions(messages, compacted_tool_call_ids)
|
||||
estimate, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
updated,
|
||||
tools,
|
||||
)
|
||||
if estimate <= budget:
|
||||
return updated
|
||||
|
||||
target = int(budget * INFLIGHT_COMPACT_TARGET_RATIO)
|
||||
candidates = self._inflight_compaction_candidates(
|
||||
config,
|
||||
updated,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
if not candidates:
|
||||
return updated
|
||||
|
||||
for candidate_idx, (idx, tool_call_id) in enumerate(candidates):
|
||||
is_newest_candidate = candidate_idx == len(candidates) - 1
|
||||
if is_newest_candidate and estimate <= budget:
|
||||
break
|
||||
if tool_call_id in compacted_tool_call_ids:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
compacted_tool_call_ids.add(tool_call_id)
|
||||
self._compact_tool_result_at(updated, idx)
|
||||
estimate, source = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
updated,
|
||||
tools,
|
||||
)
|
||||
if estimate <= target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"In-flight context compaction for {}: prompt={} budget={} target={} via {}, ids={}",
|
||||
config.session_key or "default",
|
||||
estimate,
|
||||
budget,
|
||||
target,
|
||||
source,
|
||||
len(compacted_tool_call_ids),
|
||||
)
|
||||
return updated
|
||||
|
||||
def snip_history(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not messages or not config.context_window_tokens:
|
||||
return messages
|
||||
|
||||
budget = self.input_budget(config)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
tools = config.tools.get_definitions()
|
||||
estimate, _ = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
messages,
|
||||
tools,
|
||||
)
|
||||
if estimate <= budget:
|
||||
return messages
|
||||
|
||||
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
|
||||
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
|
||||
if not non_system:
|
||||
return messages
|
||||
|
||||
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
||||
fixed_tokens, _ = estimate_prompt_tokens_chain(
|
||||
config.provider,
|
||||
config.model,
|
||||
system_messages,
|
||||
tools,
|
||||
)
|
||||
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
|
||||
kept: list[dict[str, Any]] = []
|
||||
kept_tokens = 0
|
||||
for message in reversed(non_system):
|
||||
msg_tokens = estimate_message_tokens(message)
|
||||
if kept and kept_tokens + msg_tokens > remaining_budget:
|
||||
break
|
||||
kept.append(message)
|
||||
kept_tokens += msg_tokens
|
||||
kept.reverse()
|
||||
|
||||
return system_messages + self._legal_history_tail(kept, non_system)
|
||||
|
||||
@staticmethod
|
||||
def _summary_for(message: dict[str, Any]) -> str:
|
||||
name = message.get("name", "tool")
|
||||
return f"[{name} result omitted from context]"
|
||||
|
||||
def _legal_history_tail(
|
||||
self,
|
||||
kept: list[dict[str, Any]],
|
||||
non_system: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
fallback = kept if kept else (non_system[-1:] if non_system else [])
|
||||
kept = self._user_tail(kept) or self._user_tail(non_system, last=True) or fallback
|
||||
|
||||
start = find_legal_message_start(kept)
|
||||
return kept[start:] if start else kept
|
||||
|
||||
@staticmethod
|
||||
def _user_tail(messages: list[dict[str, Any]], *, last: bool = False) -> list[dict[str, Any]]:
|
||||
indexes = range(len(messages) - 1, -1, -1) if last else range(len(messages))
|
||||
for idx in indexes:
|
||||
if messages[idx].get("role") == "user":
|
||||
return messages[idx:]
|
||||
return []
|
||||
|
||||
def _apply_recorded_compactions(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not compacted_tool_call_ids:
|
||||
return messages
|
||||
updated = messages
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
|
||||
continue
|
||||
summary = self._summary_for(msg)
|
||||
if msg.get("content") == summary:
|
||||
continue
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = summary
|
||||
return updated
|
||||
|
||||
def _inflight_compaction_candidates(
|
||||
self,
|
||||
config: ContextGovernanceConfig,
|
||||
messages: list[dict[str, Any]],
|
||||
compacted_tool_call_ids: set[str],
|
||||
) -> list[tuple[int, str]]:
|
||||
compactable: list[tuple[int, str]] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if idx < config.inflight_start_index:
|
||||
continue
|
||||
if msg.get("role") != "tool" or msg.get("name") not in COMPACTABLE_TOOLS:
|
||||
continue
|
||||
tool_call_id = msg.get("tool_call_id")
|
||||
if not tool_call_id or str(tool_call_id) in compacted_tool_call_ids:
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) < MICROCOMPACT_MIN_CHARS:
|
||||
continue
|
||||
compactable.append((idx, str(tool_call_id)))
|
||||
|
||||
if not compactable:
|
||||
return []
|
||||
primary_count = max(0, len(compactable) - MICROCOMPACT_KEEP_RECENT)
|
||||
primary = compactable[:primary_count]
|
||||
# Hard overflow beats the keep-recent preference. Return recent results
|
||||
# after stale ones so the newest result is naturally last.
|
||||
fallback = compactable[primary_count:]
|
||||
return primary + fallback
|
||||
|
||||
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
||||
messages[idx]["content"] = self._summary_for(messages[idx])
|
||||
+28
-6
@@ -57,7 +57,11 @@ from nanobot.session.goal_state import (
|
||||
sustained_goal_active,
|
||||
)
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.manager import (
|
||||
Session,
|
||||
SessionManager,
|
||||
replay_max_messages_for_context,
|
||||
)
|
||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
@@ -190,6 +194,7 @@ class AgentLoop:
|
||||
context_window_tokens: int | None = None,
|
||||
context_block_limit: int | None = None,
|
||||
max_tool_result_chars: int | None = None,
|
||||
fail_on_tool_error: bool | None = None,
|
||||
provider_retry_mode: str = "standard",
|
||||
tool_hint_max_length: int | None = None,
|
||||
cron_service: CronService | None = None,
|
||||
@@ -200,7 +205,6 @@ class AgentLoop:
|
||||
timezone: str | None = None,
|
||||
session_ttl_minutes: int = 0,
|
||||
consolidation_ratio: float = 0.5,
|
||||
max_messages: int = 120,
|
||||
hooks: list[AgentHook] | None = None,
|
||||
unified_session: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
@@ -214,6 +218,7 @@ class AgentLoop:
|
||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||
runtime_events: RuntimeEventBus | None = None,
|
||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||
restart_mode: str = "auto",
|
||||
):
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
@@ -223,6 +228,7 @@ class AgentLoop:
|
||||
self.runtime_events = runtime_events or RuntimeEventBus()
|
||||
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
|
||||
self.channels_config = channels_config
|
||||
self.restart_mode = restart_mode
|
||||
self.provider = provider
|
||||
self._provider_snapshot_loader = provider_snapshot_loader
|
||||
self._preset_snapshot_loader = preset_snapshot_loader
|
||||
@@ -287,10 +293,11 @@ class AgentLoop:
|
||||
disabled_skills=disabled_skills,
|
||||
max_iterations=self.max_iterations,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
fail_on_tool_error=fail_on_tool_error,
|
||||
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
||||
)
|
||||
self._unified_session = unified_session
|
||||
self._max_messages = max_messages if max_messages > 0 else 120
|
||||
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
|
||||
self._running = False
|
||||
self._mcp_servers = mcp_servers or {}
|
||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
||||
@@ -377,6 +384,7 @@ class AgentLoop:
|
||||
context_window_tokens=context_window_tokens,
|
||||
context_block_limit=defaults.context_block_limit,
|
||||
max_tool_result_chars=defaults.max_tool_result_chars,
|
||||
fail_on_tool_error=defaults.fail_on_tool_error,
|
||||
provider_retry_mode=defaults.provider_retry_mode,
|
||||
tool_hint_max_length=defaults.tool_hint_max_length,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
@@ -387,10 +395,10 @@ class AgentLoop:
|
||||
disabled_skills=defaults.disabled_skills,
|
||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||
consolidation_ratio=defaults.consolidation_ratio,
|
||||
max_messages=defaults.max_messages,
|
||||
tools_config=config.tools,
|
||||
model_presets=preset_helpers.configured_model_presets(config),
|
||||
model_preset=defaults.model_preset,
|
||||
restart_mode=config.gateway.restart_mode,
|
||||
provider_snapshot_loader=provider_snapshot_loader,
|
||||
preset_snapshot_loader=preset_snapshot_loader,
|
||||
**extra,
|
||||
@@ -418,6 +426,7 @@ class AgentLoop:
|
||||
self.runner.provider = provider
|
||||
self.subagents.set_provider(provider, model)
|
||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||
self._sync_replay_max_messages()
|
||||
self._provider_signature = snapshot.signature
|
||||
if publish_update and self._runtime_model_publisher is not None:
|
||||
self._runtime_model_publisher(
|
||||
@@ -431,6 +440,9 @@ class AgentLoop:
|
||||
)
|
||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
||||
|
||||
def _sync_replay_max_messages(self) -> None:
|
||||
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
|
||||
|
||||
def _refresh_provider_snapshot(self) -> None:
|
||||
if self._provider_snapshot_loader is None:
|
||||
return
|
||||
@@ -1180,7 +1192,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 +1470,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)
|
||||
@@ -1842,13 +1852,17 @@ class AgentLoop:
|
||||
)
|
||||
# Share the dispatch lock so direct calls serialize with bus turns.
|
||||
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||
pending: asyncio.Queue[InboundMessage] = asyncio.Queue(maxsize=20)
|
||||
try:
|
||||
async with lock:
|
||||
self._pending_queues[session_key] = pending
|
||||
self.subagents.set_direct_result_queue(session_key, pending)
|
||||
kwargs: dict[str, Any] = {
|
||||
"session_key": session_key,
|
||||
"on_progress": on_progress,
|
||||
"on_stream": on_stream,
|
||||
"on_stream_end": on_stream_end,
|
||||
"pending_queue": pending,
|
||||
"ephemeral": ephemeral,
|
||||
}
|
||||
if _run_extra_hooks_for_ephemeral:
|
||||
@@ -1862,5 +1876,13 @@ class AgentLoop:
|
||||
**kwargs,
|
||||
)
|
||||
finally:
|
||||
self.subagents.clear_direct_result_queue(session_key, pending)
|
||||
if self._pending_queues.get(session_key) is pending:
|
||||
self._pending_queues.pop(session_key, None)
|
||||
while True:
|
||||
try:
|
||||
await self.bus.publish_inbound(pending.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
await self._runtime_events().run_status_changed(msg, session_key, "idle")
|
||||
self._runtime_events().clear_turn(session_key)
|
||||
|
||||
+7
-13
@@ -33,7 +33,6 @@ if TYPE_CHECKING:
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryStore — pure file I/O layer
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -479,6 +478,9 @@ class MemoryStore:
|
||||
def set_last_dream_cursor(self, cursor: int) -> None:
|
||||
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
|
||||
|
||||
def get_latest_cursor(self) -> int:
|
||||
return max(self._next_cursor() - 1, 0)
|
||||
|
||||
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
||||
"""Build the Dream prompt with unprocessed history context.
|
||||
|
||||
@@ -709,17 +711,12 @@ class Consolidator:
|
||||
@staticmethod
|
||||
def _full_unconsolidated_history(
|
||||
session: Session,
|
||||
*,
|
||||
include_timestamps: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the whole unconsolidated tail for consolidation decisions."""
|
||||
unconsolidated_count = len(session.messages) - session.last_consolidated
|
||||
if unconsolidated_count <= 0:
|
||||
return []
|
||||
return session.get_history(
|
||||
max_messages=unconsolidated_count,
|
||||
include_timestamps=include_timestamps,
|
||||
)
|
||||
return session.get_history(max_messages=unconsolidated_count)
|
||||
|
||||
@staticmethod
|
||||
def _replay_overflow_boundary(
|
||||
@@ -794,7 +791,7 @@ class Consolidator:
|
||||
session: Session,
|
||||
) -> tuple[int, str]:
|
||||
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||
history = self._full_unconsolidated_history(session, include_timestamps=True)
|
||||
history = self._full_unconsolidated_history(session)
|
||||
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
|
||||
# Include archived summary in estimation so the budget accounts for it.
|
||||
meta = session.metadata.get("_last_summary")
|
||||
@@ -1008,7 +1005,6 @@ class Consolidator:
|
||||
|
||||
messages_to_summarize = list(session.messages[session.last_consolidated:])
|
||||
if not messages_to_summarize:
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -1020,12 +1016,11 @@ class Consolidator:
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
messages_to_keep = probe.messages
|
||||
messages_to_remove = dropped[already_consolidated:]
|
||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||
|
||||
if not messages_to_remove and not messages_to_keep:
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -1048,7 +1043,6 @@ class Consolidator:
|
||||
|
||||
session.messages = messages_to_keep
|
||||
session.last_consolidated = 0
|
||||
session.updated_at = datetime.now()
|
||||
self.sessions.save(session)
|
||||
|
||||
if messages_to_remove:
|
||||
|
||||
+125
-246
@@ -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,11 +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,
|
||||
@@ -49,7 +50,6 @@ from nanobot.utils.runtime import (
|
||||
build_finalization_retry_message,
|
||||
build_goal_continue_message,
|
||||
build_length_recovery_message,
|
||||
ensure_nonempty_tool_result,
|
||||
is_blank_text,
|
||||
repeated_external_lookup_error,
|
||||
repeated_workspace_violation_error,
|
||||
@@ -67,17 +67,6 @@ _MAX_EMPTY_RETRIES = 2
|
||||
_MAX_LENGTH_RECOVERIES = 3
|
||||
_MAX_INJECTIONS_PER_TURN = 3
|
||||
_MAX_INJECTION_CYCLES = 5
|
||||
_SNIP_SAFETY_BUFFER = 1024
|
||||
_MICROCOMPACT_KEEP_RECENT = 10
|
||||
_MICROCOMPACT_MIN_CHARS = 500
|
||||
_COMPACTABLE_TOOLS = frozenset({
|
||||
"read_file", "exec", "grep", "find_files",
|
||||
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
|
||||
})
|
||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||
_TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||
|
||||
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
||||
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
||||
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
||||
@@ -135,6 +124,7 @@ class AgentRunner:
|
||||
|
||||
def __init__(self, provider: LLMProvider):
|
||||
self.provider = provider
|
||||
self.context_governor = ContextGovernor()
|
||||
|
||||
@staticmethod
|
||||
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
|
||||
@@ -367,6 +357,19 @@ class AgentRunner:
|
||||
length_recovery_count = 0
|
||||
had_injections = False
|
||||
injection_cycles = 0
|
||||
compacted_tool_call_ids: set[str] = set()
|
||||
governance_config = ContextGovernanceConfig(
|
||||
provider=self.provider,
|
||||
model=spec.model,
|
||||
tools=spec.tools,
|
||||
workspace=spec.workspace,
|
||||
session_key=spec.session_key,
|
||||
max_tool_result_chars=spec.max_tool_result_chars,
|
||||
context_window_tokens=spec.context_window_tokens,
|
||||
context_block_limit=spec.context_block_limit,
|
||||
max_tokens=spec.max_tokens,
|
||||
inflight_start_index=len(spec.initial_messages),
|
||||
)
|
||||
|
||||
for iteration in range(spec.max_iterations):
|
||||
try:
|
||||
@@ -374,14 +377,11 @@ class AgentRunner:
|
||||
# may repair or compact historical messages for the model, but
|
||||
# those synthetic edits must not shift the append boundary used
|
||||
# later when the caller saves only the new turn.
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = self._microcompact(messages_for_model)
|
||||
messages_for_model = self._apply_tool_result_budget(spec, messages_for_model)
|
||||
messages_for_model = self._snip_history(spec, messages_for_model)
|
||||
# Snipping may have created new orphans; clean them up.
|
||||
messages_for_model = self._drop_orphan_tool_results(messages_for_model)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = self.context_governor.prepare_for_model(
|
||||
governance_config,
|
||||
messages,
|
||||
compacted_tool_call_ids,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Context governance failed on turn {} for {}; applying minimal repair",
|
||||
@@ -389,8 +389,18 @@ class AgentRunner:
|
||||
spec.session_key or "default",
|
||||
)
|
||||
try:
|
||||
messages_for_model = self._drop_orphan_tool_results(messages)
|
||||
messages_for_model = self._backfill_missing_tool_results(messages_for_model)
|
||||
messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
|
||||
messages
|
||||
)
|
||||
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
|
||||
messages_for_model
|
||||
)
|
||||
messages_for_model = ContextGovernor.drop_orphan_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
||||
messages_for_model
|
||||
)
|
||||
except Exception:
|
||||
messages_for_model = messages
|
||||
context = AgentHookContext(
|
||||
@@ -463,8 +473,8 @@ class AgentRunner:
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"name": tool_call.name,
|
||||
"content": self._normalize_tool_result(
|
||||
spec,
|
||||
"content": self.context_governor.normalize_tool_result(
|
||||
governance_config,
|
||||
tool_call.id,
|
||||
tool_call.name,
|
||||
result,
|
||||
@@ -723,6 +733,8 @@ class AgentRunner:
|
||||
messages: list[dict[str, Any]],
|
||||
hook: AgentHook,
|
||||
context: AgentHookContext,
|
||||
*,
|
||||
malformed_retry: bool = False,
|
||||
):
|
||||
timeout_s: float | None = spec.llm_timeout_s
|
||||
if timeout_s is None:
|
||||
@@ -865,8 +877,94 @@ class AgentRunner:
|
||||
)
|
||||
if progress_state and progress_state.get("reasoning_open"):
|
||||
await hook.emit_reasoning_end()
|
||||
dropped, all_dropped, original_finish_reason = (
|
||||
self._drop_malformed_tool_calls(response)
|
||||
)
|
||||
if (
|
||||
all_dropped
|
||||
and original_finish_reason in ("tool_calls", "function_call")
|
||||
and not malformed_retry
|
||||
):
|
||||
logger.warning(
|
||||
"Retrying LLM request after all {} malformed tool call(s) were dropped",
|
||||
dropped,
|
||||
)
|
||||
retry_messages = self._malformed_tool_call_retry_messages(
|
||||
messages, response.content,
|
||||
)
|
||||
return await self._request_model(
|
||||
spec, retry_messages, hook, context,
|
||||
malformed_retry=True,
|
||||
)
|
||||
if (
|
||||
all_dropped
|
||||
and original_finish_reason in ("tool_calls", "function_call")
|
||||
and malformed_retry
|
||||
):
|
||||
logger.warning(
|
||||
"Malformed tool calls persisted after retry; falling back to no-tools request",
|
||||
)
|
||||
fallback_messages = self._malformed_tool_call_retry_messages(
|
||||
messages, response.content,
|
||||
)
|
||||
return await self._request_no_tools(spec, fallback_messages)
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _drop_malformed_tool_calls(
|
||||
response: LLMResponse,
|
||||
) -> tuple[int, bool, str | None]:
|
||||
"""Strip tool calls whose name is missing/non-string from the response.
|
||||
|
||||
Returns (dropped_count, all_dropped, original_finish_reason).
|
||||
|
||||
A degenerate call (name=None or "") cannot be executed, and if it were
|
||||
persisted into the assistant message it would be replayed on every
|
||||
subsequent turn, causing upstream validation errors
|
||||
(``tool_use.name: Input should be a valid string``) that permanently
|
||||
wedge the session. Dropping it here keeps it out of execution, the
|
||||
assistant message, and the saved history in one place.
|
||||
"""
|
||||
calls = getattr(response, "tool_calls", None)
|
||||
if not calls:
|
||||
return (0, False, getattr(response, "finish_reason", None))
|
||||
valid = [tc for tc in calls if tc.has_valid_name()]
|
||||
if len(valid) == len(calls):
|
||||
return (0, False, getattr(response, "finish_reason", None))
|
||||
dropped = len(calls) - len(valid)
|
||||
original_finish_reason = getattr(response, "finish_reason", None)
|
||||
logger.warning(
|
||||
"Dropped {} malformed tool call(s) with missing/non-string name "
|
||||
"from LLM response (finish_reason={!r})",
|
||||
dropped,
|
||||
original_finish_reason,
|
||||
)
|
||||
response.tool_calls = valid
|
||||
if not valid:
|
||||
response.finish_reason = "stop"
|
||||
return (dropped, not valid, original_finish_reason)
|
||||
|
||||
@staticmethod
|
||||
def _malformed_tool_call_retry_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
assistant_text: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
retry_messages = list(messages)
|
||||
note = (
|
||||
"The previous model response attempted to call tools, but every tool call "
|
||||
"was malformed: the tool_use blocks had missing or non-string tool names. "
|
||||
"Do not answer with a promise to use tools. Either call the required tools again "
|
||||
"using valid tool names from the provided tool list and JSON object inputs, or give "
|
||||
"a final answer only if no tool is required."
|
||||
)
|
||||
if assistant_text:
|
||||
note += (
|
||||
f"\n\nPrevious assistant text before the malformed calls:\n"
|
||||
f"{assistant_text}"
|
||||
)
|
||||
retry_messages.append({"role": "user", "content": note})
|
||||
return retry_messages
|
||||
|
||||
async def _request_finalization_retry(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
@@ -1334,225 +1432,6 @@ class AgentRunner:
|
||||
return
|
||||
messages.append(build_assistant_message(_PERSISTED_MODEL_ERROR_PLACEHOLDER))
|
||||
|
||||
def _normalize_tool_result(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Any,
|
||||
) -> Any:
|
||||
result = ensure_nonempty_tool_result(tool_name, result)
|
||||
if tool_name in _TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
|
||||
# Exempt tools bound their own output; skip generic offload and truncation.
|
||||
return result
|
||||
try:
|
||||
content = maybe_persist_tool_result(
|
||||
spec.workspace,
|
||||
spec.session_key,
|
||||
tool_call_id,
|
||||
result,
|
||||
max_chars=spec.max_tool_result_chars,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Tool result persist failed for {} in {}; using raw result",
|
||||
tool_call_id,
|
||||
spec.session_key or "default",
|
||||
)
|
||||
content = result
|
||||
if isinstance(content, str) and len(content) > spec.max_tool_result_chars:
|
||||
return truncate_text(content, spec.max_tool_result_chars)
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _drop_orphan_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Drop tool results that have no matching assistant tool_call earlier in the history."""
|
||||
declared: set[str] = set()
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
declared.add(str(tc["id"]))
|
||||
if role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid and str(tid) not in declared:
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages[:idx]]
|
||||
continue
|
||||
if updated is not None:
|
||||
updated.append(dict(msg))
|
||||
|
||||
if updated is None:
|
||||
return messages
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _backfill_missing_tool_results(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert synthetic error results for orphaned tool_use blocks."""
|
||||
declared: list[tuple[int, str, str]] = [] # (assistant_idx, call_id, name)
|
||||
fulfilled: set[str] = set()
|
||||
for idx, msg in enumerate(messages):
|
||||
role = msg.get("role")
|
||||
if role == "assistant":
|
||||
for tc in msg.get("tool_calls") or []:
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
name = ""
|
||||
func = tc.get("function")
|
||||
if isinstance(func, dict):
|
||||
name = func.get("name", "")
|
||||
declared.append((idx, str(tc["id"]), name))
|
||||
elif role == "tool":
|
||||
tid = msg.get("tool_call_id")
|
||||
if tid:
|
||||
fulfilled.add(str(tid))
|
||||
|
||||
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
|
||||
if not missing:
|
||||
return messages
|
||||
|
||||
updated = list(messages)
|
||||
offset = 0
|
||||
for assistant_idx, call_id, name in missing:
|
||||
insert_at = assistant_idx + 1 + offset
|
||||
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
|
||||
insert_at += 1
|
||||
updated.insert(insert_at, {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": name,
|
||||
"content": _BACKFILL_CONTENT,
|
||||
})
|
||||
offset += 1
|
||||
return updated
|
||||
|
||||
@staticmethod
|
||||
def _microcompact(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Replace old compactable tool results with one-line summaries."""
|
||||
compactable_indices: list[int] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if msg.get("role") == "tool" and msg.get("name") in _COMPACTABLE_TOOLS:
|
||||
compactable_indices.append(idx)
|
||||
|
||||
if len(compactable_indices) <= _MICROCOMPACT_KEEP_RECENT:
|
||||
return messages
|
||||
|
||||
stale = compactable_indices[: len(compactable_indices) - _MICROCOMPACT_KEEP_RECENT]
|
||||
updated: list[dict[str, Any]] | None = None
|
||||
for idx in stale:
|
||||
msg = messages[idx]
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or len(content) < _MICROCOMPACT_MIN_CHARS:
|
||||
continue
|
||||
name = msg.get("name", "tool")
|
||||
summary = f"[{name} result omitted from context]"
|
||||
if updated is None:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = summary
|
||||
|
||||
return updated if updated is not None else messages
|
||||
|
||||
def _apply_tool_result_budget(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
updated = messages
|
||||
for idx, message in enumerate(messages):
|
||||
if message.get("role") != "tool":
|
||||
continue
|
||||
normalized = self._normalize_tool_result(
|
||||
spec,
|
||||
str(message.get("tool_call_id") or f"tool_{idx}"),
|
||||
str(message.get("name") or "tool"),
|
||||
message.get("content"),
|
||||
)
|
||||
if normalized != message.get("content"):
|
||||
if updated is messages:
|
||||
updated = [dict(m) for m in messages]
|
||||
updated[idx]["content"] = normalized
|
||||
return updated
|
||||
|
||||
def _snip_history(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not messages or not spec.context_window_tokens:
|
||||
return messages
|
||||
|
||||
provider_max_tokens = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
|
||||
max_output = spec.max_tokens if isinstance(spec.max_tokens, int) else (
|
||||
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
|
||||
)
|
||||
budget = spec.context_block_limit or (
|
||||
spec.context_window_tokens - max_output - _SNIP_SAFETY_BUFFER
|
||||
)
|
||||
if budget <= 0:
|
||||
return messages
|
||||
|
||||
estimate, _ = estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
spec.model,
|
||||
messages,
|
||||
spec.tools.get_definitions(),
|
||||
)
|
||||
if estimate <= budget:
|
||||
return messages
|
||||
|
||||
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
|
||||
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
|
||||
if not non_system:
|
||||
return messages
|
||||
|
||||
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
|
||||
fixed_tokens, _ = estimate_prompt_tokens_chain(
|
||||
self.provider,
|
||||
spec.model,
|
||||
system_messages,
|
||||
spec.tools.get_definitions(),
|
||||
)
|
||||
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
|
||||
kept: list[dict[str, Any]] = []
|
||||
kept_tokens = 0
|
||||
for message in reversed(non_system):
|
||||
msg_tokens = estimate_message_tokens(message)
|
||||
if kept and kept_tokens + msg_tokens > remaining_budget:
|
||||
break
|
||||
kept.append(message)
|
||||
kept_tokens += msg_tokens
|
||||
kept.reverse()
|
||||
|
||||
if kept:
|
||||
for i, message in enumerate(kept):
|
||||
if message.get("role") == "user":
|
||||
kept = kept[i:]
|
||||
break
|
||||
else:
|
||||
# Recover nearest user message from outside the kept window;
|
||||
# GLM rejects system→assistant (error 1214). Budget is
|
||||
# intentionally exceeded — oversized beats invalid.
|
||||
for idx in range(len(non_system) - 1, -1, -1):
|
||||
if non_system[idx].get("role") == "user":
|
||||
kept = non_system[idx:]
|
||||
break
|
||||
# If no user exists at all, _enforce_role_alternation
|
||||
# will insert a synthetic one as a safety net.
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
if not kept:
|
||||
kept = non_system[-min(len(non_system), 4) :]
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
return system_messages + kept
|
||||
|
||||
def _partition_tool_batches(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
|
||||
@@ -86,6 +86,7 @@ class SubagentManager:
|
||||
disabled_skills: list[str] | None = None,
|
||||
max_iterations: int | None = None,
|
||||
max_concurrent_subagents: int | None = None,
|
||||
fail_on_tool_error: bool | None = None,
|
||||
llm_wall_timeout_for_session: Callable[[str | None], float | None] | None = None,
|
||||
):
|
||||
defaults = AgentDefaults()
|
||||
@@ -107,11 +108,32 @@ 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]] = {}
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
self._direct_result_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
|
||||
|
||||
def set_direct_result_queue(
|
||||
self,
|
||||
session_key: str,
|
||||
queue: asyncio.Queue[InboundMessage],
|
||||
) -> None:
|
||||
self._direct_result_queues[session_key] = queue
|
||||
|
||||
def clear_direct_result_queue(
|
||||
self,
|
||||
session_key: str,
|
||||
queue: asyncio.Queue[InboundMessage],
|
||||
) -> None:
|
||||
if self._direct_result_queues.get(session_key) is queue:
|
||||
self._direct_result_queues.pop(session_key, None)
|
||||
|
||||
def _subagent_tools_config(self) -> ToolsConfig:
|
||||
"""Build a ToolsConfig scoped for subagent use."""
|
||||
@@ -251,7 +273,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,
|
||||
@@ -329,6 +351,10 @@ class SubagentManager:
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
if queue := self._direct_result_queues.get(override):
|
||||
await queue.put(msg)
|
||||
logger.debug("Subagent [{}] queued result directly for {}", task_id, override)
|
||||
return
|
||||
await self.bus.publish_inbound(msg)
|
||||
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
|
||||
|
||||
|
||||
@@ -97,7 +97,8 @@ class _GoalToolsMixin(ContextAware):
|
||||
"Sustained objective for this chat thread. First read the built-in **long-goal** skill, "
|
||||
"especially its Start fast section, then call this promptly once the user's intent is clear. "
|
||||
"The goal must still be idempotent, self-contained, bounded, and explicit about done-ness; "
|
||||
"do not delay this tool call to over-plan, research, or decide execution details.",
|
||||
"do not delay this tool call to over-plan, research, or decide execution details. "
|
||||
"Do not use this for a single current-turn answer, including one that uses spawn subagents.",
|
||||
max_length=12_000,
|
||||
),
|
||||
ui_summary=StringSchema(
|
||||
@@ -139,6 +140,8 @@ class LongTaskTool(Tool, _GoalToolsMixin):
|
||||
def description(self) -> str:
|
||||
return (
|
||||
"Mark this thread as a sustained long-running task. "
|
||||
"Use only when the user wants work to persist across future turns or background check-ins; "
|
||||
"do not use for a single current-turn answer, including one that uses spawn subagents. "
|
||||
"First read the built-in **long-goal** skill, especially its Start fast section; then call this "
|
||||
"as soon as the user's intent is clear. Write a good idempotent goal, but do not delay the tool "
|
||||
"call with long planning, research, or execution-detail thinking. "
|
||||
|
||||
+171
-35
@@ -1,6 +1,7 @@
|
||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -165,12 +166,31 @@ async def _probe_http_url(url: str, timeout: float = 3.0) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _redact_url(url: str) -> str:
|
||||
"""Strip credentials and query/fragment before logging an MCP URL.
|
||||
|
||||
Server URLs may embed secrets (``https://user:token@host/sse`` or a
|
||||
``?token=`` query). Some deployments also put opaque tokens in the path, so
|
||||
log only the origin and a path placeholder.
|
||||
"""
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
hostname = parts.hostname or ""
|
||||
netloc = f"[{hostname}]" if ":" in hostname else hostname
|
||||
if parts.port:
|
||||
netloc = f"{netloc}:{parts.port}"
|
||||
path = "/..." if parts.path and parts.path != "/" else parts.path
|
||||
return urllib.parse.urlunsplit((parts.scheme, netloc, path, "", ""))
|
||||
except Exception:
|
||||
return "<redacted-url>"
|
||||
|
||||
|
||||
async def _validate_mcp_request_url(request: httpx.Request) -> None:
|
||||
"""Validate each outgoing MCP HTTP request, including redirect targets."""
|
||||
ok, error = validate_url_target(str(request.url))
|
||||
if not ok:
|
||||
raise httpx.RequestError(
|
||||
f"Blocked unsafe MCP URL {request.url} ({error})",
|
||||
f"Blocked unsafe MCP URL {_redact_url(str(request.url))} ({error})",
|
||||
request=request,
|
||||
)
|
||||
|
||||
@@ -313,6 +333,52 @@ class _MCPWrapperBase(Tool):
|
||||
return True
|
||||
|
||||
|
||||
def _image_block_data_url(block: Any, types: Any) -> str | None:
|
||||
"""Return a base64 ``data:`` URL for an MCP image-bearing content block.
|
||||
|
||||
Handles ``ImageContent`` directly and ``EmbeddedResource`` wrapping a binary
|
||||
blob with an ``image/*`` MIME type. Returns ``None`` for anything else.
|
||||
``getattr`` guards keep this safe when the installed/faked ``mcp`` SDK does
|
||||
not expose a given type.
|
||||
"""
|
||||
image_cls = getattr(types, "ImageContent", None)
|
||||
if image_cls is not None and isinstance(block, image_cls):
|
||||
mime = getattr(block, "mimeType", None) or "image/png"
|
||||
return f"data:{mime};base64,{block.data}"
|
||||
|
||||
embedded_cls = getattr(types, "EmbeddedResource", None)
|
||||
blob_cls = getattr(types, "BlobResourceContents", None)
|
||||
if embedded_cls is not None and isinstance(block, embedded_cls):
|
||||
resource = getattr(block, "resource", None)
|
||||
if blob_cls is not None and isinstance(resource, blob_cls):
|
||||
mime = getattr(resource, "mimeType", None) or ""
|
||||
if isinstance(mime, str) and mime.startswith("image/"):
|
||||
return f"data:{mime};base64,{resource.blob}"
|
||||
return None
|
||||
|
||||
|
||||
def _mcp_image_tool_result(text_parts: list[str], artifacts: list[dict[str, Any]]) -> str:
|
||||
"""Build the compact tool result for an MCP call that returned image(s).
|
||||
|
||||
The base64 stays out of the model context entirely — only artifact paths and
|
||||
metadata are returned, so the result is small and the channel can deliver the
|
||||
saved file via the message tool.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"artifacts": artifacts,
|
||||
"next_step": (
|
||||
"These images were returned by an MCP tool and saved as local artifacts. "
|
||||
"Call the message tool with the artifact 'path' values in the media "
|
||||
"parameter to deliver the images to the user. Do not paste base64 or raw "
|
||||
"paths into your reply unless the user asks for debug details."
|
||||
),
|
||||
}
|
||||
text = "\n".join(part for part in text_parts if part)
|
||||
if text:
|
||||
payload["text"] = text
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
class MCPToolWrapper(_MCPWrapperBase):
|
||||
"""Wraps a single MCP server tool as a nanobot Tool."""
|
||||
|
||||
@@ -340,8 +406,6 @@ class MCPToolWrapper(_MCPWrapperBase):
|
||||
return self._parameters
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
from mcp import types
|
||||
|
||||
retried_transient = False
|
||||
refreshed_session = False
|
||||
while True:
|
||||
@@ -396,17 +460,63 @@ class MCPToolWrapper(_MCPWrapperBase):
|
||||
)
|
||||
return f"(MCP tool call failed: {type(exc).__name__})"
|
||||
else:
|
||||
# Success — extract result
|
||||
parts = []
|
||||
for block in result.content:
|
||||
if isinstance(block, types.TextContent):
|
||||
parts.append(block.text)
|
||||
else:
|
||||
parts.append(str(block))
|
||||
return "\n".join(parts) or "(no output)"
|
||||
# Success — extract text and persist any image content as artifacts.
|
||||
return self._render_call_result(result.content, kwargs)
|
||||
|
||||
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
|
||||
|
||||
def _render_call_result(self, content: Any, arguments: Mapping[str, Any]) -> str:
|
||||
"""Turn MCP content blocks into a tool result string.
|
||||
|
||||
Text is concatenated as before. Image blocks are decoded and saved as
|
||||
local artifacts (mirroring the built-in image generation tool) so the
|
||||
model can deliver them via the message tool instead of trying to forward
|
||||
base64 — which would be truncated and bloat the context window.
|
||||
"""
|
||||
from mcp import types
|
||||
|
||||
text_parts: list[str] = []
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
for block in content:
|
||||
if isinstance(block, types.TextContent):
|
||||
text_parts.append(block.text)
|
||||
continue
|
||||
data_url = _image_block_data_url(block, types)
|
||||
if data_url is not None:
|
||||
stored = self._store_image_block(data_url, arguments)
|
||||
if stored is not None:
|
||||
artifacts.append(stored)
|
||||
else:
|
||||
text_parts.append("(MCP tool returned an image that could not be stored)")
|
||||
continue
|
||||
text_parts.append(str(block))
|
||||
|
||||
if artifacts:
|
||||
return _mcp_image_tool_result(text_parts, artifacts)
|
||||
return "\n".join(text_parts) or "(no output)"
|
||||
|
||||
def _store_image_block(
|
||||
self, data_url: str, arguments: Mapping[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist one image data URL as an artifact; return its metadata or None."""
|
||||
from nanobot.utils.artifacts import ArtifactError, store_generated_image_artifact
|
||||
|
||||
try:
|
||||
return store_generated_image_artifact(
|
||||
data_url,
|
||||
prompt=str(arguments.get("prompt") or ""),
|
||||
model=str(arguments.get("model") or ""),
|
||||
save_dir="generated",
|
||||
provider=f"mcp:{self._server_name}",
|
||||
)
|
||||
except (ArtifactError, OSError) as exc:
|
||||
logger.warning(
|
||||
"MCP tool '{}' returned an image that could not be stored: {}",
|
||||
self._name,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class MCPResourceWrapper(_MCPWrapperBase):
|
||||
"""Wraps an MCP resource URI as a read-only nanobot Tool."""
|
||||
@@ -683,7 +793,7 @@ async def connect_mcp_servers(
|
||||
logger.warning(
|
||||
"MCP server '{}': blocked unsafe URL {} ({})",
|
||||
name,
|
||||
cfg.url,
|
||||
_redact_url(cfg.url),
|
||||
error,
|
||||
)
|
||||
await server_stack.aclose()
|
||||
@@ -704,7 +814,7 @@ async def connect_mcp_servers(
|
||||
read, write = await server_stack.enter_async_context(stdio_client(params))
|
||||
elif transport_type == "sse":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, cfg.url)
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
@@ -731,7 +841,7 @@ async def connect_mcp_servers(
|
||||
)
|
||||
elif transport_type == "streamableHttp":
|
||||
if not await _probe_http_url(cfg.url):
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, cfg.url)
|
||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||
await server_stack.aclose()
|
||||
return name, None
|
||||
|
||||
@@ -797,31 +907,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
|
||||
|
||||
@@ -438,6 +438,9 @@ class MyTool(Tool, ContextAware):
|
||||
setattr(self._runtime_state, key, value)
|
||||
if key == "model":
|
||||
self._runtime_state._active_preset = None
|
||||
sync_replay = getattr(self._runtime_state, "_sync_replay_max_messages", None)
|
||||
if key == "context_window_tokens" and callable(sync_replay):
|
||||
sync_replay()
|
||||
if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"):
|
||||
self._runtime_state._sync_subagent_runtime_limits()
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
|
||||
@@ -93,8 +93,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(
|
||||
@@ -432,7 +432,7 @@ class ExecTool(Tool):
|
||||
env=env,
|
||||
timeout=effective_timeout,
|
||||
shell_program=shell_program,
|
||||
login=True if login is None else login,
|
||||
login=False if login is None else login,
|
||||
)
|
||||
|
||||
def _compose_path(self, current_path: str) -> str:
|
||||
@@ -461,7 +461,7 @@ class ExecTool(Tool):
|
||||
async def _spawn(
|
||||
command: str, cwd: str, env: dict[str, str],
|
||||
shell_program: str | None = None,
|
||||
login: bool = True,
|
||||
login: bool = False,
|
||||
*,
|
||||
stdin: int = asyncio.subprocess.DEVNULL,
|
||||
) -> asyncio.subprocess.Process:
|
||||
@@ -541,8 +541,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 +603,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:
|
||||
|
||||
@@ -63,6 +63,9 @@ class SpawnTool(Tool, ContextAware):
|
||||
return (
|
||||
"Spawn a subagent to handle a task in the background. "
|
||||
"Use this for complex or time-consuming tasks that can run independently. "
|
||||
"For MapReduce-style work, spawn only independent map slices with clear "
|
||||
"boundaries; keep reduction, conflict resolution, and final user-facing "
|
||||
"synthesis in the main agent. "
|
||||
"The subagent will complete the task and report back when done. "
|
||||
"For deliverables or existing projects, inspect the workspace first "
|
||||
"and use a dedicated subdirectory when helpful."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
@@ -911,7 +914,7 @@ class TelegramChannel(BaseChannel):
|
||||
# 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 not getattr(self, "_rich_send_disabled", False):
|
||||
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}
|
||||
|
||||
@@ -129,6 +129,13 @@ class WeixinConfig(Base):
|
||||
token: str = "" # Manually set token, or obtained via QR login
|
||||
state_dir: str = "" # Default: ~/.nanobot/weixin/
|
||||
poll_timeout: int = DEFAULT_LONG_POLL_TIMEOUT_S # seconds for long-poll
|
||||
# Default on: WeChat iLink has no native incremental delivery (send_delta is
|
||||
# buffered and the final answer is still sent in one shot), so streaming has
|
||||
# zero user-facing effect here — it only switches the LLM call to the
|
||||
# streaming API. That avoids upstream Anthropic relays that drop tool_use
|
||||
# id/name/input on the non-streaming Messages path (a common third-party
|
||||
# relay bug). Set to false only if a relay's streaming/SSE path is broken.
|
||||
streaming: bool = True
|
||||
|
||||
|
||||
class WeixinChannel(BaseChannel):
|
||||
@@ -167,6 +174,10 @@ class WeixinChannel(BaseChannel):
|
||||
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
||||
self._context_token_at: dict[str, float] = {}
|
||||
self._pending_tool_hints: dict[str, list[str]] = {}
|
||||
# Buffers streamed content deltas per chat. WeChat iLink has no native
|
||||
# incremental delivery, so when streaming is enabled we accumulate the
|
||||
# deltas and flush the full reply in one shot at _stream_end.
|
||||
self._stream_buffers: dict[str, list[str]] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State persistence
|
||||
@@ -1223,14 +1234,38 @@ class WeixinChannel(BaseChannel):
|
||||
async def send_delta(
|
||||
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Weixin iLink does not support native streaming deltas.
|
||||
"""Deliver a streamed reply to WeChat.
|
||||
|
||||
We only hook ``_stream_end`` so buffered tool hints are flushed even
|
||||
when the final answer carries the ``_streamed`` flag and bypasses
|
||||
:meth:`send`.
|
||||
WeChat iLink has no native incremental delivery, and the manager
|
||||
bypasses :meth:`send` for the ``_streamed`` final answer. So we
|
||||
accumulate the content deltas here and flush the full reply as a
|
||||
single message at ``_stream_end`` — otherwise a streamed reply would
|
||||
never reach the user. Reasoning deltas are invisible in WeChat and are
|
||||
dropped.
|
||||
"""
|
||||
if metadata and metadata.get("_stream_end"):
|
||||
await self._flush_tool_hints(chat_id)
|
||||
meta = metadata or {}
|
||||
if meta.get("_reasoning_delta") or meta.get("_reasoning"):
|
||||
return
|
||||
is_end = meta.get("_stream_end")
|
||||
# Accumulate intermediate deltas. The _stream_end message's own content
|
||||
# (present when the manager coalesces deltas into the end message) is
|
||||
# folded into `full` below instead of appended here, so a send retry
|
||||
# recomputes the same `full` from an unchanged buffer rather than
|
||||
# double-counting that delta.
|
||||
if delta and not is_end:
|
||||
self._stream_buffers.setdefault(chat_id, []).append(delta)
|
||||
if not is_end:
|
||||
return
|
||||
full = ("".join(self._stream_buffers.get(chat_id, [])) + (delta or "")).strip()
|
||||
await self._flush_tool_hints(chat_id)
|
||||
if full:
|
||||
# Send before clearing the buffer: if the send raises, the buffer is
|
||||
# left intact so ChannelManager._send_with_retry can re-deliver the
|
||||
# same _stream_end message instead of silently losing the reply.
|
||||
await self.send(
|
||||
OutboundMessage(channel=self.name, chat_id=chat_id, content=full)
|
||||
)
|
||||
self._stream_buffers.pop(chat_id, None)
|
||||
|
||||
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
||||
"""Start typing indicator immediately when a message is received."""
|
||||
|
||||
+616
-329
File diff suppressed because it is too large
Load Diff
+66
-1
@@ -154,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.
|
||||
|
||||
@@ -1165,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:
|
||||
@@ -1737,6 +1744,11 @@ _PROVIDER_DISPLAY: dict[str, str] = {
|
||||
"github_copilot": "GitHub Copilot",
|
||||
}
|
||||
|
||||
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
||||
"openai_codex": "openai-codex/gpt-5.4-mini",
|
||||
"github_copilot": "github-copilot/gpt-5.4-mini",
|
||||
}
|
||||
|
||||
|
||||
def _register_login(name: str):
|
||||
"""Register an OAuth login handler."""
|
||||
@@ -1768,9 +1780,51 @@ def _resolve_oauth_provider(provider: str):
|
||||
return spec
|
||||
|
||||
|
||||
def _set_oauth_provider_as_main(
|
||||
provider_name: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
config_path: str | None = None,
|
||||
) -> None:
|
||||
"""Persist an OAuth provider as the active agent provider."""
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
|
||||
|
||||
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
||||
if resolved_config_path is not None:
|
||||
set_config_path(resolved_config_path)
|
||||
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
||||
|
||||
config = load_config(resolved_config_path)
|
||||
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
||||
config.agents.defaults.model_preset = None
|
||||
config.agents.defaults.provider = provider_name
|
||||
config.agents.defaults.model = selected_model
|
||||
save_config(config, resolved_config_path)
|
||||
|
||||
saved_path = resolved_config_path or get_config_path()
|
||||
console.print(
|
||||
f"[green]✓ Set {provider_name.replace('_', '-')} as the main provider[/green] "
|
||||
f"[dim]{selected_model}[/dim]"
|
||||
)
|
||||
console.print(f"[dim]Saved: {saved_path}[/dim]")
|
||||
|
||||
|
||||
@provider_app.command("login")
|
||||
def provider_login(
|
||||
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
||||
set_main: bool = typer.Option(
|
||||
False,
|
||||
"--set-main",
|
||||
"--main",
|
||||
help="Set this OAuth provider as the active agent provider after login",
|
||||
),
|
||||
model: str | None = typer.Option(
|
||||
None,
|
||||
"--model",
|
||||
"-m",
|
||||
help="Model to use when setting this provider as the active provider",
|
||||
),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
):
|
||||
"""Authenticate with an OAuth provider."""
|
||||
spec = _resolve_oauth_provider(provider)
|
||||
@@ -1782,6 +1836,8 @@ def provider_login(
|
||||
|
||||
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
|
||||
handler()
|
||||
if set_main or model:
|
||||
_set_oauth_provider_as_main(spec.name, model=model, config_path=config)
|
||||
|
||||
|
||||
@provider_app.command("logout")
|
||||
@@ -1805,14 +1861,23 @@ def _login_openai_codex() -> None:
|
||||
try:
|
||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
proxy = None
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
except ValueError as e:
|
||||
console.print(f"[red]{e}[/red]")
|
||||
raise typer.Exit(1) from e
|
||||
token = None
|
||||
with suppress(Exception):
|
||||
token = get_token()
|
||||
token = get_token(proxy=proxy)
|
||||
if not (token and token.access):
|
||||
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||
token = login_oauth_interactive(
|
||||
print_fn=lambda s: console.print(s),
|
||||
prompt_fn=lambda s: typer.prompt(s),
|
||||
proxy=proxy,
|
||||
)
|
||||
if not (token and token.access):
|
||||
console.print("[red]✗ Authentication failed[/red]")
|
||||
|
||||
+35
-7
@@ -762,13 +762,11 @@ def _handle_model_preset_field(
|
||||
setattr(working_model, field_name, new_value)
|
||||
|
||||
|
||||
def _handle_provider_field(
|
||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||
def _set_field_from_choices(
|
||||
working_model: BaseModel, field_name: str, field_display: str,
|
||||
choices: list[str], default_choice: str
|
||||
) -> None:
|
||||
"""Handle the 'provider' field with a list of registered providers."""
|
||||
provider_names = sorted(_get_provider_names().keys())
|
||||
choices = ["auto"] + provider_names
|
||||
default_choice = str(current_value) if current_value else "auto"
|
||||
"""Prompt to pick one of ``choices`` and set the field (no-op on back/cancel)."""
|
||||
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||
if new_value is _BACK_PRESSED:
|
||||
return
|
||||
@@ -776,6 +774,15 @@ def _handle_provider_field(
|
||||
setattr(working_model, field_name, new_value)
|
||||
|
||||
|
||||
def _handle_provider_field(
|
||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||
) -> None:
|
||||
"""Handle the 'provider' field with a list of registered LLM providers."""
|
||||
choices = ["auto"] + sorted(_get_provider_names().keys())
|
||||
default_choice = str(current_value) if current_value else "auto"
|
||||
_set_field_from_choices(working_model, field_name, field_display, choices, default_choice)
|
||||
|
||||
|
||||
def _handle_fallback_models_field(
|
||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||
) -> None:
|
||||
@@ -836,6 +843,17 @@ def _handle_fallback_models_field(
|
||||
items.clear()
|
||||
|
||||
|
||||
def _handle_search_provider_field(
|
||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||
) -> None:
|
||||
"""Handle the web-search 'provider' field with the search-engine list."""
|
||||
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
||||
|
||||
choices = [opt["name"] for opt in SEARCH_PROVIDER_OPTIONS]
|
||||
default_choice = current_value if current_value in choices else choices[0]
|
||||
_set_field_from_choices(working_model, field_name, field_display, choices, default_choice)
|
||||
|
||||
|
||||
_FIELD_HANDLERS: dict[str, Any] = {
|
||||
"model": _handle_model_field,
|
||||
"context_window_tokens": _handle_context_window_field,
|
||||
@@ -845,6 +863,16 @@ _FIELD_HANDLERS: dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
def _resolve_field_handler(model: BaseModel, field_name: str) -> Any:
|
||||
"""Resolve the handler for a field. WebSearchConfig shares the bare "provider"
|
||||
name with LLM configs but needs the search-engine picker, not the LLM list."""
|
||||
if field_name == "provider":
|
||||
from nanobot.agent.tools.web import WebSearchConfig
|
||||
if isinstance(model, WebSearchConfig):
|
||||
return _handle_search_provider_field
|
||||
return _FIELD_HANDLERS.get(field_name)
|
||||
|
||||
|
||||
def _is_str_or_none(annotation: Any) -> bool:
|
||||
"""Check whether a field annotation is ``str | None`` (or ``Optional[str]``)."""
|
||||
origin = get_origin(annotation)
|
||||
@@ -934,7 +962,7 @@ def _configure_pydantic_model(
|
||||
continue
|
||||
|
||||
# Registered special-field handlers
|
||||
handler = _FIELD_HANDLERS.get(field_name)
|
||||
handler = _resolve_field_handler(working_model, field_name)
|
||||
if handler:
|
||||
handler(working_model, field_name, field_display, current_value)
|
||||
continue
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
@@ -50,7 +51,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
|
||||
BuiltinCommandSpec(
|
||||
"/restart",
|
||||
"Restart nanobot",
|
||||
"Restart the bot process in place.",
|
||||
"Restart the bot process.",
|
||||
"rotate-cw",
|
||||
),
|
||||
BuiltinCommandSpec(
|
||||
@@ -130,6 +131,15 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||
loop = ctx.loop
|
||||
msg = ctx.msg
|
||||
total = await loop._cancel_active_tasks(ctx.key)
|
||||
# Also drain pending queue to prevent mid-turn injection deadlock
|
||||
pending = loop._pending_queues.pop(ctx.key, None)
|
||||
if pending is not None:
|
||||
while not pending.empty():
|
||||
try:
|
||||
pending.get_nowait()
|
||||
total += 1
|
||||
except Exception:
|
||||
break
|
||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||
return OutboundMessage(
|
||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||
@@ -138,7 +148,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
|
||||
async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Restart the process in-place via os.execv."""
|
||||
"""Restart the process."""
|
||||
msg = ctx.msg
|
||||
set_restart_notice_to_env(
|
||||
channel=msg.channel,
|
||||
@@ -148,7 +158,19 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
||||
|
||||
async def _do_restart():
|
||||
await asyncio.sleep(1)
|
||||
os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
|
||||
argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:]
|
||||
mode = getattr(ctx.loop, "restart_mode", "auto") or "auto"
|
||||
if mode == "auto":
|
||||
mode = "spawn" if sys.platform == "win32" else "exec"
|
||||
if mode == "exec":
|
||||
os.execv(sys.executable, argv)
|
||||
return
|
||||
if mode == "spawn":
|
||||
kwargs = {}
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
subprocess.Popen(argv, **kwargs)
|
||||
os._exit(0)
|
||||
|
||||
asyncio.create_task(_do_restart())
|
||||
return OutboundMessage(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
from nanobot.config.paths import (
|
||||
get_bridge_install_dir,
|
||||
get_cli_history_path,
|
||||
get_cron_dir,
|
||||
get_data_dir,
|
||||
@@ -29,6 +28,5 @@ __all__ = [
|
||||
"get_workspace_path",
|
||||
"is_default_workspace",
|
||||
"get_cli_history_path",
|
||||
"get_bridge_install_dir",
|
||||
"get_legacy_sessions_dir",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pydantic
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||
@@ -79,6 +80,10 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = config.model_dump(mode="json", by_alias=True)
|
||||
if config.providers.openai_codex.proxy is not None:
|
||||
data.setdefault("providers", {})["openaiCodex"] = {
|
||||
"proxy": config.providers.openai_codex.proxy,
|
||||
}
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
@@ -152,6 +157,23 @@ def _env_replace(match: re.Match[str]) -> str:
|
||||
|
||||
def _migrate_config(data: dict) -> dict:
|
||||
"""Migrate old config formats to current."""
|
||||
agents = data.get("agents", {})
|
||||
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
|
||||
if isinstance(defaults, dict):
|
||||
had_legacy_max_messages = (
|
||||
"maxMessages" in defaults or "max_messages" in defaults
|
||||
)
|
||||
defaults.pop("maxMessages", None)
|
||||
defaults.pop("max_messages", None)
|
||||
if had_legacy_max_messages:
|
||||
# TODO(next version): Remove this legacy cleanup branch; the schema
|
||||
# will silently ignore this field once the warning grace period ends.
|
||||
logger.warning(
|
||||
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
||||
"replay max messages is now an internal safety cap. Remove it from "
|
||||
"config. This compatibility warning will be removed in the next version."
|
||||
)
|
||||
|
||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||
tools = data.get("tools", {})
|
||||
exec_cfg = tools.get("exec", {})
|
||||
|
||||
@@ -66,11 +66,6 @@ def get_cli_history_path() -> Path:
|
||||
return Path.home() / ".nanobot" / "history" / "cli_history"
|
||||
|
||||
|
||||
def get_bridge_install_dir() -> Path:
|
||||
"""Return the shared WhatsApp bridge installation directory."""
|
||||
return Path.home() / ".nanobot" / "bridge"
|
||||
|
||||
|
||||
def get_legacy_sessions_dir() -> Path:
|
||||
"""Return the legacy global session directory used for migration fallback."""
|
||||
return Path.home() / ".nanobot" / "sessions"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
||||
|
||||
from pydantic import AliasChoices, ConfigDict, Field, model_validator
|
||||
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from nanobot.config_base import Base
|
||||
@@ -132,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(
|
||||
@@ -153,10 +154,6 @@ class AgentDefaults(Base):
|
||||
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
|
||||
serialization_alias="idleCompactAfterMinutes",
|
||||
) # Auto-compact idle threshold in minutes (0 = disabled)
|
||||
max_messages: int = Field(
|
||||
default=120,
|
||||
ge=0,
|
||||
) # Max messages to replay from session history (0 = use default 120, respects token budget)
|
||||
consolidation_ratio: float = Field(
|
||||
default=0.5,
|
||||
ge=0.1,
|
||||
@@ -182,6 +179,30 @@ class ProviderConfig(Base):
|
||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
||||
extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways)
|
||||
proxy: str | None = None # OpenAI-compatible/Codex HTTP proxy URL
|
||||
thinking_style: str | None = None # Thinking/reasoning style for custom providers
|
||||
|
||||
# Valid values mirror the keys of _THINKING_STYLE_MAP in
|
||||
# nanobot/providers/openai_compat_provider.py. Kept duplicated here to
|
||||
# avoid an import cycle (schema.py must not import from providers/).
|
||||
_VALID_THINKING_STYLES: ClassVar[tuple[str, ...]] = (
|
||||
"thinking_type",
|
||||
"enable_thinking",
|
||||
"reasoning_split",
|
||||
)
|
||||
|
||||
@field_validator("thinking_style")
|
||||
@classmethod
|
||||
def _validate_thinking_style(cls, v: str | None) -> str | None:
|
||||
if not v: # None or "" -> no injection, valid (backwards compatible)
|
||||
return v
|
||||
if v not in cls._VALID_THINKING_STYLES:
|
||||
raise ValueError(
|
||||
f"Invalid thinking_style {v!r}. "
|
||||
f"Must be one of: {', '.join(repr(s) for s in cls._VALID_THINKING_STYLES)} "
|
||||
f"(or empty/omitted)."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class BedrockProviderConfig(ProviderConfig):
|
||||
@@ -293,6 +314,7 @@ class GatewayConfig(Base):
|
||||
|
||||
host: str = "127.0.0.1" # Safer default: local-only bind.
|
||||
port: int = 18790
|
||||
restart_mode: Literal["auto", "exec", "spawn", "exit"] = "auto"
|
||||
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
||||
|
||||
|
||||
@@ -307,7 +329,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:
|
||||
|
||||
+28
-9
@@ -357,6 +357,25 @@ class CronService:
|
||||
|
||||
return self._store
|
||||
|
||||
def _require_store(self) -> CronStore:
|
||||
"""Return a usable store or raise a clear error.
|
||||
|
||||
``_load_store`` deliberately returns ``None`` when the first load sees
|
||||
a corrupt on-disk store and no previous in-memory snapshot exists. The
|
||||
public API requires a concrete store object before touching
|
||||
``store.jobs``; raising here keeps callers from seeing an accidental
|
||||
``AttributeError`` and, more importantly, prevents follow-up saves from
|
||||
treating a corrupt store as an empty one.
|
||||
"""
|
||||
store = self._load_store()
|
||||
if store is None:
|
||||
raise RuntimeError(
|
||||
f"cron store at {self.store_path} could not be loaded and was preserved "
|
||||
"as a .corrupt-<ts> backup; refusing to operate to avoid overwriting "
|
||||
"scheduled jobs. Inspect the corrupt backup and restore jobs.json manually."
|
||||
)
|
||||
return store
|
||||
|
||||
def _save_store(self) -> None:
|
||||
"""Save jobs to disk."""
|
||||
if not self._store:
|
||||
@@ -622,7 +641,7 @@ class CronService:
|
||||
|
||||
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
|
||||
"""List all jobs."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
|
||||
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
|
||||
|
||||
@@ -684,7 +703,7 @@ class CronService:
|
||||
_normalize_agent_turn_job(job)
|
||||
self._enforce_agent_binding(job)
|
||||
if self._running:
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
store.jobs.append(job)
|
||||
self._save_store()
|
||||
self._arm_timer()
|
||||
@@ -696,7 +715,7 @@ class CronService:
|
||||
|
||||
def register_system_job(self, job: CronJob) -> CronJob:
|
||||
"""Register an internal system job (idempotent on restart)."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
now = _now_ms()
|
||||
job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now))
|
||||
job.created_at_ms = now
|
||||
@@ -710,7 +729,7 @@ class CronService:
|
||||
|
||||
def remove_job(self, job_id: str) -> Literal["removed", "protected", "not_found"]:
|
||||
"""Remove a job by ID, unless it is a protected system job."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
job = next((j for j in store.jobs if j.id == job_id), None)
|
||||
if job is None:
|
||||
return "not_found"
|
||||
@@ -735,7 +754,7 @@ class CronService:
|
||||
|
||||
def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
|
||||
"""Enable or disable a job."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
for job in store.jobs:
|
||||
if job.id == job_id:
|
||||
job.enabled = enabled
|
||||
@@ -770,7 +789,7 @@ class CronService:
|
||||
For ``channel`` and ``to``, pass an explicit value (including ``None``)
|
||||
to update; omit (sentinel ``...``) to leave unchanged.
|
||||
"""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
job = next((j for j in store.jobs if j.id == job_id), None)
|
||||
if job is None:
|
||||
return "not_found"
|
||||
@@ -815,7 +834,7 @@ class CronService:
|
||||
was_running = self._running
|
||||
self._running = True
|
||||
try:
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
for job in store.jobs:
|
||||
if job.id == job_id:
|
||||
if self._is_unbound_agent_job(job):
|
||||
@@ -835,12 +854,12 @@ class CronService:
|
||||
|
||||
def get_job(self, job_id: str) -> CronJob | None:
|
||||
"""Get a job by ID."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
return next((j for j in store.jobs if j.id == job_id), None)
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Get service status."""
|
||||
store = self._load_store()
|
||||
store = self._require_store()
|
||||
return {
|
||||
"enabled": self._running,
|
||||
"jobs": len(store.jobs),
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
@@ -275,7 +276,19 @@ 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):
|
||||
@@ -315,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."""
|
||||
|
||||
@@ -54,6 +54,18 @@ class ToolCallRequest:
|
||||
provider_specific_fields: dict[str, Any] | None = None
|
||||
function_provider_specific_fields: dict[str, Any] | None = None
|
||||
|
||||
def has_valid_name(self) -> bool:
|
||||
"""Whether this call carries a usable (non-empty string) tool name.
|
||||
|
||||
ToolCallRequest.name is typed ``str`` but not enforced at runtime: a
|
||||
model/gateway can emit a degenerate call with ``name=None`` or ``""``.
|
||||
Such a call cannot be executed and, if persisted and replayed, makes
|
||||
upstream APIs reject the whole request (e.g. Anthropic-style
|
||||
``messages.content.N.tool_use.name: Input should be a valid string``),
|
||||
which permanently wedges the session.
|
||||
"""
|
||||
return isinstance(self.name, str) and bool(self.name)
|
||||
|
||||
def to_openai_tool_call(self) -> dict[str, Any]:
|
||||
"""Serialize to an OpenAI-style tool_call payload."""
|
||||
arguments = (
|
||||
|
||||
@@ -54,10 +54,15 @@ def _make_provider_core(
|
||||
if provider_name and not spec and p:
|
||||
if not p.api_base:
|
||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||
spec = create_dynamic_spec(provider_name)
|
||||
spec = create_dynamic_spec(provider_name, thinking_style=(p.thinking_style or "") if p else "")
|
||||
if spec and spec.is_transcription_only:
|
||||
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
||||
backend = spec.backend if spec else "openai_compat"
|
||||
if p and p.proxy and backend not in {"openai_compat", "openai_codex"}:
|
||||
raise ValueError(
|
||||
f"providers.{provider_name}.proxy is only supported for "
|
||||
"OpenAI-compatible providers and OpenAI Codex."
|
||||
)
|
||||
|
||||
if backend == "azure_openai":
|
||||
if not p or not p.api_base:
|
||||
@@ -79,7 +84,10 @@ def _make_provider_core(
|
||||
if backend == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
provider = OpenAICodexProvider(default_model=model)
|
||||
provider = OpenAICodexProvider(
|
||||
default_model=model,
|
||||
proxy=getattr(p, "proxy", None) if p else None,
|
||||
)
|
||||
elif backend == "azure_openai":
|
||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||
|
||||
@@ -124,6 +132,7 @@ def _make_provider_core(
|
||||
extra_body=p.extra_body if p else None,
|
||||
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
||||
extra_query=p.extra_query if p else None,
|
||||
proxy=p.proxy if p else None,
|
||||
)
|
||||
|
||||
provider.generation = resolved.to_generation_settings()
|
||||
@@ -218,6 +227,7 @@ def provider_signature(
|
||||
fallback.temperature,
|
||||
fallback.reasoning_effort,
|
||||
fallback.context_window_tokens,
|
||||
getattr(fp, "proxy", None) if fp else None,
|
||||
)
|
||||
|
||||
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
||||
@@ -237,6 +247,7 @@ def provider_signature(
|
||||
resolved.temperature,
|
||||
resolved.reasoning_effort,
|
||||
resolved.context_window_tokens,
|
||||
getattr(p, "proxy", None) if p else None,
|
||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import webbrowser
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -29,6 +30,12 @@ _EXPIRY_SKEW_SECONDS = 60
|
||||
_LONG_LIVED_TOKEN_SECONDS = 315360000
|
||||
|
||||
|
||||
def _resolve(env_var: str, default: str) -> str:
|
||||
"""Allow GitHub Enterprise / Copilot for Business deployments to override defaults via env."""
|
||||
value = os.environ.get(env_var)
|
||||
return value.strip() if value and value.strip() else default
|
||||
|
||||
|
||||
def get_storage() -> FileTokenStorage:
|
||||
return FileTokenStorage(
|
||||
token_filename=TOKEN_FILENAME,
|
||||
@@ -68,11 +75,16 @@ def login_github_copilot(
|
||||
printer = print_fn or print
|
||||
timeout = httpx.Timeout(20.0, connect=20.0)
|
||||
|
||||
client_id = _resolve("NANOBOT_GITHUB_COPILOT_CLIENT_ID", GITHUB_COPILOT_CLIENT_ID)
|
||||
device_code_url = _resolve("NANOBOT_GITHUB_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL)
|
||||
access_token_url = _resolve("NANOBOT_GITHUB_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL)
|
||||
user_url = _resolve("NANOBOT_GITHUB_USER_URL", DEFAULT_GITHUB_USER_URL)
|
||||
|
||||
with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
||||
response = client.post(
|
||||
DEFAULT_GITHUB_DEVICE_CODE_URL,
|
||||
device_code_url,
|
||||
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
||||
data={"client_id": GITHUB_COPILOT_CLIENT_ID, "scope": GITHUB_COPILOT_SCOPE},
|
||||
data={"client_id": client_id, "scope": GITHUB_COPILOT_SCOPE},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
@@ -96,10 +108,10 @@ def login_github_copilot(
|
||||
token_expires_in = _LONG_LIVED_TOKEN_SECONDS
|
||||
while time.time() < deadline:
|
||||
poll = client.post(
|
||||
DEFAULT_GITHUB_ACCESS_TOKEN_URL,
|
||||
access_token_url,
|
||||
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
||||
data={
|
||||
"client_id": GITHUB_COPILOT_CLIENT_ID,
|
||||
"client_id": client_id,
|
||||
"device_code": device_code,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
@@ -132,7 +144,7 @@ def login_github_copilot(
|
||||
raise RuntimeError("GitHub device flow timed out.")
|
||||
|
||||
user = client.get(
|
||||
DEFAULT_GITHUB_USER_URL,
|
||||
user_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
@@ -164,7 +176,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
self._copilot_expires_at: float = 0.0
|
||||
super().__init__(
|
||||
api_key="no-key",
|
||||
api_base=DEFAULT_COPILOT_BASE_URL,
|
||||
api_base=_resolve("NANOBOT_COPILOT_BASE_URL", DEFAULT_COPILOT_BASE_URL),
|
||||
default_model=default_model,
|
||||
extra_headers={
|
||||
"Editor-Version": EDITOR_VERSION,
|
||||
@@ -186,7 +198,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
||||
timeout = httpx.Timeout(20.0, connect=20.0)
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
||||
response = await client.get(
|
||||
DEFAULT_COPILOT_TOKEN_URL,
|
||||
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
|
||||
headers=_copilot_headers(github_token.access),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -33,9 +33,14 @@ class OpenAICodexProvider(LLMProvider):
|
||||
|
||||
supports_progress_deltas = True
|
||||
|
||||
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||
def __init__(
|
||||
self,
|
||||
default_model: str = "openai-codex/gpt-5.1-codex",
|
||||
proxy: str | None = None,
|
||||
):
|
||||
super().__init__(api_key=None, api_base=None)
|
||||
self.default_model = default_model
|
||||
self.proxy = proxy or None
|
||||
|
||||
async def _call_codex(
|
||||
self,
|
||||
@@ -52,9 +57,6 @@ class OpenAICodexProvider(LLMProvider):
|
||||
model = model or self.default_model
|
||||
system_prompt, input_items = convert_messages(messages)
|
||||
|
||||
token = await asyncio.to_thread(get_codex_token)
|
||||
headers = _build_headers(token.account_id, token.access)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"model": _strip_model_prefix(model),
|
||||
"store": False,
|
||||
@@ -74,9 +76,13 @@ class OpenAICodexProvider(LLMProvider):
|
||||
body["tools"] = convert_tools(tools)
|
||||
|
||||
try:
|
||||
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
||||
headers = _build_headers(token.account_id, token.access)
|
||||
|
||||
try:
|
||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||
DEFAULT_CODEX_URL, headers, body, verify=True,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
@@ -87,6 +93,7 @@ class OpenAICodexProvider(LLMProvider):
|
||||
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||
DEFAULT_CODEX_URL, headers, body, verify=False,
|
||||
proxy=self.proxy,
|
||||
on_content_delta=on_content_delta,
|
||||
on_thinking_delta=on_thinking_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
@@ -199,12 +206,17 @@ async def _request_codex(
|
||||
headers: dict[str, str],
|
||||
body: dict[str, Any],
|
||||
verify: bool,
|
||||
proxy: str | None = None,
|
||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
|
||||
client_kwargs: dict[str, Any] = {"timeout": idle_timeout_s, "verify": verify}
|
||||
if proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
client_kwargs["trust_env"] = False
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||
if response.status_code != 200:
|
||||
text = await response.aread()
|
||||
|
||||
@@ -358,6 +358,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
api_type: str = "auto",
|
||||
extra_query: dict[str, str] | None = None,
|
||||
proxy: str | None = None,
|
||||
):
|
||||
super().__init__(api_key, api_base)
|
||||
self.default_model = default_model
|
||||
@@ -366,6 +367,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
self._extra_body = extra_body or {}
|
||||
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
||||
self._extra_query = extra_query or {}
|
||||
self._proxy = proxy or None
|
||||
|
||||
if api_key and spec and spec.env_key:
|
||||
self._setup_env(api_key, api_base)
|
||||
@@ -396,7 +398,14 @@ class OpenAICompatProvider(LLMProvider):
|
||||
|
||||
timeout_s = _openai_compat_timeout_s()
|
||||
http_client: httpx.AsyncClient | None = None
|
||||
if self._is_local:
|
||||
if self._proxy:
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=timeout_s,
|
||||
proxy=self._proxy,
|
||||
trust_env=False,
|
||||
follow_redirects=True,
|
||||
)
|
||||
elif self._is_local:
|
||||
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
||||
# HTTP connections before the client-side keepalive expires. When
|
||||
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
||||
@@ -1131,14 +1140,21 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if reasoning_content is None:
|
||||
reasoning_content = m.get("reasoning_content")
|
||||
|
||||
# Deduplicate tool call IDs (same pattern as streaming path)
|
||||
# Some providers reuse the same ID for parallel tool calls.
|
||||
_seen_tc_ids: set[str] = set()
|
||||
parsed_tool_calls = []
|
||||
for tc in raw_tool_calls:
|
||||
tc_map = self._maybe_mapping(tc) or {}
|
||||
fn = self._maybe_mapping(tc_map.get("function")) or {}
|
||||
args = parse_tool_arguments(fn.get("arguments", {}))
|
||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||
raw_id = str(tc_map.get("id") or _short_tool_id())
|
||||
if not raw_id or raw_id in _seen_tc_ids:
|
||||
raw_id = _short_tool_id()
|
||||
_seen_tc_ids.add(raw_id)
|
||||
parsed_tool_calls.append(ToolCallRequest(
|
||||
id=str(tc_map.get("id") or _short_tool_id()),
|
||||
id=raw_id,
|
||||
name=str(fn.get("name") or ""),
|
||||
arguments=args,
|
||||
extra_content=ec,
|
||||
|
||||
@@ -628,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)))
|
||||
@@ -640,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,
|
||||
)
|
||||
|
||||
+112
-49
@@ -1,5 +1,6 @@
|
||||
"""Session management for conversation history."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -26,6 +27,8 @@ from nanobot.utils.helpers import (
|
||||
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||
|
||||
FILE_MAX_MESSAGES = 2000
|
||||
MIN_REPLAY_MAX_MESSAGES = 120
|
||||
REPLAY_TOKENS_PER_MESSAGE = 100
|
||||
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
||||
@@ -42,6 +45,15 @@ _FORK_VOLATILE_METADATA_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def replay_max_messages_for_context(context_window_tokens: int | None) -> int:
|
||||
if not context_window_tokens or context_window_tokens <= 0:
|
||||
return FILE_MAX_MESSAGES
|
||||
return min(
|
||||
FILE_MAX_MESSAGES,
|
||||
max(MIN_REPLAY_MAX_MESSAGES, context_window_tokens // REPLAY_TOKENS_PER_MESSAGE),
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_assistant_replay_text(content: str) -> str:
|
||||
"""Remove internal replay artifacts that the model may have copied before.
|
||||
|
||||
@@ -98,6 +110,12 @@ def _metadata_title(metadata: Any) -> str:
|
||||
return strip_think(title)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetentionResult:
|
||||
dropped: list[dict]
|
||||
already_consolidated_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""A conversation session."""
|
||||
@@ -118,25 +136,6 @@ class Session:
|
||||
):
|
||||
self.last_consolidated = 0
|
||||
|
||||
@staticmethod
|
||||
def _annotate_message_time(message: dict[str, Any], content: Any) -> Any:
|
||||
"""Expose persisted turn timestamps to the model for relative-date reasoning.
|
||||
|
||||
Annotating *every* assistant turn trains the model (via in-context
|
||||
demonstrations) to start its own replies with the same
|
||||
``[Message Time: ...]`` prefix, which leaks metadata back to the user.
|
||||
We therefore only annotate user turns. User-side stamps are enough to
|
||||
pin adjacent assistant replies for relative-time reasoning, including
|
||||
proactive messages the user replies to later.
|
||||
"""
|
||||
timestamp = message.get("timestamp")
|
||||
if not timestamp or not isinstance(content, str):
|
||||
return content
|
||||
role = message.get("role")
|
||||
if role != "user":
|
||||
return content
|
||||
return f"[Message Time: {timestamp}]\n{content}"
|
||||
|
||||
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
|
||||
"""Add a message to the session."""
|
||||
msg = {
|
||||
@@ -150,10 +149,9 @@ class Session:
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
max_messages: int = 120,
|
||||
max_messages: int = FILE_MAX_MESSAGES,
|
||||
*,
|
||||
max_tokens: int = 0,
|
||||
include_timestamps: bool = False,
|
||||
extend_to_user: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return unconsolidated messages for LLM input.
|
||||
@@ -162,7 +160,7 @@ class Session:
|
||||
token budget from the tail (``max_tokens``) when provided.
|
||||
"""
|
||||
unconsolidated = self.messages[self.last_consolidated:]
|
||||
max_messages = max_messages if max_messages > 0 else 120
|
||||
max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
|
||||
start_idx = recent_message_start_index(
|
||||
unconsolidated,
|
||||
max_messages,
|
||||
@@ -243,8 +241,6 @@ class Session:
|
||||
if mcp_lines:
|
||||
breadcrumbs = "\n".join(mcp_lines)
|
||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||
if include_timestamps:
|
||||
content = self._annotate_message_time(message, content)
|
||||
if role == "assistant" and isinstance(content, str) and not content.strip():
|
||||
if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")):
|
||||
continue
|
||||
@@ -299,22 +295,26 @@ class Session:
|
||||
max_messages: int,
|
||||
*,
|
||||
extend_to_user: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
) -> RetentionResult:
|
||||
"""Keep a legal recent suffix, optionally extending it back to a user turn.
|
||||
|
||||
Returns ``(dropped, already_consolidated_count)`` where *dropped* is
|
||||
the list of removed messages (in original order) and
|
||||
*already_consolidated_count* is how many of those were inside the
|
||||
pre-existing ``last_consolidated`` prefix and therefore do not need
|
||||
raw archiving.
|
||||
Returns a RetentionResult with dropped messages and how many of those
|
||||
were in the already-consolidated prefix. This method mutates
|
||||
self.messages and self.last_consolidated in place.
|
||||
"""
|
||||
if max_messages <= 0:
|
||||
dropped = list(self.messages)
|
||||
lc = self.last_consolidated
|
||||
self.clear()
|
||||
return dropped, min(lc, len(dropped))
|
||||
return RetentionResult(
|
||||
dropped=dropped,
|
||||
already_consolidated_count=min(lc, len(dropped)),
|
||||
)
|
||||
if len(self.messages) <= max_messages:
|
||||
return [], 0
|
||||
return RetentionResult(
|
||||
dropped=[],
|
||||
already_consolidated_count=0,
|
||||
)
|
||||
|
||||
original = list(self.messages)
|
||||
before_lc = self.last_consolidated
|
||||
@@ -380,7 +380,10 @@ class Session:
|
||||
self.messages = retained
|
||||
self.last_consolidated = new_lc
|
||||
self.updated_at = datetime.now()
|
||||
return dropped, already_consolidated
|
||||
return RetentionResult(
|
||||
dropped=dropped,
|
||||
already_consolidated_count=already_consolidated,
|
||||
)
|
||||
|
||||
def enforce_file_cap(
|
||||
self,
|
||||
@@ -391,17 +394,17 @@ class Session:
|
||||
if limit <= 0 or len(self.messages) <= limit:
|
||||
return
|
||||
|
||||
dropped, already_consolidated = self.retain_recent_legal_suffix(limit)
|
||||
if not dropped:
|
||||
result = self.retain_recent_legal_suffix(limit)
|
||||
if not result.dropped:
|
||||
return
|
||||
|
||||
archive_chunk = dropped[already_consolidated:]
|
||||
archive_chunk = result.dropped[result.already_consolidated_count:]
|
||||
if archive_chunk and on_archive:
|
||||
on_archive(archive_chunk)
|
||||
logger.info(
|
||||
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
||||
self.key,
|
||||
len(dropped),
|
||||
len(result.dropped),
|
||||
len(archive_chunk),
|
||||
len(self.messages),
|
||||
)
|
||||
@@ -425,14 +428,53 @@ class SessionManager:
|
||||
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
|
||||
return safe_filename(key.replace(":", "_"))
|
||||
|
||||
@staticmethod
|
||||
def _storage_key(key: str) -> str:
|
||||
"""Collision-resistant encoding for internal session storage filenames."""
|
||||
return base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
||||
|
||||
@staticmethod
|
||||
def _decode_storage_key(stem: str) -> str | None:
|
||||
"""Reverse _storage_key(): decode a base64url (no-padding) stem back to the original key."""
|
||||
try:
|
||||
# Restore padding stripped by rstrip("=")
|
||||
padding = 4 - len(stem) % 4
|
||||
if padding != 4:
|
||||
stem += "=" * padding
|
||||
return base64.urlsafe_b64decode(stem).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_session_path(self, key: str) -> Path:
|
||||
"""Get the file path for a session."""
|
||||
return self.sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||
"""Get the collision-resistant workspace path for a session."""
|
||||
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
|
||||
|
||||
def _get_legacy_lossy_path(self, key: str) -> Path:
|
||||
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
||||
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
||||
|
||||
def _get_legacy_session_path(self, key: str) -> Path:
|
||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||
|
||||
@staticmethod
|
||||
def _stored_key_for_path(path: Path) -> str | None:
|
||||
"""Read the stored session key from a JSONL metadata row, if present."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
data = json.loads(line)
|
||||
if data.get("_type") == "metadata":
|
||||
stored_key = data.get("key")
|
||||
return stored_key if isinstance(stored_key, str) else None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_or_create(self, key: str) -> Session:
|
||||
"""
|
||||
Get an existing session or create a new one.
|
||||
@@ -457,13 +499,28 @@ class SessionManager:
|
||||
"""Load a session from disk."""
|
||||
path = self._get_session_path(key)
|
||||
if not path.exists():
|
||||
legacy_path = self._get_legacy_session_path(key)
|
||||
if legacy_path.exists():
|
||||
fallback_paths = [
|
||||
(self._get_legacy_lossy_path(key), "legacy lossy path"),
|
||||
(self._get_legacy_session_path(key), "legacy path"),
|
||||
]
|
||||
for fallback_path, description in fallback_paths:
|
||||
if not fallback_path.exists():
|
||||
continue
|
||||
stored_key = self._stored_key_for_path(fallback_path)
|
||||
if stored_key and stored_key != key:
|
||||
logger.info(
|
||||
"Skipping migration for {} from {} because it belongs to {}",
|
||||
key,
|
||||
description,
|
||||
stored_key,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
shutil.move(str(legacy_path), str(path))
|
||||
logger.info("Migrated session {} from legacy path", key)
|
||||
shutil.move(str(fallback_path), str(path))
|
||||
logger.info("Migrated session {} from {}", key, description)
|
||||
except Exception:
|
||||
logger.exception("Failed to migrate session {}", key)
|
||||
break
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -506,9 +563,10 @@ class SessionManager:
|
||||
logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages))
|
||||
return repaired
|
||||
|
||||
def _repair(self, key: str) -> Session | None:
|
||||
def _repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
||||
"""Attempt to recover a session from a corrupt JSONL file."""
|
||||
path = self._get_session_path(key)
|
||||
if path is None:
|
||||
path = self._get_session_path(key)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
@@ -645,7 +703,11 @@ class SessionManager:
|
||||
|
||||
Returns True if at least one JSONL file was found and unlinked.
|
||||
"""
|
||||
paths = [self._get_session_path(key), self._get_legacy_session_path(key)]
|
||||
paths = [
|
||||
self._get_session_path(key),
|
||||
self._get_legacy_lossy_path(key),
|
||||
self._get_legacy_session_path(key),
|
||||
]
|
||||
self.invalidate(key)
|
||||
deleted = False
|
||||
for path in paths:
|
||||
@@ -806,7 +868,8 @@ class SessionManager:
|
||||
sessions = []
|
||||
|
||||
for path in self.sessions_dir.glob("*.jsonl"):
|
||||
fallback_key = path.stem.replace("_", ":", 1)
|
||||
decoded = self._decode_storage_key(path.stem)
|
||||
fallback_key = decoded or path.stem.replace("_", ":", 1)
|
||||
try:
|
||||
# Read the metadata line and a small preview for session lists.
|
||||
with open(path, encoding="utf-8") as f:
|
||||
@@ -814,7 +877,7 @@ class SessionManager:
|
||||
if first_line:
|
||||
data = json.loads(first_line)
|
||||
if data.get("_type") == "metadata":
|
||||
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||
key = data.get("key") or fallback_key
|
||||
metadata = data.get("metadata", {})
|
||||
title = _metadata_title(metadata)
|
||||
preview = ""
|
||||
@@ -855,7 +918,7 @@ class SessionManager:
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
repaired = self._repair(fallback_key)
|
||||
repaired = self._repair(fallback_key, path=path)
|
||||
if repaired is not None:
|
||||
sessions.append(
|
||||
{
|
||||
|
||||
@@ -5,7 +5,9 @@ description: Schedule reminders and recurring tasks.
|
||||
|
||||
# Cron
|
||||
|
||||
Use the `cron` tool to schedule reminders or recurring tasks.
|
||||
Use the `cron` tool to schedule reminders or recurring tasks that should report back to the originating chat/session when they run.
|
||||
|
||||
Do not use `cron` for periodic background checks that should stay quiet when there is nothing useful to report. For those, update `HEARTBEAT.md`; the protected heartbeat job runs those checks and only delivers results that pass the notification gate.
|
||||
|
||||
## Three Modes
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ Use this file for project-specific preferences, recurring workflow conventions,
|
||||
- Before scheduling reminders, check available skills and follow skill guidance first.
|
||||
- Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`).
|
||||
- Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
|
||||
- Cron jobs run as scheduled turns in the origin chat/session and normally deliver the result back to that channel. Do not use cron for background checks that should stay silent when there is nothing useful to report; use `HEARTBEAT.md` instead.
|
||||
|
||||
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
|
||||
|
||||
@@ -20,4 +21,4 @@ Use this file for project-specific preferences, recurring workflow conventions,
|
||||
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
|
||||
- Use `write_file` for first creation or intentional full-file rewrites.
|
||||
|
||||
When the user asks for a recurring/periodic heartbeat task, update `HEARTBEAT.md` instead of creating a one-time reminder. Use the built-in `cron` tool for separate reminders or custom schedules that should not be part of the heartbeat task list.
|
||||
When the user asks for a recurring/periodic heartbeat task, or for a periodic background check that should only notify on actionable changes, update `HEARTBEAT.md` instead of creating a one-time reminder. Use the built-in `cron` tool for explicit reminders, scheduled tasks that should report every run, or custom schedules that should not be part of the heartbeat task list.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<!--
|
||||
This file is checked periodically by your nanobot agent. When nanobot gateway starts with gateway.heartbeat.enabled=true, it automatically registers a protected heartbeat cron job that reads this file.
|
||||
|
||||
If this file has no tasks (only headers and comments), the agent will skip it. Completed tasks should be deleted, not kept — heartbeat only reads "Active Tasks".
|
||||
Use this file for recurring background checks that should stay quiet unless there is something useful to report. Regular cron jobs are different: they normally deliver each run's result back to the chat/session where they were created.
|
||||
|
||||
If this file has no tasks (only headers and comments), the agent will skip it. Completed tasks should be deleted, not kept - heartbeat only reads "Active Tasks".
|
||||
-->
|
||||
|
||||
## Active Tasks
|
||||
|
||||
@@ -5,4 +5,7 @@ Task: {{ task }}
|
||||
Result:
|
||||
{{ result }}
|
||||
|
||||
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs.
|
||||
Use this result as evidence for the current turn. For MapReduce-style work,
|
||||
preserve any Summary / Evidence / Open issues structure when reducing multiple
|
||||
results. Mention gaps or failures if they affect the answer; avoid exposing
|
||||
internal task IDs unless they are needed for clarity.
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
You are a subagent spawned by the main agent to complete a specific task.
|
||||
Stay focused on the assigned task. Your final response will be reported back to the main agent.
|
||||
If this task is one slice of a larger MapReduce-style effort, treat yourself as
|
||||
the map step: do only the assigned slice, avoid cross-slice coordination, and
|
||||
leave reduction or final synthesis to the main agent.
|
||||
|
||||
For MapReduce-style slices, end with a compact, mergeable result:
|
||||
|
||||
- Summary: what you found or changed
|
||||
- Evidence: relevant files, commands, URLs, or observations
|
||||
- Open issues: blockers, failures, or "none"
|
||||
|
||||
{% include 'agent/_snippets/untrusted_content.md' %}
|
||||
|
||||
|
||||
@@ -529,6 +529,9 @@ class StreamingFileEditTracker:
|
||||
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
||||
used_canonicals: set[str] = set()
|
||||
for tool_call in final_tool_calls:
|
||||
name = getattr(tool_call, "name", None)
|
||||
if not is_file_edit_tool(name):
|
||||
continue
|
||||
canonical = self.canonical_call_id_for(tool_call)
|
||||
if canonical and canonical not in used_canonicals:
|
||||
try:
|
||||
|
||||
@@ -35,10 +35,15 @@ def format_tool_hints(tool_calls: list, max_length: int = 40) -> str:
|
||||
|
||||
formatted = []
|
||||
for tc in tool_calls:
|
||||
fmt = _TOOL_FORMATS.get(tc.name)
|
||||
name = getattr(tc, "name", None)
|
||||
if not isinstance(name, str) or not name:
|
||||
# Degenerate/malformed tool call (e.g. a model emits name=None);
|
||||
# skip it instead of raising AttributeError on the whole turn.
|
||||
continue
|
||||
fmt = _TOOL_FORMATS.get(name)
|
||||
if fmt:
|
||||
formatted.append(_fmt_known(tc, fmt, max_length))
|
||||
elif tc.name.startswith("mcp_"):
|
||||
elif name.startswith("mcp_"):
|
||||
formatted.append(_fmt_mcp(tc, max_length))
|
||||
else:
|
||||
formatted.append(_fmt_fallback(tc, max_length))
|
||||
|
||||
@@ -26,10 +26,11 @@ from nanobot.session.manager import (
|
||||
_metadata_title,
|
||||
)
|
||||
|
||||
_INDEX_VERSION = 1
|
||||
_INDEX_VERSION = 2
|
||||
_INDEX_FILENAME = ".webui_session_index.json"
|
||||
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
||||
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
||||
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
|
||||
|
||||
|
||||
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
||||
@@ -214,14 +215,45 @@ def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
|
||||
return stored
|
||||
|
||||
|
||||
def _visible_message_timestamp(item: dict[str, Any]) -> str | None:
|
||||
if item.get(CRON_HISTORY_META) is True:
|
||||
return None
|
||||
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
|
||||
return None
|
||||
timestamp = item.get("timestamp")
|
||||
return timestamp if isinstance(timestamp, str) else None
|
||||
|
||||
|
||||
def _last_visible_message_at(messages: list[dict[str, Any]]) -> str | None:
|
||||
latest: str | None = None
|
||||
for item in messages:
|
||||
timestamp = _visible_message_timestamp(item)
|
||||
if timestamp is not None:
|
||||
latest = _latest_updated_at(latest, timestamp)
|
||||
return latest
|
||||
|
||||
|
||||
def _visible_activity_updated_at(
|
||||
stored: str | None,
|
||||
visible_message_at: str | None,
|
||||
webui_activity: str | None,
|
||||
) -> str | None:
|
||||
return _latest_updated_at(visible_message_at, webui_activity) or stored
|
||||
|
||||
|
||||
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
||||
signature = _file_signature(path)
|
||||
activity_signature = _webui_activity_signature(session.key)
|
||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||
visible_message_at = _last_visible_message_at(session.messages)
|
||||
return {
|
||||
"key": session.key,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
"updated_at": _latest_updated_at(session.updated_at.isoformat(), activity_updated_at),
|
||||
"updated_at": _visible_activity_updated_at(
|
||||
session.updated_at.isoformat(),
|
||||
visible_message_at,
|
||||
activity_updated_at,
|
||||
),
|
||||
"title": _metadata_title(session.metadata),
|
||||
"preview": _preview_from_messages(session.messages),
|
||||
"file": path.name,
|
||||
@@ -232,7 +264,8 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
||||
fallback_key = path.stem.replace("_", ":", 1)
|
||||
storage_key = SessionManager._decode_storage_key(path.stem)
|
||||
fallback_key = storage_key or path.stem.replace("_", ":", 1)
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
first_line = f.readline().strip()
|
||||
@@ -243,31 +276,37 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
||||
return None
|
||||
preview = ""
|
||||
fallback_preview = ""
|
||||
visible_message_at = None
|
||||
preview_done = False
|
||||
scanned_records = 0
|
||||
scanned_chars = 0
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
scanned_records += 1
|
||||
scanned_chars += len(line)
|
||||
if (
|
||||
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||
):
|
||||
break
|
||||
item = json.loads(line)
|
||||
if item.get("_type") == "metadata":
|
||||
continue
|
||||
if item.get(CRON_HISTORY_META) is True:
|
||||
continue
|
||||
text = _message_preview_text(item)
|
||||
if not text:
|
||||
continue
|
||||
if item.get("role") == "user":
|
||||
preview = text
|
||||
break
|
||||
if not fallback_preview and item.get("role") == "assistant":
|
||||
fallback_preview = text
|
||||
timestamp = _visible_message_timestamp(item)
|
||||
if timestamp is not None:
|
||||
visible_message_at = _latest_updated_at(visible_message_at, timestamp)
|
||||
if not preview_done:
|
||||
scanned_records += 1
|
||||
scanned_chars += len(line)
|
||||
if (
|
||||
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||
):
|
||||
preview_done = True
|
||||
continue
|
||||
if item.get(CRON_HISTORY_META) is True:
|
||||
continue
|
||||
text = _message_preview_text(item)
|
||||
if not text:
|
||||
continue
|
||||
if item.get("role") == "user":
|
||||
preview = text
|
||||
preview_done = True
|
||||
continue
|
||||
if not fallback_preview and item.get("role") == "assistant":
|
||||
fallback_preview = text
|
||||
signature = _file_signature(path)
|
||||
created_at_s = data.get("created_at")
|
||||
updated_at_s = data.get("updated_at")
|
||||
@@ -281,7 +320,11 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
||||
return {
|
||||
"key": key,
|
||||
"created_at": created_at_s,
|
||||
"updated_at": _latest_updated_at(updated_at_s, activity_updated_at),
|
||||
"updated_at": _visible_activity_updated_at(
|
||||
updated_at_s,
|
||||
visible_message_at,
|
||||
activity_updated_at,
|
||||
),
|
||||
"title": _metadata_title(data.get("metadata", {})),
|
||||
"preview": preview or fallback_preview,
|
||||
"file": path.name,
|
||||
|
||||
@@ -16,12 +16,13 @@ from zoneinfo import ZoneInfo
|
||||
import httpx
|
||||
|
||||
from nanobot import __version__
|
||||
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
||||
from nanobot.audio.transcription import resolve_transcription_config
|
||||
from nanobot.audio.transcription_registry import (
|
||||
resolve_transcription_provider,
|
||||
transcription_provider_names,
|
||||
)
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars, save_config
|
||||
from nanobot.config.schema import ModelPresetConfig, ProviderConfig
|
||||
from nanobot.providers.image_generation import (
|
||||
get_image_gen_provider,
|
||||
@@ -79,19 +80,7 @@ _NATIVE_RESTART_BEHAVIOR_BY_SECTION = {
|
||||
"apps": "engineRestart",
|
||||
}
|
||||
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS: tuple[dict[str, str], ...] = (
|
||||
{"name": "duckduckgo", "label": "DuckDuckGo", "credential": "none"},
|
||||
{"name": "brave", "label": "Brave Search", "credential": "api_key"},
|
||||
{"name": "tavily", "label": "Tavily", "credential": "api_key"},
|
||||
{"name": "searxng", "label": "SearXNG", "credential": "base_url"},
|
||||
{"name": "jina", "label": "Jina", "credential": "api_key"},
|
||||
{"name": "kagi", "label": "Kagi", "credential": "api_key"},
|
||||
{"name": "exa", "label": "Exa", "credential": "api_key"},
|
||||
{"name": "olostep", "label": "Olostep", "credential": "api_key"},
|
||||
{"name": "bocha", "label": "Bocha", "credential": "api_key"},
|
||||
{"name": "volcengine", "label": "Volcengine Search", "credential": "api_key"},
|
||||
{"name": "keenable", "label": "Keenable", "credential": "optional_api_key"},
|
||||
)
|
||||
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
|
||||
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -1177,14 +1166,19 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||
except ImportError:
|
||||
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
|
||||
|
||||
try:
|
||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||
except ValueError as e:
|
||||
raise WebUISettingsError(str(e), status=400) from e
|
||||
token = None
|
||||
with suppress(Exception):
|
||||
token = get_token()
|
||||
token = get_token(proxy=proxy)
|
||||
if not (token and token.access):
|
||||
messages: list[str] = []
|
||||
token = login_oauth_interactive(
|
||||
print_fn=lambda message: messages.append(str(message)),
|
||||
prompt_fn=lambda _prompt: "",
|
||||
proxy=proxy,
|
||||
)
|
||||
if not (token and token.access):
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
|
||||
+6
-5
@@ -31,7 +31,7 @@ dependencies = [
|
||||
"websocket-client>=1.9.0,<2.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
"ddgs>=9.5.5,<10.0.0",
|
||||
"oauth-cli-kit>=0.1.3,<1.0.0",
|
||||
"oauth-cli-kit>=0.1.6,<1.0.0",
|
||||
"loguru>=0.7.3,<1.0.0",
|
||||
"readability-lxml>=0.8.4,<1.0.0",
|
||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||
@@ -93,6 +93,10 @@ matrix = [
|
||||
discord = [
|
||||
"discord.py>=2.5.2,<3.0.0",
|
||||
]
|
||||
whatsapp = [
|
||||
"neonize>=0.3.18.post0,<0.4.0",
|
||||
"segno>=1.6.1,<2.0.0",
|
||||
]
|
||||
langsmith = [
|
||||
"langsmith>=0.1.0",
|
||||
]
|
||||
@@ -150,14 +154,10 @@ packages = ["nanobot"]
|
||||
[tool.hatch.build.targets.wheel.sources]
|
||||
"nanobot" = "nanobot"
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"bridge" = "nanobot/bridge"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"nanobot/",
|
||||
"nanobot/web/dist/",
|
||||
"bridge/",
|
||||
"hatch_build.py",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
@@ -182,6 +182,7 @@ source = ["nanobot"]
|
||||
omit = ["tests/*", "**/tests/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 75
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
|
||||
+10
-2
@@ -269,7 +269,15 @@ if [ "${NANOBOT_SKIP_WIZARD:-}" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
info "Starting setup wizard..."
|
||||
run_nanobot onboard --wizard
|
||||
if [ -t 0 ]; then
|
||||
info "Starting setup wizard..."
|
||||
run_nanobot onboard --wizard
|
||||
elif : 2>/dev/null < /dev/tty; then
|
||||
info "Starting setup wizard..."
|
||||
run_nanobot onboard --wizard < /dev/tty
|
||||
else
|
||||
info "Skipping setup wizard because no interactive terminal is available."
|
||||
info "Run this later: $(nanobot_try_command) onboard --wizard"
|
||||
fi
|
||||
|
||||
info "Done. Try: $(nanobot_try_command) agent -m \"Hello!\""
|
||||
|
||||
@@ -38,7 +38,6 @@ def make_loop(
|
||||
model: str = "test-model",
|
||||
context_window_tokens: int = 128_000,
|
||||
session_ttl_minutes: int = 0,
|
||||
max_messages: int = 120,
|
||||
unified_session: bool = False,
|
||||
mcp_servers: dict | None = None,
|
||||
tools_config=None,
|
||||
@@ -64,7 +63,6 @@ def make_loop(
|
||||
model=model,
|
||||
context_window_tokens=context_window_tokens,
|
||||
session_ttl_minutes=session_ttl_minutes,
|
||||
max_messages=max_messages,
|
||||
unified_session=unified_session,
|
||||
)
|
||||
if mcp_servers is not None:
|
||||
@@ -79,8 +77,8 @@ def make_loop(
|
||||
if patch_deps:
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
return AgentLoop(**kwargs)
|
||||
return AgentLoop(**kwargs)
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ def _make_fake_compact(
|
||||
|
||||
tail = list(session.messages[session.last_consolidated:])
|
||||
if not tail:
|
||||
session.updated_at = datetime.now()
|
||||
loop.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -103,15 +102,14 @@ def _make_fake_compact(
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
dropped, already_consolidated = probe.retain_recent_legal_suffix(
|
||||
result = probe.retain_recent_legal_suffix(
|
||||
max_suffix,
|
||||
extend_to_user=True,
|
||||
)
|
||||
kept = probe.messages
|
||||
archive_msgs = dropped[already_consolidated:]
|
||||
archive_msgs = result.dropped[result.already_consolidated_count:]
|
||||
|
||||
if not archive_msgs and not kept:
|
||||
session.updated_at = datetime.now()
|
||||
loop.sessions.save(session)
|
||||
return ""
|
||||
|
||||
@@ -132,7 +130,6 @@ def _make_fake_compact(
|
||||
|
||||
session.messages = kept
|
||||
session.last_consolidated = 0
|
||||
session.updated_at = datetime.now()
|
||||
loop.sessions.save(session)
|
||||
return s
|
||||
|
||||
@@ -223,7 +220,7 @@ class TestAgentLoopTTLParam:
|
||||
kwargs = session.get_history.call_args.kwargs
|
||||
assert isinstance(kwargs.get("max_tokens"), int)
|
||||
assert kwargs["max_tokens"] > 0
|
||||
assert kwargs["include_timestamps"] is True
|
||||
assert set(kwargs) == {"max_messages", "max_tokens", "extend_to_user"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
|
||||
@@ -1021,27 +1018,28 @@ class TestProactiveAutoCompact:
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 1
|
||||
|
||||
# Second tick: should NOT re-schedule (updated_at is fresh after clear)
|
||||
# Second tick: should NOT re-schedule because the session has no removable tail.
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_skip_refreshes_updated_at_prevents_reschedule(self, tmp_path):
|
||||
"""Empty session skip refreshes updated_at, preventing immediate re-scheduling."""
|
||||
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
|
||||
"""Empty expired sessions have no removable tail and should not schedule."""
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("cli:test")
|
||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||
loop.sessions.save(session)
|
||||
|
||||
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||
_fake_compact = _make_fake_compact(loop)
|
||||
loop.consolidator.compact_idle_session = _fake_compact
|
||||
|
||||
# First tick: skips (no messages), refreshes updated_at
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 0
|
||||
assert "cli:test" not in loop.auto_compact._summaries
|
||||
|
||||
# Second tick: should NOT re-schedule because updated_at is fresh
|
||||
await self._run_check_expired(loop)
|
||||
assert _fake_compact.state["count"] == 0
|
||||
assert "cli:test" not in loop.auto_compact._summaries
|
||||
await loop.close_mcp()
|
||||
|
||||
|
||||
@@ -200,8 +200,11 @@ class TestCheckExpired:
|
||||
"""Expired session should trigger schedule_background."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}]
|
||||
old_dt = datetime.now() - timedelta(minutes=20)
|
||||
session = _make_session("cli:old", updated_at=old_dt)
|
||||
_add_turns(session, 5)
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_dt.isoformat()}]
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
|
||||
scheduled = []
|
||||
@@ -273,6 +276,24 @@ class TestCheckExpired:
|
||||
scheduler.assert_not_called()
|
||||
assert "dream:20260602-155256" not in ac._archiving
|
||||
|
||||
def test_already_trimmed_session_skips(self):
|
||||
"""Expired session with no removable tail should not be re-scheduled."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
last_active = datetime(2026, 1, 1, 10, 0, 0)
|
||||
session = _make_session("cli:done", updated_at=last_active)
|
||||
_add_turns(session, 2)
|
||||
mock_sm.list_sessions.return_value = [
|
||||
{"key": "cli:done", "updated_at": last_active.isoformat()},
|
||||
]
|
||||
mock_sm.get_or_create.return_value = session
|
||||
ac.sessions = mock_sm
|
||||
|
||||
scheduler = MagicMock()
|
||||
ac.check_expired(scheduler)
|
||||
|
||||
scheduler.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _archive
|
||||
|
||||
@@ -430,9 +430,11 @@ class TestCompactIdleSession:
|
||||
)
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:test")
|
||||
old_ts = session.updated_at
|
||||
for i in range(20):
|
||||
session.add_message("user", f"user msg {i}")
|
||||
session.add_message("assistant", f"assistant msg {i}")
|
||||
session.updated_at = old_ts
|
||||
sessions.save(session)
|
||||
|
||||
result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8)
|
||||
@@ -445,6 +447,7 @@ class TestCompactIdleSession:
|
||||
assert meta is not None
|
||||
assert meta["text"] == "Summary of old conversation."
|
||||
assert "last_active" in meta
|
||||
assert reloaded.updated_at == old_ts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
|
||||
@@ -518,8 +521,10 @@ class TestCompactIdleSession:
|
||||
assert entries[0]["session_key"] == "cli:test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_session_refreshes_timestamp(self, real_consolidator):
|
||||
"""Empty session with old updated_at → refreshed after call, returns ''."""
|
||||
async def test_empty_session_does_not_refresh_timestamp(
|
||||
self, real_consolidator
|
||||
):
|
||||
"""Empty session with old updated_at does not look active after compaction."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
sessions = real_consolidator.sessions
|
||||
@@ -532,7 +537,8 @@ class TestCompactIdleSession:
|
||||
assert result == ""
|
||||
|
||||
reloaded = sessions.get_or_create("cli:empty")
|
||||
assert reloaded.updated_at > old_ts
|
||||
assert reloaded.updated_at == old_ts
|
||||
assert reloaded.metadata == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
|
||||
|
||||
@@ -24,9 +24,14 @@ class TestDreamSessionKey:
|
||||
|
||||
class TestPruneDreamSessions:
|
||||
def test_keeps_n_most_recent(self, tmp_path):
|
||||
import os
|
||||
import time
|
||||
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
|
||||
base_time = time.time() - 100
|
||||
|
||||
for i in range(15):
|
||||
key = f"dream:20260528-{100000 + i:06d}"
|
||||
safe_key = key.replace(":", "_")
|
||||
@@ -37,6 +42,7 @@ class TestPruneDreamSessions:
|
||||
f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.utime(path, (base_time + i, base_time + i))
|
||||
|
||||
normal_path = sessions_dir / "telegram_123.jsonl"
|
||||
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
|
||||
|
||||
@@ -11,7 +11,6 @@ from unittest.mock import patch
|
||||
from nanobot.providers.base import ToolCallRequest
|
||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||
|
||||
|
||||
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
|
||||
|
||||
|
||||
@@ -125,6 +124,47 @@ def test_parse_dict_preserves_extra_content() -> None:
|
||||
assert payload["extra_content"] == GEMINI_EXTRA
|
||||
|
||||
|
||||
def test_parse_dict_deduplicates_duplicate_tool_call_ids() -> None:
|
||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
||||
provider = OpenAICompatProvider()
|
||||
|
||||
response_dict = {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_same",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
},
|
||||
{
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_same",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = provider._parse(response_dict)
|
||||
|
||||
ids = [tc.id for tc in result.tool_calls]
|
||||
assert len(ids) == 2
|
||||
assert ids[0] == "call_same"
|
||||
assert ids[1] != "call_same"
|
||||
assert len(set(ids)) == 2
|
||||
assert [tc.arguments for tc in result.tool_calls] == [{"path": "a.txt"}, {"path": "b.txt"}]
|
||||
|
||||
|
||||
# ── _parse_chunks: streaming round-trip ───────────────────────────────
|
||||
|
||||
def test_parse_chunks_sdk_preserves_extra_content() -> None:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for max_messages config wiring into session history replay."""
|
||||
"""Tests for the internal max_messages replay cap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,20 +11,27 @@ from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
DEFAULT_MAX_MESSAGES = 120
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.session.manager import (
|
||||
FILE_MAX_MESSAGES,
|
||||
Session,
|
||||
replay_max_messages_for_context,
|
||||
)
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path, max_messages: int = DEFAULT_MAX_MESSAGES) -> AgentLoop:
|
||||
def _make_loop(
|
||||
tmp_path: Path,
|
||||
context_window_tokens: int = 200_000,
|
||||
) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation.max_tokens = 4096
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
max_messages=max_messages,
|
||||
context_window_tokens=context_window_tokens,
|
||||
)
|
||||
|
||||
|
||||
@@ -51,24 +58,44 @@ def _tool_round(call_id: str) -> list[dict]:
|
||||
|
||||
|
||||
class TestMaxMessagesInit:
|
||||
"""Verify AgentLoop stores the config value correctly."""
|
||||
"""Verify AgentLoop derives the internal replay cap correctly."""
|
||||
|
||||
def test_default_is_builtin_limit(self, tmp_path: Path) -> None:
|
||||
def test_context_formula(self) -> None:
|
||||
assert replay_max_messages_for_context(8_000) == 120
|
||||
assert replay_max_messages_for_context(32_768) == 327
|
||||
assert replay_max_messages_for_context(200_000) == FILE_MAX_MESSAGES
|
||||
|
||||
def test_default_for_200k_context_reaches_file_cap(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||
assert loop._max_messages == FILE_MAX_MESSAGES
|
||||
|
||||
def test_positive_value_stored(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, max_messages=25)
|
||||
assert loop._max_messages == 25
|
||||
def test_default_scales_with_context_window(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, context_window_tokens=32_768)
|
||||
assert loop._max_messages == 327
|
||||
|
||||
def test_zero_uses_builtin_limit(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, max_messages=0)
|
||||
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||
def test_provider_refresh_resyncs_context_derived_limit(self, tmp_path: Path) -> None:
|
||||
old_provider = MagicMock()
|
||||
old_provider.get_default_model.return_value = "old-model"
|
||||
old_provider.generation.max_tokens = 4096
|
||||
new_provider = MagicMock()
|
||||
new_provider.generation.max_tokens = 4096
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=old_provider,
|
||||
workspace=tmp_path,
|
||||
model="old-model",
|
||||
context_window_tokens=32_768,
|
||||
provider_snapshot_loader=lambda: ProviderSnapshot(
|
||||
provider=new_provider,
|
||||
model="new-model",
|
||||
context_window_tokens=200_000,
|
||||
signature=("new-model",),
|
||||
),
|
||||
)
|
||||
|
||||
def test_negative_treated_as_builtin_limit(self, tmp_path: Path) -> None:
|
||||
"""Negative values should not produce negative slicing."""
|
||||
loop = _make_loop(tmp_path, max_messages=-5)
|
||||
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||
assert loop._max_messages == 327
|
||||
loop._refresh_provider_snapshot()
|
||||
assert loop._max_messages == FILE_MAX_MESSAGES
|
||||
|
||||
|
||||
class TestGetHistoryWithMaxMessages:
|
||||
@@ -77,7 +104,7 @@ class TestGetHistoryWithMaxMessages:
|
||||
def test_default_uses_builtin_limit(self) -> None:
|
||||
session = _populated_session(80)
|
||||
history = session.get_history()
|
||||
assert len(history) <= DEFAULT_MAX_MESSAGES
|
||||
assert len(history) <= FILE_MAX_MESSAGES
|
||||
|
||||
def test_explicit_max_messages_limits_output(self) -> None:
|
||||
session = _populated_session(40) # 80 messages total
|
||||
@@ -93,7 +120,7 @@ class TestGetHistoryWithMaxMessages:
|
||||
def test_max_messages_zero_uses_builtin_limit(self) -> None:
|
||||
session = _populated_session(80) # 160 messages total
|
||||
history = session.get_history(max_messages=0)
|
||||
assert len(history) <= DEFAULT_MAX_MESSAGES
|
||||
assert len(history) <= FILE_MAX_MESSAGES
|
||||
|
||||
def test_small_session_unaffected(self) -> None:
|
||||
"""When session has fewer messages than max_messages, all are returned."""
|
||||
@@ -103,12 +130,13 @@ class TestGetHistoryWithMaxMessages:
|
||||
|
||||
|
||||
class TestMaxMessagesIntegration:
|
||||
"""Verify the config flows from AgentLoop into get_history calls."""
|
||||
"""Verify AgentLoop passes the replay cap into get_history calls."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_passes_config_to_history_call(self, tmp_path: Path) -> None:
|
||||
async def test_process_message_passes_limit_to_history_call(self, tmp_path: Path) -> None:
|
||||
"""The real message path should pass max_messages into session history replay."""
|
||||
loop = _make_loop(tmp_path, max_messages=25)
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._max_messages = 25
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
||||
)
|
||||
@@ -127,8 +155,11 @@ class TestMaxMessagesIntegration:
|
||||
assert mock_hist.call_args.kwargs["extend_to_user"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_config_passes_builtin_limit_to_history_call(self, tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, max_messages=0)
|
||||
async def test_default_limit_passes_context_derived_limit_to_history_call(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
||||
)
|
||||
@@ -142,7 +173,7 @@ class TestMaxMessagesIntegration:
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert mock_hist.call_args.kwargs["max_messages"] == DEFAULT_MAX_MESSAGES
|
||||
assert mock_hist.call_args.kwargs["max_messages"] == FILE_MAX_MESSAGES
|
||||
assert mock_hist.call_args.kwargs["extend_to_user"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -151,7 +182,8 @@ class TestMaxMessagesIntegration:
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A live user turn should not extend history to an older long tool turn."""
|
||||
loop = _make_loop(tmp_path, max_messages=6)
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._max_messages = 6
|
||||
loop.provider.chat_with_retry = AsyncMock(
|
||||
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
||||
)
|
||||
@@ -182,31 +214,3 @@ class TestMaxMessagesIntegration:
|
||||
sent_text = "\n".join(str(message.get("content")) for message in sent_messages)
|
||||
assert "new question" in sent_text
|
||||
assert "long older turn" not in sent_text
|
||||
|
||||
|
||||
class TestSchemaConfig:
|
||||
"""Verify the config schema accepts max_messages."""
|
||||
|
||||
def test_schema_default(self) -> None:
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
defaults = AgentDefaults()
|
||||
assert defaults.max_messages == DEFAULT_MAX_MESSAGES
|
||||
|
||||
def test_schema_accepts_zero_as_builtin_limit(self) -> None:
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
defaults = AgentDefaults(max_messages=0)
|
||||
assert defaults.max_messages == 0
|
||||
|
||||
def test_schema_accepts_positive(self) -> None:
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
defaults = AgentDefaults(max_messages=25)
|
||||
assert defaults.max_messages == 25
|
||||
|
||||
def test_schema_rejects_negative(self) -> None:
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
with pytest.raises(Exception): # Pydantic validation error
|
||||
AgentDefaults(max_messages=-1)
|
||||
|
||||
@@ -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
|
||||
|
||||
_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)
|
||||
@@ -695,3 +877,162 @@ def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
|
||||
assert non_system[0]["role"] in ("user", "tool"), (
|
||||
f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Malformed tool_call name guard (missing/non-string name wedges the session
|
||||
# upstream: messages.content.N.tool_use.name: Input should be a valid string)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_drop_malformed_tool_calls_trims_response():
|
||||
"""LLM response tool_calls with a missing/empty name are dropped in place."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
response = LLMResponse(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCallRequest(id="1", name=None, arguments={}),
|
||||
ToolCallRequest(id="2", name="", arguments={}),
|
||||
ToolCallRequest(id="3", name="read_file", arguments={}),
|
||||
],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
||||
assert [tc.name for tc in response.tool_calls] == ["read_file"]
|
||||
assert response.finish_reason == "tool_calls"
|
||||
assert response.should_execute_tools is True
|
||||
assert dropped == 2
|
||||
assert all_dropped is False
|
||||
assert orig == "tool_calls"
|
||||
|
||||
|
||||
def test_drop_malformed_tool_calls_all_bad_disables_execution():
|
||||
"""If every tool call is malformed, execution is disabled (no empty exec)."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
response = LLMResponse(
|
||||
content="some text",
|
||||
tool_calls=[ToolCallRequest(id="1", name=None, arguments={})],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
||||
assert response.tool_calls == []
|
||||
assert response.finish_reason == "stop"
|
||||
assert response.should_execute_tools is False
|
||||
assert dropped == 1
|
||||
assert all_dropped is True
|
||||
assert orig == "tool_calls"
|
||||
|
||||
|
||||
def test_drop_malformed_returns_tuple_no_calls():
|
||||
"""No tool calls returns (0, False, current_finish_reason)."""
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
|
||||
response = LLMResponse(content="hi", finish_reason="stop")
|
||||
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
||||
assert dropped == 0
|
||||
assert all_dropped is False
|
||||
assert orig == "stop"
|
||||
|
||||
|
||||
def test_strip_malformed_tool_calls_keeps_valid_calls_in_history():
|
||||
"""A mixed assistant turn keeps only its valid tool_calls."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "bad", "type": "function", "function": {"name": None, "arguments": "{}"}},
|
||||
{"id": "ok", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "ok", "name": "exec", "content": "done"},
|
||||
]
|
||||
result = ContextGovernor.strip_malformed_tool_calls(messages)
|
||||
assert result is not messages # copied, original untouched
|
||||
assert len(messages[1]["tool_calls"]) == 2 # original preserved
|
||||
kept = result[1]["tool_calls"]
|
||||
assert [tc["function"]["name"] for tc in kept] == ["exec"]
|
||||
|
||||
|
||||
def test_strip_malformed_tool_calls_drops_empty_assistant_turn():
|
||||
"""An assistant turn that is only a malformed call is removed entirely;
|
||||
the existing orphan-result cleanup then drops its dangling tool result,
|
||||
so a polluted session self-heals."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "bad", "type": "function", "function": {"name": None, "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "bad", "name": "", "content": "r"},
|
||||
]
|
||||
stripped = ContextGovernor.strip_malformed_tool_calls(messages)
|
||||
assert [m["role"] for m in stripped] == ["user", "tool"]
|
||||
healed = ContextGovernor.drop_orphan_tool_results(stripped)
|
||||
assert [m["role"] for m in healed] == ["user"]
|
||||
|
||||
|
||||
def test_strip_malformed_tool_calls_noop_when_clean():
|
||||
"""Clean history is returned unchanged (same object)."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "ok", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "ok", "name": "exec", "content": "done"},
|
||||
]
|
||||
assert ContextGovernor.strip_malformed_tool_calls(messages) is messages
|
||||
|
||||
|
||||
def test_strip_placeholder_assistant_messages_removes_omitted():
|
||||
"""Placeholder assistant messages are removed; real messages kept."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "real response"},
|
||||
{"role": "user", "content": "ok"},
|
||||
{"role": "assistant", "content": "[Previous assistant message omitted.]"},
|
||||
{"role": "user", "content": "?"},
|
||||
{"role": "assistant", "content": "[Previous assistant message omitted.]"},
|
||||
{"role": "user", "content": "hello"},
|
||||
]
|
||||
result = ContextGovernor.strip_placeholder_assistant_messages(messages)
|
||||
assert [m["role"] for m in result] == [
|
||||
"user", "assistant", "user", "user", "user",
|
||||
]
|
||||
assert result[1]["content"] == "real response"
|
||||
|
||||
|
||||
def test_strip_placeholder_noop_when_clean():
|
||||
"""Clean history is returned unchanged (same object)."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello back"},
|
||||
]
|
||||
assert ContextGovernor.strip_placeholder_assistant_messages(messages) is messages
|
||||
|
||||
|
||||
def test_strip_placeholder_keeps_assistant_with_tool_calls():
|
||||
"""A placeholder assistant that also carries tool_calls is kept."""
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "[Previous assistant message omitted.]",
|
||||
"tool_calls": [
|
||||
{"id": "1", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "1", "name": "exec", "content": "done"},
|
||||
]
|
||||
result = ContextGovernor.strip_placeholder_assistant_messages(messages)
|
||||
assert result is messages
|
||||
|
||||
@@ -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] = []
|
||||
@@ -172,7 +170,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 +193,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,
|
||||
|
||||
@@ -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": "来了 🎨"}]
|
||||
|
||||
@@ -686,12 +685,12 @@ def test_retain_recent_legal_suffix_returns_dropped_messages():
|
||||
for i in range(10):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
|
||||
dropped, already_cons = session.retain_recent_legal_suffix(4)
|
||||
result = session.retain_recent_legal_suffix(4)
|
||||
|
||||
assert len(dropped) == 6
|
||||
assert [m["content"] for m in dropped] == [f"msg{i}" for i in range(6)]
|
||||
assert len(result.dropped) == 6
|
||||
assert [m["content"] for m in result.dropped] == [f"msg{i}" for i in range(6)]
|
||||
assert len(session.messages) == 4
|
||||
assert already_cons == 0
|
||||
assert result.already_consolidated_count == 0
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
||||
@@ -700,10 +699,10 @@ def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
||||
for i in range(3):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
|
||||
dropped, already_cons = session.retain_recent_legal_suffix(4)
|
||||
result = session.retain_recent_legal_suffix(4)
|
||||
|
||||
assert dropped == []
|
||||
assert already_cons == 0
|
||||
assert result.dropped == []
|
||||
assert result.already_consolidated_count == 0
|
||||
assert len(session.messages) == 3
|
||||
|
||||
|
||||
@@ -714,10 +713,10 @@ def test_retain_recent_legal_suffix_returns_all_on_zero():
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
session.last_consolidated = 3
|
||||
|
||||
dropped, already_cons = session.retain_recent_legal_suffix(0)
|
||||
result = session.retain_recent_legal_suffix(0)
|
||||
|
||||
assert len(dropped) == 5
|
||||
assert already_cons == 3
|
||||
assert len(result.dropped) == 5
|
||||
assert result.already_consolidated_count == 3
|
||||
assert session.messages == []
|
||||
|
||||
|
||||
@@ -821,11 +820,11 @@ def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
|
||||
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
||||
session.last_consolidated = 12 # u0..u9, a0, a1 consolidated
|
||||
|
||||
dropped, already_cons = session.retain_recent_legal_suffix(4)
|
||||
result = session.retain_recent_legal_suffix(4)
|
||||
|
||||
# Retained messages start from latest user (u9) + max_messages forward
|
||||
# so retained = [u9, a0..a9][:4] → but these are from original indices 9..12
|
||||
# Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3
|
||||
assert session.last_consolidated == 3
|
||||
# already_cons should count dropped messages with original index < 12
|
||||
assert already_cons == 9
|
||||
assert result.already_consolidated_count == 9
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for tool hint formatting (nanobot.utils.tool_hints)."""
|
||||
|
||||
from nanobot.utils.tool_hints import format_tool_hints
|
||||
from nanobot.providers.base import ToolCallRequest
|
||||
from nanobot.utils.tool_hints import format_tool_hints
|
||||
|
||||
|
||||
def _tc(name: str, args) -> ToolCallRequest:
|
||||
@@ -306,3 +306,22 @@ class TestToolHintMaxLength:
|
||||
short = _hint([_tc("list_dir", {"path": long_path})], max_length=40)
|
||||
long = _hint([_tc("list_dir", {"path": long_path})], max_length=120)
|
||||
assert len(long) > len(short)
|
||||
|
||||
|
||||
class TestToolHintMalformedCalls:
|
||||
"""Malformed tool calls must not crash hint formatting (see HKUDS/nanobot)."""
|
||||
|
||||
def test_none_name_is_skipped(self):
|
||||
"""A tool call with name=None should be skipped, not raise AttributeError."""
|
||||
result = _hint([_tc(None, None)])
|
||||
assert result == ""
|
||||
|
||||
def test_empty_name_is_skipped(self):
|
||||
"""A tool call with an empty name should be skipped."""
|
||||
result = _hint([_tc("", {"path": "foo.txt"})])
|
||||
assert result == ""
|
||||
|
||||
def test_none_name_mixed_with_valid_call(self):
|
||||
"""A degenerate call must not suppress hints for the valid calls beside it."""
|
||||
result = _hint([_tc(None, None), _tc("read_file", {"path": "foo.txt"})])
|
||||
assert result == "read foo.txt"
|
||||
|
||||
@@ -236,10 +236,12 @@ class TestModifyRestricted:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_context_window_valid(self):
|
||||
tool = _make_tool()
|
||||
loop = _make_mock_loop(_sync_replay_max_messages=MagicMock())
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||
assert "Set context_window_tokens" in result
|
||||
assert tool._runtime_state.context_window_tokens == 131072
|
||||
assert loop.context_window_tokens == 131072
|
||||
loop._sync_replay_max_messages.assert_called_once_with()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_none_value_for_restricted_int(self):
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
@@ -482,3 +483,49 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
await hang_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_routes_subagent_results_to_pending_queue(tmp_path):
|
||||
"""Single-message CLI mode should consume subagent announcements mid-turn."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=MagicMock(),
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
)
|
||||
loop._connect_mcp = AsyncMock() # type: ignore[method-assign]
|
||||
|
||||
async def fake_process_message(msg, **kwargs):
|
||||
pending_queue = kwargs["pending_queue"]
|
||||
await loop.bus.publish_inbound(InboundMessage(
|
||||
channel="other",
|
||||
sender_id="u",
|
||||
chat_id="room",
|
||||
content="unrelated",
|
||||
))
|
||||
await loop.subagents._announce_result(
|
||||
"sub-1",
|
||||
"label",
|
||||
"task",
|
||||
"subagent result",
|
||||
{"channel": "cli", "chat_id": "direct", "session_key": "cli:direct"},
|
||||
"ok",
|
||||
)
|
||||
routed = await asyncio.wait_for(pending_queue.get(), timeout=1)
|
||||
assert "subagent result" in routed.content
|
||||
assert routed.metadata["subagent_task_id"] == "sub-1"
|
||||
return OutboundMessage(channel="cli", chat_id="direct", content="done")
|
||||
|
||||
loop._process_message = fake_process_message # type: ignore[method-assign]
|
||||
|
||||
response = await loop.process_direct("start", session_key="cli:direct")
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "done"
|
||||
unrelated = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=1)
|
||||
assert unrelated.content == "unrelated"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Test websocket subscribe hydration only replays known active turns."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.websocket import WebSocketChannel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active():
|
||||
"""Subscribe hydration must not inject an idle event into normal message order."""
|
||||
channel = WebSocketChannel.__new__(WebSocketChannel)
|
||||
channel.gateway = MagicMock()
|
||||
channel.gateway.session_manager = MagicMock()
|
||||
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
||||
|
||||
sent_events = []
|
||||
|
||||
async def mock_send_goal_state(chat_id, blob):
|
||||
sent_events.append(("goal_state", chat_id, blob))
|
||||
|
||||
async def mock_send_goal_status(chat_id, status, **kwargs):
|
||||
sent_events.append(("goal_status", chat_id, status, kwargs))
|
||||
|
||||
channel.send_goal_state = mock_send_goal_state
|
||||
channel.send_goal_status = mock_send_goal_status
|
||||
|
||||
with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=None):
|
||||
await channel._hydrate_after_subscribe("test-chat")
|
||||
|
||||
assert sent_events == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
|
||||
"""Reconnecting client should receive running status when turn is active."""
|
||||
channel = WebSocketChannel.__new__(WebSocketChannel)
|
||||
channel.gateway = MagicMock()
|
||||
channel.gateway.session_manager = MagicMock()
|
||||
channel.gateway.session_manager.read_session_file = MagicMock(return_value={})
|
||||
|
||||
sent_events = []
|
||||
|
||||
async def mock_send_goal_state(chat_id, blob):
|
||||
sent_events.append(("goal_state", chat_id, blob))
|
||||
|
||||
async def mock_send_goal_status(chat_id, status, **kwargs):
|
||||
sent_events.append(("goal_status", chat_id, status, kwargs))
|
||||
|
||||
channel.send_goal_state = mock_send_goal_state
|
||||
channel.send_goal_status = mock_send_goal_status
|
||||
|
||||
with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=1234567890.0):
|
||||
await channel._hydrate_after_subscribe("test-chat")
|
||||
|
||||
running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"]
|
||||
assert len(running_events) == 1
|
||||
assert running_events[0][3]["started_at"] == 1234567890.0
|
||||
@@ -1764,6 +1764,44 @@ async def test_buffer_flushed_on_stream_end() -> None:
|
||||
assert "wx-user" not in channel._pending_tool_hints
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_flushes_buffered_answer() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-1"
|
||||
channel._context_token_at["wx-user"] = time.time()
|
||||
channel._send_text = AsyncMock()
|
||||
|
||||
await channel.send_delta("wx-user", "hello ", {"_stream_delta": True})
|
||||
await channel.send_delta("wx-user", "world", {"_stream_end": True})
|
||||
|
||||
channel._send_text.assert_awaited_once_with("wx-user", "hello world", "ctx-1")
|
||||
assert "wx-user" not in channel._stream_buffers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_send_failure_keeps_buffer_for_retry() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
channel._client = object()
|
||||
channel._token = "token"
|
||||
channel._context_tokens["wx-user"] = "ctx-1"
|
||||
channel._context_token_at["wx-user"] = time.time()
|
||||
channel._send_text = AsyncMock(side_effect=RuntimeError("temporary send failure"))
|
||||
|
||||
await channel.send_delta("wx-user", "hello ", {"_stream_delta": True})
|
||||
with pytest.raises(RuntimeError):
|
||||
await channel.send_delta("wx-user", "world", {"_stream_end": True})
|
||||
|
||||
assert channel._stream_buffers["wx-user"] == ["hello "]
|
||||
|
||||
channel._send_text = AsyncMock()
|
||||
await channel.send_delta("wx-user", "world", {"_stream_end": True})
|
||||
|
||||
channel._send_text.assert_awaited_once_with("wx-user", "hello world", "ctx-1")
|
||||
assert "wx-user" not in channel._stream_buffers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_clears_buffer() -> None:
|
||||
channel, _bus = _make_channel()
|
||||
|
||||
@@ -1,507 +1,528 @@
|
||||
"""Tests for WhatsApp channel outbound media support."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
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])
|
||||
def _patch_receipt_type(monkeypatch):
|
||||
neonize = types.ModuleType("neonize")
|
||||
utils = types.ModuleType("neonize.utils")
|
||||
enum = types.ModuleType("neonize.utils.enum")
|
||||
|
||||
assert text_payload["type"] == "send"
|
||||
assert text_payload["text"] == "check this out"
|
||||
class ReceiptType:
|
||||
READ = "read"
|
||||
|
||||
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"
|
||||
enum.ReceiptType = ReceiptType
|
||||
neonize.utils = utils
|
||||
utils.enum = enum
|
||||
monkeypatch.setitem(sys.modules, "neonize", neonize)
|
||||
monkeypatch.setitem(sys.modules, "neonize.utils", utils)
|
||||
monkeypatch.setitem(sys.modules, "neonize.utils.enum", enum)
|
||||
return ReceiptType
|
||||
|
||||
|
||||
class _FakeLoginClient:
|
||||
def __init__(self) -> None:
|
||||
self.handlers = {}
|
||||
self.me = _Proto(JID=_jid("bot", "s.whatsapp.net"), LID=_jid("BOTLID", "lid"))
|
||||
self.stop = AsyncMock()
|
||||
|
||||
def event(self, event_type):
|
||||
def register(func):
|
||||
self.handlers[event_type] = func
|
||||
return func
|
||||
|
||||
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_read_receipt_is_requested_once_after_dedup() -> None:
|
||||
ch = _make_channel()
|
||||
ch._send_read_receipt = AsyncMock()
|
||||
ch._handle_message = AsyncMock()
|
||||
client = SimpleNamespace(download_any=AsyncMock())
|
||||
event = _event(
|
||||
message=_Proto(conversation="hi"),
|
||||
sender=_jid("15551234567", "s.whatsapp.net"),
|
||||
)
|
||||
|
||||
await ch._handle_neonize_message(client, event)
|
||||
await ch._handle_neonize_message(client, event)
|
||||
|
||||
ch._send_read_receipt.assert_awaited_once_with(
|
||||
client,
|
||||
event.Info.MessageSource,
|
||||
"m1",
|
||||
)
|
||||
ch._handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_read_receipt_uses_mark_read_and_swallows_failures(monkeypatch) -> None:
|
||||
receipt_type = _patch_receipt_type(monkeypatch)
|
||||
ch = _make_channel()
|
||||
source = _event(
|
||||
message=_Proto(conversation="hi"),
|
||||
sender=_jid("15551234567", "s.whatsapp.net"),
|
||||
).Info.MessageSource
|
||||
client = SimpleNamespace(
|
||||
mark_read=AsyncMock(),
|
||||
download_any=AsyncMock(),
|
||||
)
|
||||
|
||||
await ch._send_read_receipt(client, source, "m1")
|
||||
|
||||
client.mark_read.assert_awaited_once_with(
|
||||
"m1",
|
||||
chat=source.Chat,
|
||||
sender=source.Sender,
|
||||
receipt=receipt_type.READ,
|
||||
)
|
||||
|
||||
failing_client = SimpleNamespace(
|
||||
mark_read=AsyncMock(side_effect=RuntimeError("boom")),
|
||||
download_any=AsyncMock(),
|
||||
)
|
||||
|
||||
await ch._send_read_receipt(failing_client, source, "m2")
|
||||
|
||||
failing_client.mark_read.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
|
||||
+213
-2
@@ -5,11 +5,13 @@ import shutil
|
||||
import signal
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
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
|
||||
@@ -18,7 +20,7 @@ from nanobot.cron.service import CronJobSkippedError
|
||||
from nanobot.cron.session_turns import CRON_DEFER_UNTIL_IDLE_META, CRON_TRIGGER_META
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
||||
from nanobot.providers.factory import ProviderSnapshot, make_provider
|
||||
from nanobot.providers.factory import ProviderSnapshot, make_provider, provider_signature
|
||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.metadata import (
|
||||
@@ -140,6 +142,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."""
|
||||
@@ -420,6 +435,154 @@ def test_provider_login_rejects_unknown_provider():
|
||||
assert "Unknown OAuth provider" in result.stdout
|
||||
|
||||
|
||||
def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
called = False
|
||||
original = cli_commands._LOGIN_HANDLERS["openai_codex"]
|
||||
|
||||
def fake_login() -> None:
|
||||
nonlocal called
|
||||
called = True
|
||||
|
||||
cli_commands._LOGIN_HANDLERS["openai_codex"] = fake_login
|
||||
try:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"provider",
|
||||
"login",
|
||||
"openai-codex",
|
||||
"--set-main",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
)
|
||||
finally:
|
||||
cli_commands._LOGIN_HANDLERS["openai_codex"] = original
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert called is True
|
||||
assert "Set openai-codex as the main provider" in result.stdout
|
||||
|
||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||
assert saved.agents.defaults.provider == "openai_codex"
|
||||
assert saved.agents.defaults.model == "openai-codex/gpt-5.4-mini"
|
||||
assert saved.agents.defaults.model_preset is None
|
||||
assert make_provider(saved).__class__.__name__ == "OpenAICodexProvider"
|
||||
|
||||
|
||||
def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
original = cli_commands._LOGIN_HANDLERS["github_copilot"]
|
||||
cli_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
|
||||
try:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"provider",
|
||||
"login",
|
||||
"github-copilot",
|
||||
"--set-main",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
)
|
||||
finally:
|
||||
cli_commands._LOGIN_HANDLERS["github_copilot"] = original
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Set github-copilot as the main provider" in result.stdout
|
||||
|
||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||
assert saved.agents.defaults.provider == "github_copilot"
|
||||
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
|
||||
assert saved.agents.defaults.model_preset is None
|
||||
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
||||
|
||||
|
||||
def test_provider_login_model_implies_set_main_provider(tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
original = cli_commands._LOGIN_HANDLERS["github_copilot"]
|
||||
cli_commands._LOGIN_HANDLERS["github_copilot"] = lambda: None
|
||||
try:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"provider",
|
||||
"login",
|
||||
"github-copilot",
|
||||
"--model",
|
||||
"github-copilot/gpt-5.4-mini",
|
||||
"--config",
|
||||
str(config_path),
|
||||
],
|
||||
)
|
||||
finally:
|
||||
cli_commands._LOGIN_HANDLERS["github_copilot"] = original
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Set github-copilot as the main provider" in result.stdout
|
||||
|
||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||
assert saved.agents.defaults.provider == "github_copilot"
|
||||
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
|
||||
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
||||
|
||||
|
||||
def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch):
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.load_config",
|
||||
lambda: Config.model_validate({"providers": {"openaiCodex": {"proxy": proxy}}}),
|
||||
)
|
||||
|
||||
import oauth_cli_kit
|
||||
|
||||
def fake_get_token(**_kwargs):
|
||||
raise RuntimeError("no-token")
|
||||
|
||||
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
||||
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fake_login(*, print_fn, prompt_fn, proxy=None):
|
||||
captured["proxy"] = proxy
|
||||
return SimpleNamespace(access="access-token", account_id="acct-test")
|
||||
|
||||
monkeypatch.setattr(oauth_cli_kit, "login_oauth_interactive", fake_login)
|
||||
|
||||
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["proxy"] == proxy
|
||||
|
||||
|
||||
def test_provider_login_openai_codex_resolves_proxy_env_ref(monkeypatch):
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
monkeypatch.setenv("CODEX_PROXY_FOR_TEST", proxy)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.load_config",
|
||||
lambda: Config.model_validate(
|
||||
{"providers": {"openaiCodex": {"proxy": "${CODEX_PROXY_FOR_TEST}"}}}
|
||||
),
|
||||
)
|
||||
|
||||
import oauth_cli_kit
|
||||
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def fake_get_token(*, proxy=None):
|
||||
captured["proxy"] = proxy
|
||||
return SimpleNamespace(access="access-token", account_id="acct-test")
|
||||
|
||||
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
|
||||
|
||||
result = runner.invoke(app, ["provider", "login", "openai-codex"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert captured["proxy"] == proxy
|
||||
|
||||
|
||||
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
||||
config = Config()
|
||||
config.agents.defaults.model = "ollama/llama3.2"
|
||||
@@ -671,6 +834,54 @@ def test_make_provider_uses_github_copilot_backend():
|
||||
assert provider.__class__.__name__ == "GitHubCopilotProvider"
|
||||
|
||||
|
||||
def test_openai_codex_proxy_config_affects_provider_and_signature():
|
||||
def config_with_proxy(proxy: str) -> Config:
|
||||
return Config.model_validate(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "openai-codex",
|
||||
"model": "openai-codex/gpt-5.5",
|
||||
}
|
||||
},
|
||||
"providers": {"openaiCodex": {"proxy": proxy}},
|
||||
}
|
||||
)
|
||||
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config = config_with_proxy(proxy)
|
||||
|
||||
provider = make_provider(config)
|
||||
|
||||
assert provider.__class__.__name__ == "OpenAICodexProvider"
|
||||
assert provider.proxy == proxy
|
||||
assert provider_signature(config) != provider_signature(
|
||||
config_with_proxy("http://127.0.0.1:23459")
|
||||
)
|
||||
|
||||
|
||||
def test_provider_proxy_rejects_unsupported_backend():
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "anthropic",
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"apiKey": "sk-test",
|
||||
"proxy": "http://127.0.0.1:23458",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=r"providers\.anthropic\.proxy"):
|
||||
make_provider(config)
|
||||
|
||||
|
||||
def test_github_copilot_provider_strips_prefixed_model_name():
|
||||
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
|
||||
|
||||
@@ -738,7 +949,7 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||
"x-session-affinity": "sticky-session",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -44,7 +45,8 @@ class TestRestartCommand:
|
||||
RESTART_STARTED_AT_ENV,
|
||||
)
|
||||
|
||||
loop, bus = _make_loop()
|
||||
loop, _bus = _make_loop()
|
||||
loop.restart_mode = "exec"
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
|
||||
|
||||
@@ -76,10 +78,75 @@ class TestRestartCommand:
|
||||
await scheduled[0]
|
||||
mock_execv.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_windows_auto_spawns_and_exits(self):
|
||||
from nanobot.command.builtin import cmd_restart
|
||||
from nanobot.command.router import CommandContext
|
||||
|
||||
loop, _bus = _make_loop()
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
|
||||
|
||||
async def _fast_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
scheduled: list[asyncio.Task] = []
|
||||
fake_asyncio = SimpleNamespace(
|
||||
sleep=_fast_sleep,
|
||||
create_task=lambda coro: scheduled.append(asyncio.create_task(coro)) or scheduled[-1],
|
||||
)
|
||||
|
||||
with patch("nanobot.command.builtin.asyncio", new=fake_asyncio), \
|
||||
patch("nanobot.command.builtin.sys.platform", "win32"), \
|
||||
patch("nanobot.command.builtin.subprocess.CREATE_NEW_PROCESS_GROUP", 512, create=True), \
|
||||
patch("nanobot.command.builtin.subprocess.Popen") as mock_popen, \
|
||||
patch("nanobot.command.builtin.os._exit") as mock_exit, \
|
||||
patch("nanobot.command.builtin.os.execv") as mock_execv:
|
||||
await cmd_restart(ctx)
|
||||
await scheduled[0]
|
||||
|
||||
mock_popen.assert_called_once_with(
|
||||
[sys.executable, "-m", "nanobot"] + sys.argv[1:],
|
||||
creationflags=512,
|
||||
)
|
||||
mock_exit.assert_called_once_with(0)
|
||||
mock_execv.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_exit_mode_does_not_spawn(self):
|
||||
from nanobot.command.builtin import cmd_restart
|
||||
from nanobot.command.router import CommandContext
|
||||
|
||||
loop, _bus = _make_loop()
|
||||
loop.restart_mode = "exit"
|
||||
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
|
||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
|
||||
|
||||
async def _fast_sleep(_delay: float) -> None:
|
||||
return None
|
||||
|
||||
scheduled: list[asyncio.Task] = []
|
||||
fake_asyncio = SimpleNamespace(
|
||||
sleep=_fast_sleep,
|
||||
create_task=lambda coro: scheduled.append(asyncio.create_task(coro)) or scheduled[-1],
|
||||
)
|
||||
|
||||
with patch("nanobot.command.builtin.asyncio", new=fake_asyncio), \
|
||||
patch("nanobot.command.builtin.subprocess.Popen") as mock_popen, \
|
||||
patch("nanobot.command.builtin.os._exit") as mock_exit, \
|
||||
patch("nanobot.command.builtin.os.execv") as mock_execv:
|
||||
await cmd_restart(ctx)
|
||||
await scheduled[0]
|
||||
|
||||
mock_exit.assert_called_once_with(0)
|
||||
mock_popen.assert_not_called()
|
||||
mock_execv.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_intercepted_in_run_loop(self):
|
||||
"""Verify /restart is handled at the run-loop level, not inside _dispatch."""
|
||||
loop, bus = _make_loop()
|
||||
loop.restart_mode = "exec"
|
||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart")
|
||||
|
||||
async def _fast_sleep(_delay: float) -> None:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Test cmd_stop drains pending queue to prevent mid-turn injection deadlock."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.command.builtin import cmd_stop
|
||||
from nanobot.command.router import CommandContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_stop_drains_pending_queue():
|
||||
"""cmd_stop should drain pending queue in addition to cancelling active tasks."""
|
||||
mock_loop = MagicMock()
|
||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=1)
|
||||
mock_loop._pending_queues = {}
|
||||
|
||||
pending = asyncio.Queue()
|
||||
await pending.put("msg1")
|
||||
await pending.put("msg2")
|
||||
mock_loop._pending_queues["test-session"] = pending
|
||||
|
||||
ctx = CommandContext(
|
||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||
session=None,
|
||||
key="test-session",
|
||||
raw="/stop",
|
||||
loop=mock_loop,
|
||||
)
|
||||
|
||||
result = await cmd_stop(ctx)
|
||||
|
||||
assert isinstance(result, OutboundMessage)
|
||||
assert "Stopped 3 task(s)" in result.content # 1 cancelled + 2 drained
|
||||
assert "test-session" not in mock_loop._pending_queues
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_stop_with_empty_pending_queue():
|
||||
"""cmd_stop should work correctly when pending queue is empty."""
|
||||
mock_loop = MagicMock()
|
||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=2)
|
||||
mock_loop._pending_queues = {}
|
||||
|
||||
pending = asyncio.Queue()
|
||||
mock_loop._pending_queues["test-session"] = pending
|
||||
|
||||
ctx = CommandContext(
|
||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||
session=None,
|
||||
key="test-session",
|
||||
raw="/stop",
|
||||
loop=mock_loop,
|
||||
)
|
||||
|
||||
result = await cmd_stop(ctx)
|
||||
|
||||
assert "Stopped 2 task(s)" in result.content
|
||||
assert "test-session" not in mock_loop._pending_queues
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cmd_stop_no_pending_queue():
|
||||
"""cmd_stop should work when no pending queue exists."""
|
||||
mock_loop = MagicMock()
|
||||
mock_loop._cancel_active_tasks = AsyncMock(return_value=0)
|
||||
mock_loop._pending_queues = {}
|
||||
|
||||
ctx = CommandContext(
|
||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||
session=None,
|
||||
key="test-session",
|
||||
raw="/stop",
|
||||
loop=mock_loop,
|
||||
)
|
||||
|
||||
result = await cmd_stop(ctx)
|
||||
|
||||
assert "No active task to stop" in result.content
|
||||
@@ -2,6 +2,8 @@ import json
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.security.network import validate_url_target
|
||||
|
||||
@@ -93,6 +95,41 @@ def test_onboard_does_not_crash_with_legacy_memory_window(tmp_path, monkeypatch)
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"])
|
||||
def test_load_config_warns_and_ignores_legacy_max_messages(tmp_path, field_name) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch("nanobot.config.loader.logger.warning") as warning:
|
||||
config = load_config(config_path)
|
||||
|
||||
assert config.agents.defaults.max_tokens == 1234
|
||||
assert not hasattr(config.agents.defaults, "max_messages")
|
||||
warning.assert_called_once()
|
||||
message = warning.call_args.args[0]
|
||||
assert "legacy and ignored" in message
|
||||
assert "next version" in message
|
||||
|
||||
|
||||
def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"agents": {"defaults": {"maxMessages": 25}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch("nanobot.config.loader.logger.warning"):
|
||||
config = load_config(config_path)
|
||||
save_config(config, config_path)
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert "maxMessages" not in saved["agents"]["defaults"]
|
||||
assert "max_messages" not in saved["agents"]["defaults"]
|
||||
|
||||
|
||||
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from nanobot.config.loader import (
|
||||
resolve_config_env_vars,
|
||||
save_config,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
class TestResolveEnvVars:
|
||||
@@ -127,6 +128,31 @@ class TestResolveConfig:
|
||||
assert "githubCopilot" not in saved["providers"]
|
||||
assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
|
||||
|
||||
def test_save_preserves_openai_codex_proxy_config(self, tmp_path):
|
||||
config_path = tmp_path / "config.json"
|
||||
proxy = "http://127.0.0.1:23458"
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"providers": {
|
||||
"openaiCodex": {
|
||||
"apiKey": "codex-secret",
|
||||
"proxy": proxy,
|
||||
},
|
||||
"groq": {"apiKey": "groq-secret"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
save_config(config, config_path)
|
||||
|
||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["providers"]["openaiCodex"] == {"proxy": proxy}
|
||||
assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
|
||||
|
||||
reloaded = load_config(config_path)
|
||||
assert reloaded.providers.openai_codex.proxy == proxy
|
||||
assert reloaded.providers.openai_codex.api_key is None
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import Config, GatewayConfig
|
||||
|
||||
|
||||
def test_gateway_restart_mode_accepts_camel_alias():
|
||||
config = Config.model_validate({"gateway": {"restartMode": "exit"}})
|
||||
|
||||
assert config.gateway.restart_mode == "exit"
|
||||
assert config.model_dump(by_alias=True)["gateway"]["restartMode"] == "exit"
|
||||
|
||||
|
||||
def test_gateway_restart_mode_rejects_unknown_value():
|
||||
with pytest.raises(ValueError):
|
||||
GatewayConfig(restart_mode="service")
|
||||
@@ -10,11 +10,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronSchedule
|
||||
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
|
||||
|
||||
|
||||
def _seeded_store(tmp_path: Path) -> tuple[CronService, Path]:
|
||||
@@ -41,6 +42,29 @@ def _seeded_store(tmp_path: Path) -> tuple[CronService, Path]:
|
||||
return service, store_path
|
||||
|
||||
|
||||
def _corrupt_store(tmp_path: Path) -> Path:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
store_path.parent.mkdir(parents=True)
|
||||
store_path.write_text("{not valid json", encoding="utf-8")
|
||||
return store_path
|
||||
|
||||
|
||||
def _assert_single_corrupt_backup(store_path: Path) -> None:
|
||||
assert not store_path.exists()
|
||||
backups = list(store_path.parent.glob("jobs.json.corrupt-*"))
|
||||
assert len(backups) == 1
|
||||
assert backups[0].read_text(encoding="utf-8") == "{not valid json"
|
||||
|
||||
|
||||
def _system_job(job_id: str = "dream") -> CronJob:
|
||||
return CronJob(
|
||||
id=job_id,
|
||||
name="Dream",
|
||||
schedule=CronSchedule(kind="cron", expr="0 */2 * * *", tz="UTC"),
|
||||
payload=CronPayload(kind="system_event"),
|
||||
)
|
||||
|
||||
|
||||
def test_save_store_is_atomic(tmp_path: Path) -> None:
|
||||
"""``_save_store`` must use temp-file + rename so an interrupted write
|
||||
cannot leave the destination truncated or invalid."""
|
||||
@@ -148,6 +172,126 @@ def test_load_store_falls_back_to_in_memory_on_corruption_after_start(
|
||||
assert result.jobs[0].name == "Daily Loving Message"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_name", "call"),
|
||||
[
|
||||
("list_jobs", lambda service: service.list_jobs()),
|
||||
("get_job", lambda service: service.get_job("missing")),
|
||||
("status", lambda service: service.status()),
|
||||
("remove_job", lambda service: service.remove_job("missing")),
|
||||
("enable_job", lambda service: service.enable_job("missing", enabled=False)),
|
||||
("update_job", lambda service: service.update_job("missing", name="new name")),
|
||||
("register_system_job", lambda service: service.register_system_job(_system_job())),
|
||||
],
|
||||
)
|
||||
def test_public_apis_raise_clear_error_for_unavailable_corrupt_store(
|
||||
tmp_path: Path,
|
||||
api_name: str,
|
||||
call: Callable[[CronService], object],
|
||||
) -> None:
|
||||
"""Public APIs should report the corrupt store explicitly instead of
|
||||
leaking ``AttributeError`` when the first load cannot produce a store."""
|
||||
store_path = _corrupt_store(tmp_path)
|
||||
service = CronService(store_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="corrupt.*restore jobs.json") as exc_info:
|
||||
call(service)
|
||||
|
||||
assert api_name
|
||||
assert str(store_path) in str(exc_info.value)
|
||||
_assert_single_corrupt_backup(store_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_job_raises_clear_error_and_restores_running_state_for_corrupt_store(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store_path = _corrupt_store(tmp_path)
|
||||
service = CronService(store_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="corrupt.*restore jobs.json"):
|
||||
await service.run_job("missing")
|
||||
|
||||
assert service._running is False
|
||||
_assert_single_corrupt_backup(store_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_job_preserves_running_state_when_corrupt_store_unavailable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store_path = _corrupt_store(tmp_path)
|
||||
service = CronService(store_path)
|
||||
service._running = True
|
||||
service._arm_timer = lambda: None
|
||||
|
||||
with pytest.raises(RuntimeError, match="corrupt.*restore jobs.json"):
|
||||
await service.run_job("missing")
|
||||
|
||||
assert service._running is True
|
||||
service.stop()
|
||||
|
||||
|
||||
def test_running_add_job_raises_clear_error_for_unavailable_corrupt_store(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store_path = _corrupt_store(tmp_path)
|
||||
service = CronService(store_path)
|
||||
service._running = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="corrupt.*restore jobs.json"):
|
||||
service.add_job(
|
||||
name="running add",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
session_key="websocket:chat-1",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="chat-1",
|
||||
)
|
||||
|
||||
_assert_single_corrupt_backup(store_path)
|
||||
|
||||
|
||||
def test_stopped_add_job_still_appends_action_without_loading_corrupt_store(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The stopped-service add path is an action-log write and must not start
|
||||
requiring a readable store."""
|
||||
store_path = _corrupt_store(tmp_path)
|
||||
service = CronService(store_path)
|
||||
|
||||
job = service.add_job(
|
||||
name="offline add",
|
||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
||||
message="hello",
|
||||
session_key="websocket:chat-1",
|
||||
origin_channel="websocket",
|
||||
origin_chat_id="chat-1",
|
||||
)
|
||||
|
||||
assert job.name == "offline add"
|
||||
assert store_path.exists()
|
||||
assert store_path.read_text(encoding="utf-8") == "{not valid json"
|
||||
assert list(store_path.parent.glob("jobs.json.corrupt-*")) == []
|
||||
actions = (store_path.parent / "action.jsonl").read_text(encoding="utf-8").splitlines()
|
||||
assert len(actions) == 1
|
||||
assert json.loads(actions[0])["action"] == "add"
|
||||
|
||||
|
||||
def test_public_api_uses_in_memory_snapshot_when_disk_becomes_corrupt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, store_path = _seeded_store(tmp_path)
|
||||
service._load_store()
|
||||
assert service._store is not None
|
||||
store_path.write_text("{not valid json", encoding="utf-8")
|
||||
|
||||
jobs = service.list_jobs(include_disabled=True)
|
||||
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].name == "Daily Loving Message"
|
||||
|
||||
|
||||
def test_full_round_trip_survives_repeated_save_load(tmp_path: Path) -> None:
|
||||
"""Sanity check: jobs survive add → save → reload across fresh
|
||||
``CronService`` instances pointing at the same store."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user