From 3119c9653222d1a20b83b8825e7e17bb0f737ab9 Mon Sep 17 00:00:00 2001 From: zrguo <49157727+LarFii@users.noreply.github.com> Date: Sat, 7 Feb 2026 15:01:48 +0800 Subject: [PATCH 01/19] feat/nanobot-integration --- .github/workflows/ci.yaml | 3 +- app/api/internal/files/edit/route.ts | 124 ++++ app/api/internal/projects/compile/route.ts | 267 +++++++ app/api/internal/projects/list/route.ts | 116 +++ docker-compose.prod.yml | 30 + docker-compose.yml | 31 + env.example.oss | 10 + nanobot/DEPLOYMENT.md | 266 +++++++ nanobot/Dockerfile | 66 ++ nanobot/Dockerfile.dev | 22 + nanobot/main.py | 125 ++++ nanobot/nanobot/__init__.py | 6 + nanobot/nanobot/__main__.py | 8 + nanobot/nanobot/agent/__init__.py | 8 + nanobot/nanobot/agent/context.py | 224 ++++++ nanobot/nanobot/agent/loop.py | 388 ++++++++++ nanobot/nanobot/agent/memory.py | 109 +++ nanobot/nanobot/agent/skills.py | 246 +++++++ nanobot/nanobot/agent/subagent.py | 262 +++++++ nanobot/nanobot/agent/tools/__init__.py | 6 + nanobot/nanobot/agent/tools/base.py | 108 +++ nanobot/nanobot/agent/tools/filesystem.py | 180 +++++ nanobot/nanobot/agent/tools/litewrite.py | 314 ++++++++ nanobot/nanobot/agent/tools/message.py | 102 +++ nanobot/nanobot/agent/tools/registry.py | 75 ++ nanobot/nanobot/agent/tools/shell.py | 147 ++++ nanobot/nanobot/agent/tools/spawn.py | 65 ++ nanobot/nanobot/agent/tools/web.py | 209 ++++++ nanobot/nanobot/bus/__init__.py | 6 + nanobot/nanobot/bus/events.py | 35 + nanobot/nanobot/bus/queue.py | 81 ++ nanobot/nanobot/channels/__init__.py | 6 + nanobot/nanobot/channels/base.py | 121 +++ nanobot/nanobot/channels/feishu.py | 275 +++++++ nanobot/nanobot/channels/manager.py | 146 ++++ nanobot/nanobot/channels/telegram.py | 320 ++++++++ nanobot/nanobot/channels/whatsapp.py | 140 ++++ nanobot/nanobot/cli/__init__.py | 1 + nanobot/nanobot/cli/commands.py | 691 ++++++++++++++++++ nanobot/nanobot/config/__init__.py | 6 + nanobot/nanobot/config/loader.py | 103 +++ nanobot/nanobot/config/schema.py | 160 ++++ nanobot/nanobot/cron/__init__.py | 6 + nanobot/nanobot/cron/service.py | 362 +++++++++ nanobot/nanobot/cron/types.py | 64 ++ nanobot/nanobot/heartbeat/__init__.py | 5 + nanobot/nanobot/heartbeat/service.py | 137 ++++ nanobot/nanobot/providers/__init__.py | 6 + nanobot/nanobot/providers/base.py | 71 ++ nanobot/nanobot/providers/litellm_provider.py | 179 +++++ nanobot/nanobot/providers/transcription.py | 61 ++ nanobot/nanobot/session/__init__.py | 5 + nanobot/nanobot/session/manager.py | 212 ++++++ nanobot/nanobot/skills/README.md | 24 + nanobot/nanobot/skills/github/SKILL.md | 48 ++ nanobot/nanobot/skills/litewrite/SKILL.md | 57 ++ nanobot/nanobot/skills/skill-creator/SKILL.md | 371 ++++++++++ nanobot/nanobot/skills/summarize/SKILL.md | 67 ++ nanobot/nanobot/skills/tmux/SKILL.md | 121 +++ .../skills/tmux/scripts/find-sessions.sh | 112 +++ .../skills/tmux/scripts/wait-for-text.sh | 83 +++ nanobot/nanobot/skills/weather/SKILL.md | 49 ++ nanobot/nanobot/utils/__init__.py | 5 + nanobot/nanobot/utils/helpers.py | 91 +++ nanobot/requirements.txt | 50 ++ scripts/setup.sh | 281 +++++++ scripts/up-dev.sh | 1 + 67 files changed, 8075 insertions(+), 1 deletion(-) create mode 100644 app/api/internal/files/edit/route.ts create mode 100644 app/api/internal/projects/compile/route.ts create mode 100644 app/api/internal/projects/list/route.ts create mode 100644 nanobot/DEPLOYMENT.md create mode 100644 nanobot/Dockerfile create mode 100644 nanobot/Dockerfile.dev create mode 100644 nanobot/main.py create mode 100644 nanobot/nanobot/__init__.py create mode 100644 nanobot/nanobot/__main__.py create mode 100644 nanobot/nanobot/agent/__init__.py create mode 100644 nanobot/nanobot/agent/context.py create mode 100644 nanobot/nanobot/agent/loop.py create mode 100644 nanobot/nanobot/agent/memory.py create mode 100644 nanobot/nanobot/agent/skills.py create mode 100644 nanobot/nanobot/agent/subagent.py create mode 100644 nanobot/nanobot/agent/tools/__init__.py create mode 100644 nanobot/nanobot/agent/tools/base.py create mode 100644 nanobot/nanobot/agent/tools/filesystem.py create mode 100644 nanobot/nanobot/agent/tools/litewrite.py create mode 100644 nanobot/nanobot/agent/tools/message.py create mode 100644 nanobot/nanobot/agent/tools/registry.py create mode 100644 nanobot/nanobot/agent/tools/shell.py create mode 100644 nanobot/nanobot/agent/tools/spawn.py create mode 100644 nanobot/nanobot/agent/tools/web.py create mode 100644 nanobot/nanobot/bus/__init__.py create mode 100644 nanobot/nanobot/bus/events.py create mode 100644 nanobot/nanobot/bus/queue.py create mode 100644 nanobot/nanobot/channels/__init__.py create mode 100644 nanobot/nanobot/channels/base.py create mode 100644 nanobot/nanobot/channels/feishu.py create mode 100644 nanobot/nanobot/channels/manager.py create mode 100644 nanobot/nanobot/channels/telegram.py create mode 100644 nanobot/nanobot/channels/whatsapp.py create mode 100644 nanobot/nanobot/cli/__init__.py create mode 100644 nanobot/nanobot/cli/commands.py create mode 100644 nanobot/nanobot/config/__init__.py create mode 100644 nanobot/nanobot/config/loader.py create mode 100644 nanobot/nanobot/config/schema.py create mode 100644 nanobot/nanobot/cron/__init__.py create mode 100644 nanobot/nanobot/cron/service.py create mode 100644 nanobot/nanobot/cron/types.py create mode 100644 nanobot/nanobot/heartbeat/__init__.py create mode 100644 nanobot/nanobot/heartbeat/service.py create mode 100644 nanobot/nanobot/providers/__init__.py create mode 100644 nanobot/nanobot/providers/base.py create mode 100644 nanobot/nanobot/providers/litellm_provider.py create mode 100644 nanobot/nanobot/providers/transcription.py create mode 100644 nanobot/nanobot/session/__init__.py create mode 100644 nanobot/nanobot/session/manager.py create mode 100644 nanobot/nanobot/skills/README.md create mode 100644 nanobot/nanobot/skills/github/SKILL.md create mode 100644 nanobot/nanobot/skills/litewrite/SKILL.md create mode 100644 nanobot/nanobot/skills/skill-creator/SKILL.md create mode 100644 nanobot/nanobot/skills/summarize/SKILL.md create mode 100644 nanobot/nanobot/skills/tmux/SKILL.md create mode 100755 nanobot/nanobot/skills/tmux/scripts/find-sessions.sh create mode 100755 nanobot/nanobot/skills/tmux/scripts/wait-for-text.sh create mode 100644 nanobot/nanobot/skills/weather/SKILL.md create mode 100644 nanobot/nanobot/utils/__init__.py create mode 100644 nanobot/nanobot/utils/helpers.py create mode 100644 nanobot/requirements.txt create mode 100755 scripts/setup.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 556410d..5f4a35b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -70,10 +70,11 @@ jobs: docker-compose -f docker-compose.prod.yml config >/dev/null fi - - name: Docker build (web, ws, ai-server, compile) + - name: Docker build (web, ws, ai-server, nanobot, compile) run: | set -euo pipefail docker build -f Dockerfile . docker build -f Dockerfile.ws . docker build -f ai-server/Dockerfile ai-server + docker build -f nanobot/Dockerfile nanobot docker build -f compile-server/Dockerfile compile-server diff --git a/app/api/internal/files/edit/route.ts b/app/api/internal/files/edit/route.ts new file mode 100644 index 0000000..eaa57bd --- /dev/null +++ b/app/api/internal/files/edit/route.ts @@ -0,0 +1,124 @@ +/** + * Internal API: Edit File (Full Replacement) + * ============================================= + * + * Internal endpoint for nanobot to replace a file's entire content. + * Simpler than the shadow-document based files/write endpoint. + * Writes directly to storage and clears Yjs cache. + * + * This is NOT exposed to the public - protected by INTERNAL_API_SECRET. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { getStorage, StoragePaths } from "@/lib/storage"; + +// Verify internal API secret +function verifyInternalAuth(request: NextRequest): boolean { + const secret = request.headers.get("X-Internal-Secret"); + const expectedSecret = process.env.INTERNAL_API_SECRET; + + if (!expectedSecret) { + console.warn("[Internal API] INTERNAL_API_SECRET not configured"); + return false; + } + + return secret === expectedSecret; +} + +/** + * Clear Yjs in-memory document cache on the WS server. + * This ensures the next time a user opens the file in the editor, + * they get the updated content from storage. + */ +async function clearYjsCache( + projectId: string, + filePath: string +): Promise { + const wsServerUrl = + process.env.WS_SERVER_URL || + process.env.NEXT_PUBLIC_WS_URL?.replace(/^wss?:\/\//, (m) => + m === "wss://" ? "https://" : "http://" + ) || + "http://localhost:1234"; + + try { + const base = wsServerUrl.replace(/\/+$/, ""); + await fetch( + `${base}/clear/${projectId}/${encodeURIComponent(filePath)}`, + { + method: "POST", + headers: process.env.INTERNAL_API_SECRET + ? { "x-internal-secret": process.env.INTERNAL_API_SECRET } + : undefined, + } + ); + console.log( + `[Internal/EditFile] Cleared Yjs cache for ${projectId}/${filePath}` + ); + } catch (err) { + // Non-fatal: WS server may be unavailable + console.warn( + `[Internal/EditFile] Failed to clear Yjs cache for ${filePath}:`, + err + ); + } +} + +export async function POST(request: NextRequest) { + // Verify authentication + if (!verifyInternalAuth(request)) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + try { + const body = await request.json(); + const { projectId, filePath, content } = body as { + projectId: string; + filePath: string; + content: string; + }; + + if (!projectId || !filePath) { + return NextResponse.json({ + success: false, + error: "projectId and filePath are required", + }); + } + + if (typeof content !== "string") { + return NextResponse.json({ + success: false, + error: "content must be a string", + }); + } + + const storage = await getStorage(); + const key = StoragePaths.projectFile(projectId, filePath); + + // Write content to storage (full replacement) + await storage.upload(key, content, "text/plain"); + console.log( + `[Internal/EditFile] Written ${content.length} chars to ${projectId}/${filePath}` + ); + + // Clear Yjs cache so the editor picks up the new content + await clearYjsCache(projectId, filePath); + + return NextResponse.json({ + success: true, + data: { + filePath, + length: content.length, + }, + }); + } catch (error) { + console.error("[Internal/EditFile] Error:", error); + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/app/api/internal/projects/compile/route.ts b/app/api/internal/projects/compile/route.ts new file mode 100644 index 0000000..5b26ab8 --- /dev/null +++ b/app/api/internal/projects/compile/route.ts @@ -0,0 +1,267 @@ +/** + * Internal API: Compile Project + * =============================== + * + * Internal endpoint for nanobot to trigger project compilation. + * Returns the compiled PDF as base64 (also saves to storage for web preview). + * + * This is NOT exposed to the public - protected by INTERNAL_API_SECRET. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getStorage, StoragePaths } from "@/lib/storage"; +import { VALID_COMPILERS, Compiler } from "@/lib/compiler-utils"; + +const COMPILE_SERVER_URL = + process.env.COMPILE_SERVER_URL || "http://localhost:3002"; + +// Text file extensions (sent as UTF-8 strings) +const TEXT_EXTENSIONS = new Set([ + ".tex", ".bib", ".bbl", ".sty", ".cls", ".txt", ".md", ".bst", + ".json", ".xml", ".cfg", ".def", ".fd", ".aux", ".toc", + ".lof", ".lot", ".idx", ".ind", ".glo", ".gls", ".out", ".blg", +]); + +// Binary file extensions (sent as base64) +const BINARY_EXTENSIONS = new Set([ + ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".eps", ".ps", + ".svg", ".bmp", ".tiff", ".tif", +]); + +// Verify internal API secret +function verifyInternalAuth(request: NextRequest): boolean { + const secret = request.headers.get("X-Internal-Secret"); + const expectedSecret = process.env.INTERNAL_API_SECRET; + + if (!expectedSecret) { + console.warn("[Internal API] INTERNAL_API_SECRET not configured"); + return false; + } + + return secret === expectedSecret; +} + +function getExtension(filename: string): string { + const lastDot = filename.lastIndexOf("."); + return lastDot >= 0 ? filename.substring(lastDot).toLowerCase() : ""; +} + +function shouldIncludeFile(filename: string): boolean { + const excludePatterns = [ + ".log", ".aux", ".out", ".toc", ".synctex.gz", ".fls", ".fdb_latexmk", + ]; + if (excludePatterns.some((p) => filename.endsWith(p))) return false; + if (filename === "project.json" || filename.startsWith(".")) return false; + return true; +} + +/** + * Read all project files from storage for compilation. + */ +async function readProjectFiles(projectId: string): Promise<{ + textFiles: Record; + binaryFiles: Record; +}> { + const storage = await getStorage(); + const prefix = StoragePaths.projectPrefix(projectId); + const prefixLen = prefix.length; + + const textFiles: Record = {}; + const binaryFiles: Record = {}; + + const files = await storage.list(prefix); + + for (const file of files) { + const relativePath = file.key.substring(prefixLen); + if (!relativePath) continue; + + const filename = relativePath.split("/").pop() || ""; + if (!shouldIncludeFile(filename)) continue; + if (file.key.endsWith("/")) continue; + + try { + const content = await storage.download(file.key); + const ext = getExtension(filename); + + if (TEXT_EXTENSIONS.has(ext)) { + textFiles[relativePath] = content.toString("utf8"); + } else if (BINARY_EXTENSIONS.has(ext)) { + binaryFiles[relativePath] = content.toString("base64"); + } + } catch (error) { + console.error( + `[Internal/Compile] Failed to read file: ${file.key}`, + error + ); + } + } + + return { textFiles, binaryFiles }; +} + +export async function POST(request: NextRequest) { + // Verify authentication + if (!verifyInternalAuth(request)) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + try { + const body = await request.json(); + const { projectId, compiler: requestedCompiler } = body as { + projectId: string; + compiler?: string; + }; + + if (!projectId) { + return NextResponse.json({ + success: false, + error: "projectId is required", + }); + } + + // Look up the project in the database + const project = await prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + return NextResponse.json({ + success: false, + error: `Project not found: ${projectId}`, + }); + } + + // Read all project files + console.log(`[Internal/Compile] Reading project files for: ${projectId}`); + const { textFiles, binaryFiles } = await readProjectFiles(projectId); + + console.log( + `[Internal/Compile] Text files: ${Object.keys(textFiles).length}, ` + + `Binary files: ${Object.keys(binaryFiles).length}` + ); + + const mainFile = project.mainFile || "main.tex"; + + // Check whether the main file exists + if (!textFiles[mainFile]) { + return NextResponse.json({ + success: false, + error: `Main file not found: ${mainFile}`, + }); + } + + // Resolve compiler: request param > project setting > default + let compiler = "pdflatex"; + if ( + requestedCompiler && + VALID_COMPILERS.has(requestedCompiler as Compiler) + ) { + compiler = requestedCompiler; + } else if ( + project.compiler && + VALID_COMPILERS.has(project.compiler as Compiler) + ) { + compiler = project.compiler; + } + + console.log(`[Internal/Compile] Using compiler: ${compiler}`); + + // Call compile server + const compileResponse = await fetch(`${COMPILE_SERVER_URL}/compile`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mainFile, + compiler, + projectFiles: textFiles, + binaryFiles, + }), + cache: "no-store", + }); + + if (!compileResponse.ok) { + let errorDetail = ""; + try { + const errBody = await compileResponse.json(); + errorDetail = errBody.logs || errBody.error || ""; + } catch { + errorDetail = `HTTP ${compileResponse.status}`; + } + + console.error("[Internal/Compile] Compile server error:", errorDetail); + + return NextResponse.json({ + success: false, + error: "Compilation failed", + logs: errorDetail, + }); + } + + const result = await compileResponse.json(); + + if (!result.success || !result.pdfBase64) { + return NextResponse.json({ + success: false, + error: "Compilation did not produce a PDF", + logs: result.logs || "", + }); + } + + // Save PDF to storage (keeps web preview working) + const storage = await getStorage(); + const timestamp = Date.now(); + const pdfFileName = `output-${timestamp}.pdf`; + const pdfKey = StoragePaths.compiledFile(projectId, pdfFileName); + const pdfBuffer = Buffer.from(result.pdfBase64, "base64"); + await storage.upload(pdfKey, pdfBuffer, "application/pdf"); + console.log(`[Internal/Compile] PDF saved to storage: ${pdfKey}`); + + // Save SyncTeX file if present + if (result.synctexBase64) { + const synctexFileName = `output-${timestamp}.synctex.gz`; + const synctexKey = StoragePaths.compiledFile(projectId, synctexFileName); + const synctexBuffer = Buffer.from(result.synctexBase64, "base64"); + await storage.upload(synctexKey, synctexBuffer, "application/gzip"); + } + + // Clean up old compiled files + try { + const prefix = StoragePaths.compiledPrefix(projectId); + const oldFiles = await storage.list(prefix); + for (const f of oldFiles) { + const fname = f.key.split("/").pop() || ""; + if ( + !fname.includes(String(timestamp)) && + (fname.endsWith(".pdf") || fname.endsWith(".synctex.gz")) + ) { + await storage.delete(f.key); + } + } + } catch (e) { + console.warn("[Internal/Compile] Failed to clean old files:", e); + } + + console.log( + `[Internal/Compile] Compilation successful, PDF size: ${pdfBuffer.length} bytes` + ); + + return NextResponse.json({ + success: true, + data: { + pdfBase64: result.pdfBase64, + pdfFileName, + logs: result.logs || "", + }, + }); + } catch (error) { + console.error("[Internal/Compile] Error:", error); + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/app/api/internal/projects/list/route.ts b/app/api/internal/projects/list/route.ts new file mode 100644 index 0000000..00c40fc --- /dev/null +++ b/app/api/internal/projects/list/route.ts @@ -0,0 +1,116 @@ +/** + * Internal API: List Projects + * ============================ + * + * Internal endpoint for nanobot to list projects. + * Supports filtering by owner and searching by name. + * + * This is NOT exposed to the public - protected by INTERNAL_API_SECRET. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// Verify internal API secret +function verifyInternalAuth(request: NextRequest): boolean { + const secret = request.headers.get("X-Internal-Secret"); + const expectedSecret = process.env.INTERNAL_API_SECRET; + + if (!expectedSecret) { + console.warn("[Internal API] INTERNAL_API_SECRET not configured"); + return false; + } + + return secret === expectedSecret; +} + +export async function POST(request: NextRequest) { + // Verify authentication + if (!verifyInternalAuth(request)) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + try { + const body = await request.json(); + const { ownerId, search, limit = 50 } = body as { + ownerId?: string; + search?: string; + limit?: number; + }; + + // Build where clause + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const where: any = { + status: { not: "trashed" }, + }; + + // Filter by owner if specified + if (ownerId) { + where.ownerId = ownerId; + } + + // Search by name (case-insensitive via Prisma contains) + if (search) { + where.AND = [ + { + OR: [ + { name: { contains: search } }, + { description: { contains: search } }, + ], + }, + ]; + } + + // Fetch projects + const projects = await prisma.project.findMany({ + where, + orderBy: { updatedAt: "desc" }, + take: Math.min(limit, 100), + select: { + id: true, + name: true, + description: true, + mainFile: true, + compiler: true, + updatedAt: true, + createdAt: true, + ownerId: true, + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = projects.map((p: any) => ({ + id: p.id, + name: p.name, + description: p.description, + mainFile: p.mainFile, + compiler: p.compiler, + updatedAt: p.updatedAt.toISOString(), + createdAt: p.createdAt.toISOString(), + ownerId: p.ownerId, + })); + + console.log( + `[Internal/ListProjects] Found ${result.length} projects` + + (ownerId ? ` for owner ${ownerId}` : "") + + (search ? ` matching "${search}"` : "") + ); + + return NextResponse.json({ + success: true, + data: { + projects: result, + count: result.length, + }, + }); + } catch (error) { + console.error("[Internal/ListProjects] Error:", error); + return NextResponse.json({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 87ef9f9..a845b22 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -283,6 +283,34 @@ services: networks: - litewrite-network + # ============================================ + # nanobot AI assistant (Feishu bot + Litewrite integration) + # ============================================ + nanobot: + build: + context: ./nanobot + dockerfile: Dockerfile + container_name: litewrite-nanobot + restart: unless-stopped + volumes: + - nanobot-data:/root/.nanobot + environment: + # Litewrite integration + - NANOBOT__LITEWRITE__URL=http://web:3000 + - NANOBOT__LITEWRITE__API_SECRET=${INTERNAL_API_SECRET:-} + # Feishu channel + - NANOBOT__CHANNELS__FEISHU__ENABLED=${FEISHU_ENABLED:-false} + - NANOBOT__CHANNELS__FEISHU__APP_ID=${FEISHU_APP_ID:-} + - NANOBOT__CHANNELS__FEISHU__APP_SECRET=${FEISHU_APP_SECRET:-} + - NANOBOT__CHANNELS__FEISHU__DEFAULT_LITEWRITE_USER_ID=${NANOBOT_DEFAULT_LITEWRITE_USER_ID:-} + # LLM provider + - NANOBOT__PROVIDERS__OPENROUTER__API_KEY=${OPENROUTER_API_KEY:-} + depends_on: + web: + condition: service_healthy + networks: + - litewrite-network + # ============================================ # Volumes # ============================================ @@ -293,6 +321,8 @@ volumes: driver: local redis-data: driver: local + nanobot-data: + driver: local # ============================================ # Network diff --git a/docker-compose.yml b/docker-compose.yml index 6f76c21..7677ba6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -245,6 +245,35 @@ services: networks: - litewrite-network + # ============================================================================ + # nanobot AI assistant (Feishu bot + Litewrite integration) + # ============================================================================ + nanobot: + build: + context: ./nanobot + dockerfile: Dockerfile.dev + container_name: litewrite-nanobot + volumes: + - nanobot-data:/root/.nanobot + # Mount source for hot reload during development + - ./nanobot:/app + environment: + # Litewrite integration + - NANOBOT__LITEWRITE__URL=http://web:3000 + - NANOBOT__LITEWRITE__API_SECRET=${INTERNAL_API_SECRET:-dev-internal-secret} + # Feishu channel + - NANOBOT__CHANNELS__FEISHU__ENABLED=${FEISHU_ENABLED:-false} + - NANOBOT__CHANNELS__FEISHU__APP_ID=${FEISHU_APP_ID:-} + - NANOBOT__CHANNELS__FEISHU__APP_SECRET=${FEISHU_APP_SECRET:-} + - NANOBOT__CHANNELS__FEISHU__DEFAULT_LITEWRITE_USER_ID=${NANOBOT_DEFAULT_LITEWRITE_USER_ID:-} + # LLM provider + - NANOBOT__PROVIDERS__OPENROUTER__API_KEY=${OPENROUTER_API_KEY:-} + depends_on: + - web + restart: unless-stopped + networks: + - litewrite-network + # MinIO init (create bucket) minio-init: image: minio/mc:latest @@ -273,6 +302,8 @@ volumes: driver: local redis-data: driver: local + nanobot-data: + driver: local # ============================================================================== # Networks diff --git a/env.example.oss b/env.example.oss index 5f4c9d3..42d5ee3 100644 --- a/env.example.oss +++ b/env.example.oss @@ -41,6 +41,16 @@ EMBEDDING_API_KEY= # Optional: web search provider used by Deep Research SERPER_API_KEY= +# ------------------------------------------------------------------------------ +# nanobot (Feishu bot integration) +# ------------------------------------------------------------------------------ +# Set FEISHU_ENABLED=true and fill in the Feishu app credentials to enable. +FEISHU_ENABLED=false +FEISHU_APP_ID= +FEISHU_APP_SECRET= +# Your Litewrite user UUID (from the database) for project ownership mapping +NANOBOT_DEFAULT_LITEWRITE_USER_ID= + # ------------------------------------------------------------------------------ # Optional: Redis # ------------------------------------------------------------------------------ diff --git a/nanobot/DEPLOYMENT.md b/nanobot/DEPLOYMENT.md new file mode 100644 index 0000000..633addc --- /dev/null +++ b/nanobot/DEPLOYMENT.md @@ -0,0 +1,266 @@ +# nanobot Deployment Guide + +nanobot is an AI assistant service integrated into Litewrite. It connects to messaging platforms (currently Feishu/Lark) and enables users to manage LaTeX projects through natural language — listing projects, reading/editing files, compiling to PDF, and sending results back. + +## Architecture + +``` +┌──────────┐ WebSocket ┌──────────────┐ Internal API ┌──────────────┐ +│ Feishu │ ◄──────────────► │ nanobot │ ─────────────────► │ Litewrite │ +│ User │ │ (Python) │ │ Web (Next) │ +└──────────┘ └──────┬───────┘ └──────┬───────┘ + │ │ + │ LLM API │ Compile + ▼ ▼ + ┌──────────┐ ┌──────────────┐ + │ OpenRouter│ │ Compile │ + │ / LLM │ │ Server │ + └──────────┘ └──────────────┘ +``` + +All services run within the same Docker Compose network. nanobot communicates with Litewrite via Internal API endpoints authenticated by `INTERNAL_API_SECRET`. + +## Prerequisites + +- Litewrite running via `docker compose` (see main README) +- An LLM API key (OpenRouter recommended) +- A Feishu enterprise app (for Feishu bot integration) + +## Configuration + +All nanobot configuration is done through environment variables in the root `.env` file. + +### Required Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `OPENROUTER_API_KEY` | LLM API key (shared with Litewrite AI features) | `sk-or-v1-xxxx` | +| `INTERNAL_API_SECRET` | Internal API auth (shared across all services) | Auto-generated by setup script | + +### Feishu Bot Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `FEISHU_ENABLED` | Enable/disable Feishu channel | `true` or `false` | +| `FEISHU_APP_ID` | Feishu app ID | `cli_xxxx` | +| `FEISHU_APP_SECRET` | Feishu app secret | `xxxx` | +| `NANOBOT_DEFAULT_LITEWRITE_USER_ID` | Litewrite user UUID for project operations | `cmlbewues00001hs5wdxvmd7q` | + +### LLM Model Configuration + +The default model is set in `nanobot/nanobot/config/schema.py`: + +```python +model: str = "minimax/minimax-m2.1" +``` + +To change it, modify the default or override via environment variable: +``` +NANOBOT__AGENTS__DEFAULTS__MODEL=anthropic/claude-sonnet-4-20250514 +``` + +Supported models (via OpenRouter): any model available on [openrouter.ai/models](https://openrouter.ai/models). + +## Feishu App Setup (Step by Step) + +### 1. Create App + +1. Go to [Feishu Open Platform](https://open.feishu.cn/) +2. Click "Create App" → "Enterprise Self-built App" +3. Fill in app name and description +4. Note the **App ID** and **App Secret** + +### 2. Enable Bot + +1. In the app settings, go to "App Features" +2. Enable **Bot** capability + +### 3. Configure Event Subscription + +1. Go to "Event & Callback" → "Event Configuration" +2. **Important**: You must start the nanobot service FIRST (see step 5 below), then come back and set the subscription mode +3. Select "**Use Long Connection to Receive Events**" (WebSocket mode) +4. Add event: `im.message.receive_v1` (Receive messages) + +### 4. Configure Permissions + +Go to "Permissions & Scopes" and add: + +| Permission | Description | +|------------|-------------| +| `im:message` | Access messages | +| `im:message:send_as_bot` | Send messages as bot | +| `im:resource` | Access message resources | +| `im:chat` | Access chat info | + +### 5. First-Time Connection (Chicken-and-Egg Problem) + +Feishu requires an active WebSocket connection before you can save the "Long Connection" event subscription mode. Follow this order: + +1. Set `FEISHU_ENABLED=true`, `FEISHU_APP_ID`, `FEISHU_APP_SECRET` in `.env` +2. Start nanobot: `docker compose up -d nanobot` +3. Verify connection: `docker logs litewrite-nanobot` — look for `connected to wss://msg-frontier.feishu.cn` +4. Go back to Feishu console → "Event & Callback" → select "Long Connection" → Save +5. Add the `im.message.receive_v1` event subscription + +### 6. Publish App + +1. Go to "Version Management" +2. Create a new version +3. Submit for review (self-approve for internal apps) +4. Once published, find the bot in Feishu and send it a message + +### 7. Get Your Litewrite User ID + +After registering a Litewrite account, get your user UUID: + +```bash +# Option 1: Check the database +docker compose exec web npx prisma studio +# Open http://localhost:5555, find your user in the User table + +# Option 2: Use the API +curl -s http://localhost:3000/api/auth/session -b | python3 -m json.tool +``` + +Set it in `.env`: +``` +NANOBOT_DEFAULT_LITEWRITE_USER_ID=your-uuid-here +``` + +Then restart nanobot: +```bash +docker compose up -d nanobot +``` + +## Internal API Endpoints + +nanobot communicates with Litewrite through these internal API endpoints (all use `X-Internal-Secret` header): + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/internal/projects/list` | POST | List/search projects | +| `/api/internal/projects/compile` | POST | Compile project, return PDF as base64 | +| `/api/internal/files/list` | POST | List files in a project | +| `/api/internal/files/read` | POST | Read file content | +| `/api/internal/files/edit` | POST | Replace file content (full-file) | +| `/api/internal/files/write` | POST | Edit file (shadow document, diff-based) | + +## nanobot Agent Tools + +The agent has access to these Litewrite-specific tools: + +| Tool | Description | +|------|-------------| +| `litewrite_list_projects` | Search projects by name | +| `litewrite_list_files` | List files in a project | +| `litewrite_read_file` | Read a file's content | +| `litewrite_edit_file` | Replace a file's entire content | +| `litewrite_compile` | Compile to PDF (pdflatex/xelatex/lualatex) | +| `message` | Send text/file messages back to the user | + +## Docker Compose Services + +nanobot runs as a Docker service alongside other Litewrite services: + +```yaml +# docker-compose.yml (dev) +nanobot: + build: + context: ./nanobot + dockerfile: Dockerfile.dev + volumes: + - ./nanobot:/app # Hot reload in dev + - nanobot-data:/root/.nanobot + environment: + - NANOBOT__LITEWRITE__URL=http://web:3000 + - NANOBOT__LITEWRITE__API_SECRET=${INTERNAL_API_SECRET} + - NANOBOT__CHANNELS__FEISHU__ENABLED=${FEISHU_ENABLED} + - NANOBOT__CHANNELS__FEISHU__APP_ID=${FEISHU_APP_ID} + - NANOBOT__CHANNELS__FEISHU__APP_SECRET=${FEISHU_APP_SECRET} + - NANOBOT__PROVIDERS__OPENROUTER__API_KEY=${OPENROUTER_API_KEY} + depends_on: + - web +``` + +## Common Operations + +```bash +# Check nanobot status +docker compose ps nanobot + +# View nanobot logs +docker compose logs -f nanobot + +# Restart nanobot (after .env changes) +docker compose up -d nanobot + +# Rebuild nanobot (after code changes) +docker compose build nanobot && docker compose up -d nanobot + +# Check Feishu connection +docker logs litewrite-nanobot 2>&1 | grep "connected to wss" + +# Test Litewrite Internal API manually +curl -s -X POST http://localhost:3000/api/internal/projects/list \ + -H "Content-Type: application/json" \ + -H "X-Internal-Secret: $(grep INTERNAL_API_SECRET .env | cut -d= -f2)" \ + -d '{}' | python3 -m json.tool +``` + +## Troubleshooting + +### nanobot keeps restarting with "No API key configured" +- Check that `OPENROUTER_API_KEY` is set in `.env` +- Rebuild: `docker compose build nanobot && docker compose up -d nanobot` + +### Feishu bot doesn't receive messages +- Check connection: `docker logs litewrite-nanobot | grep connected` +- If no "connected" log, verify `FEISHU_APP_ID` and `FEISHU_APP_SECRET` +- Ensure the app is published in Feishu console +- Ensure "Long Connection" mode is saved in event subscription settings +- Restart: `docker restart litewrite-nanobot` + +### Compilation fails with Chinese text +- Use `compiler="xelatex"` in the compile command +- The agent should do this automatically when the Skill detects CJK content +- Ensure the compile server has CJK fonts installed (default Docker image includes them) + +### File edits don't appear in the browser +- nanobot's `edit_file` clears the Yjs cache after writing +- If the browser still shows old content, refresh the page +- Check WS server is running: `docker compose ps ws` + +## Project Structure + +``` +nanobot/ +├── main.py # Entry point (gateway) +├── requirements.txt # Python dependencies +├── Dockerfile # Production image +├── Dockerfile.dev # Development image +├── DEPLOYMENT.md # This file +└── nanobot/ # Python package + ├── agent/ # LLM agent loop + tools + │ ├── loop.py # Core agent loop + │ ├── context.py # System prompt builder + │ ├── skills.py # Skill loading + │ └── tools/ + │ ├── litewrite.py # Litewrite API tools + │ ├── message.py # Send messages (with file support) + │ ├── filesystem.py # Local file operations + │ ├── shell.py # Shell command execution + │ └── web.py # Web search/fetch + ├── channels/ + │ ├── base.py # Channel interface + │ ├── feishu.py # Feishu/Lark (WebSocket) + │ ├── telegram.py # Telegram (polling) + │ └── manager.py # Channel lifecycle + ├── bus/ # Async message bus + ├── providers/ # LLM provider abstraction + ├── session/ # Conversation history + ├── config/ # Configuration (env vars + JSON) + └── skills/ + └── litewrite/ + └── SKILL.md # Agent instructions for Litewrite +``` diff --git a/nanobot/Dockerfile b/nanobot/Dockerfile new file mode 100644 index 0000000..73577c5 --- /dev/null +++ b/nanobot/Dockerfile @@ -0,0 +1,66 @@ +# ============================================================================== +# Litewrite nanobot Service Dockerfile +# ============================================================================== +# Multi-stage build, production-ready +# +# Build: +# docker build -t litewrite-nanobot . +# +# Run: +# docker run --env-file .env litewrite-nanobot +# ============================================================================== + +# ------------------------------------------------------------------------------ +# Stage 1: Builder +# ------------------------------------------------------------------------------ +FROM python:3.12-slim AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy dependency file +COPY requirements.txt . + +# Create venv and install dependencies +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# ------------------------------------------------------------------------------ +# Stage 2: Runtime +# ------------------------------------------------------------------------------ +FROM python:3.12-slim AS runtime + +LABEL maintainer="Litewrite Team" +LABEL description="Litewrite nanobot Service" + +# Create non-root user +RUN groupadd -r nanobot && useradd -r -g nanobot nanobot + +WORKDIR /app + +# Copy venv from builder stage +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Copy application code +COPY --chown=nanobot:nanobot . . + +# Create data directory +RUN mkdir -p /root/.nanobot && chown -R nanobot:nanobot /root/.nanobot + +# Environment variables +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +# Switch to non-root user +USER nanobot + +# Default command: start gateway +CMD ["python", "main.py"] diff --git a/nanobot/Dockerfile.dev b/nanobot/Dockerfile.dev new file mode 100644 index 0000000..d6fb031 --- /dev/null +++ b/nanobot/Dockerfile.dev @@ -0,0 +1,22 @@ +# ============================================================================== +# Litewrite nanobot Service Dockerfile (Development) +# ============================================================================== +# Hot-reload via volume mounts in docker-compose.yml +# ============================================================================== + +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code (overridden by volume mount in dev) +COPY . . + +ENV PYTHONUNBUFFERED=1 + +# Start with auto-reload-friendly wrapper +CMD ["python", "main.py"] diff --git a/nanobot/main.py b/nanobot/main.py new file mode 100644 index 0000000..cea2d4a --- /dev/null +++ b/nanobot/main.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Litewrite nanobot Service +========================= + +AI assistant gateway for Litewrite, providing: +- Feishu bot integration (WebSocket long-connection) +- Litewrite project operations (list/read/edit/compile via Internal API) +- LLM-powered agent with tool use + +Usage: + python main.py + python main.py --verbose + +Environment variables (set via docker-compose): + NANOBOT__LITEWRITE__URL Litewrite API base URL + NANOBOT__LITEWRITE__API_SECRET Internal API secret + NANOBOT__CHANNELS__FEISHU__ENABLED Enable Feishu channel + NANOBOT__CHANNELS__FEISHU__APP_ID Feishu App ID + NANOBOT__CHANNELS__FEISHU__APP_SECRET Feishu App Secret + NANOBOT__PROVIDERS__OPENROUTER__API_KEY LLM API key +""" + +import asyncio +import sys +from pathlib import Path + +# Ensure the nanobot package is importable +sys.path.insert(0, str(Path(__file__).parent)) + +from loguru import logger # noqa: E402 + + +def main(): + """Start the nanobot gateway.""" + from nanobot.config.loader import load_config + from nanobot.bus.queue import MessageBus + from nanobot.providers.litellm_provider import LiteLLMProvider + from nanobot.agent.loop import AgentLoop + from nanobot.channels.manager import ChannelManager + + verbose = "--verbose" in sys.argv or "-v" in sys.argv + + if verbose: + import logging + + logging.basicConfig(level=logging.DEBUG) + + logger.info("Starting nanobot gateway...") + + config = load_config() + + # Create message bus + bus = MessageBus() + + # Create LLM provider + api_key = config.get_api_key() + api_base = config.get_api_base() + + if not api_key: + logger.error( + "No API key configured. Set NANOBOT__PROVIDERS__OPENROUTER__API_KEY" + ) + sys.exit(1) + + provider = LiteLLMProvider( + api_key=api_key, + api_base=api_base, + default_model=config.agents.defaults.model, + ) + + # Create agent loop + agent = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=config.agents.defaults.model, + max_iterations=config.agents.defaults.max_tool_iterations, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + litewrite_config=config.litewrite, + feishu_config=config.channels.feishu, + ) + + # Create channel manager + channels = ChannelManager(config, bus) + + if channels.enabled_channels: + logger.info( + "Channels enabled: {}", + ", ".join(channels.enabled_channels), + ) + else: + logger.warning("No channels enabled") + + if config.litewrite.api_secret: + logger.info("Litewrite integration: {}", config.litewrite.url) + else: + logger.warning("Litewrite integration not configured (no api_secret)") + + async def run(): + try: + await asyncio.gather( + agent.run(), + channels.start_all(), + ) + except KeyboardInterrupt: + logger.info("Shutting down...") + agent.stop() + await channels.stop_all() + + print("=" * 50) + print("Litewrite nanobot Service") + print("=" * 50) + print(f" LLM Model: {config.agents.defaults.model}") + print(f" Litewrite: {config.litewrite.url}") + enabled = ", ".join(channels.enabled_channels) or "none" + print(f" Channels: {enabled}") + print("=" * 50) + + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/nanobot/nanobot/__init__.py b/nanobot/nanobot/__init__.py new file mode 100644 index 0000000..ee0445b --- /dev/null +++ b/nanobot/nanobot/__init__.py @@ -0,0 +1,6 @@ +""" +nanobot - A lightweight AI agent framework +""" + +__version__ = "0.1.0" +__logo__ = "🐈" diff --git a/nanobot/nanobot/__main__.py b/nanobot/nanobot/__main__.py new file mode 100644 index 0000000..c7f5620 --- /dev/null +++ b/nanobot/nanobot/__main__.py @@ -0,0 +1,8 @@ +""" +Entry point for running nanobot as a module: python -m nanobot +""" + +from nanobot.cli.commands import app + +if __name__ == "__main__": + app() diff --git a/nanobot/nanobot/agent/__init__.py b/nanobot/nanobot/agent/__init__.py new file mode 100644 index 0000000..c3fc97b --- /dev/null +++ b/nanobot/nanobot/agent/__init__.py @@ -0,0 +1,8 @@ +"""Agent core module.""" + +from nanobot.agent.loop import AgentLoop +from nanobot.agent.context import ContextBuilder +from nanobot.agent.memory import MemoryStore +from nanobot.agent.skills import SkillsLoader + +__all__ = ["AgentLoop", "ContextBuilder", "MemoryStore", "SkillsLoader"] diff --git a/nanobot/nanobot/agent/context.py b/nanobot/nanobot/agent/context.py new file mode 100644 index 0000000..8d63114 --- /dev/null +++ b/nanobot/nanobot/agent/context.py @@ -0,0 +1,224 @@ +"""Context builder for assembling agent prompts.""" + +import base64 +import mimetypes +from pathlib import Path +from typing import Any + +from nanobot.agent.memory import MemoryStore +from nanobot.agent.skills import SkillsLoader + + +class ContextBuilder: + """ + Builds the context (system prompt + messages) for the agent. + + Assembles bootstrap files, memory, skills, and conversation history + into a coherent prompt for the LLM. + """ + + BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] + + def __init__(self, workspace: Path): + self.workspace = workspace + self.memory = MemoryStore(workspace) + self.skills = SkillsLoader(workspace) + + def build_system_prompt(self, skill_names: list[str] | None = None) -> str: + """ + Build the system prompt from bootstrap files, memory, and skills. + + Args: + skill_names: Optional list of skills to include. + + Returns: + Complete system prompt. + """ + parts = [] + + # Core identity + parts.append(self._get_identity()) + + # Bootstrap files + bootstrap = self._load_bootstrap_files() + if bootstrap: + parts.append(bootstrap) + + # Memory context + memory = self.memory.get_memory_context() + if memory: + parts.append(f"# Memory\n\n{memory}") + + # Skills - progressive loading + # 1. Always-loaded skills: include full content + always_skills = self.skills.get_always_skills() + if always_skills: + always_content = self.skills.load_skills_for_context(always_skills) + if always_content: + parts.append(f"# Active Skills\n\n{always_content}") + + # 2. Available skills: only show summary (agent uses read_file to load) + skills_summary = self.skills.build_skills_summary() + if skills_summary: + parts.append(f"""# Skills + +The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. +Skills with available="false" need dependencies installed first - you can try installing them with apt/brew. + +{skills_summary}""") + + return "\n\n---\n\n".join(parts) + + def _get_identity(self) -> str: + """Get the core identity section.""" + from datetime import datetime + + now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + workspace_path = str(self.workspace.expanduser().resolve()) + + return f"""# nanobot 🐈 + +You are nanobot, a helpful AI assistant. You have access to tools that allow you to: +- Read, write, and edit files +- Execute shell commands +- Search the web and fetch web pages +- Send messages to users on chat channels +- Spawn subagents for complex background tasks + +## Current Time +{now} + +## Workspace +Your workspace is at: {workspace_path} +- Memory files: {workspace_path}/memory/MEMORY.md +- Daily notes: {workspace_path}/memory/YYYY-MM-DD.md +- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md + +IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. +Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). +For normal conversation, just respond with text - do not call the message tool. + +Always be helpful, accurate, and concise. When using tools, explain what you're doing. +When remembering something, write to {workspace_path}/memory/MEMORY.md""" + + def _load_bootstrap_files(self) -> str: + """Load all bootstrap files from workspace.""" + parts = [] + + for filename in self.BOOTSTRAP_FILES: + file_path = self.workspace / filename + if file_path.exists(): + content = file_path.read_text(encoding="utf-8") + parts.append(f"## {filename}\n\n{content}") + + return "\n\n".join(parts) if parts else "" + + def build_messages( + self, + history: list[dict[str, Any]], + current_message: str, + skill_names: list[str] | None = None, + media: list[str] | None = None, + ) -> list[dict[str, Any]]: + """ + Build the complete message list for an LLM call. + + Args: + history: Previous conversation messages. + current_message: The new user message. + skill_names: Optional skills to include. + media: Optional list of local file paths for images/media. + + Returns: + List of messages including system prompt. + """ + messages = [] + + # System prompt + system_prompt = self.build_system_prompt(skill_names) + messages.append({"role": "system", "content": system_prompt}) + + # History + messages.extend(history) + + # Current message (with optional image attachments) + user_content = self._build_user_content(current_message, media) + messages.append({"role": "user", "content": user_content}) + + return messages + + def _build_user_content( + self, text: str, media: list[str] | None + ) -> str | list[dict[str, Any]]: + """Build user message content with optional base64-encoded images.""" + if not media: + return text + + images = [] + for path in media: + p = Path(path) + mime, _ = mimetypes.guess_type(path) + if not p.is_file() or not mime or not mime.startswith("image/"): + continue + b64 = base64.b64encode(p.read_bytes()).decode() + images.append( + {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}} + ) + + if not images: + return text + return images + [{"type": "text", "text": text}] + + def add_tool_result( + self, + messages: list[dict[str, Any]], + tool_call_id: str, + tool_name: str, + result: str, + ) -> list[dict[str, Any]]: + """ + Add a tool result to the message list. + + Args: + messages: Current message list. + tool_call_id: ID of the tool call. + tool_name: Name of the tool. + result: Tool execution result. + + Returns: + Updated message list. + """ + messages.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "name": tool_name, + "content": result, + } + ) + return messages + + def add_assistant_message( + self, + messages: list[dict[str, Any]], + content: str | None, + tool_calls: list[dict[str, Any]] | None = None, + ) -> list[dict[str, Any]]: + """ + Add an assistant message to the message list. + + Args: + messages: Current message list. + content: Message content. + tool_calls: Optional tool calls. + + Returns: + Updated message list. + """ + msg: dict[str, Any] = {"role": "assistant", "content": content or ""} + + if tool_calls: + msg["tool_calls"] = tool_calls + + messages.append(msg) + return messages diff --git a/nanobot/nanobot/agent/loop.py b/nanobot/nanobot/agent/loop.py new file mode 100644 index 0000000..d6279a1 --- /dev/null +++ b/nanobot/nanobot/agent/loop.py @@ -0,0 +1,388 @@ +"""Agent loop: the core processing engine.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from loguru import logger + +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMProvider +from nanobot.agent.context import ContextBuilder +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.filesystem import ( + ReadFileTool, + WriteFileTool, + EditFileTool, + ListDirTool, +) +from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.web import WebSearchTool, WebFetchTool +from nanobot.agent.tools.message import MessageTool +from nanobot.agent.tools.spawn import SpawnTool +from nanobot.agent.subagent import SubagentManager +from nanobot.session.manager import SessionManager + +if TYPE_CHECKING: + from nanobot.config.schema import ExecToolConfig, FeishuConfig, LitewriteConfig + + +class AgentLoop: + """ + The agent loop is the core processing engine. + + It: + 1. Receives messages from the bus + 2. Builds context with history, memory, skills + 3. Calls the LLM + 4. Executes tool calls + 5. Sends responses back + """ + + def __init__( + self, + bus: MessageBus, + provider: LLMProvider, + workspace: Path, + model: str | None = None, + max_iterations: int = 20, + brave_api_key: str | None = None, + exec_config: "ExecToolConfig | None" = None, + litewrite_config: "LitewriteConfig | None" = None, + feishu_config: "FeishuConfig | None" = None, + ): + from nanobot.config.schema import ExecToolConfig + + self.bus = bus + self.provider = provider + self.workspace = workspace + self.model = model or provider.get_default_model() + self.max_iterations = max_iterations + self.brave_api_key = brave_api_key + self.exec_config = exec_config or ExecToolConfig() + self.litewrite_config = litewrite_config + self.feishu_config = feishu_config + + self.context = ContextBuilder(workspace) + self.sessions = SessionManager(workspace) + self.tools = ToolRegistry() + self.subagents = SubagentManager( + provider=provider, + workspace=workspace, + bus=bus, + model=self.model, + brave_api_key=brave_api_key, + exec_config=self.exec_config, + ) + + self._running = False + self._register_default_tools() + + def _register_default_tools(self) -> None: + """Register the default set of tools.""" + # File tools + self.tools.register(ReadFileTool()) + self.tools.register(WriteFileTool()) + self.tools.register(EditFileTool()) + self.tools.register(ListDirTool()) + + # Shell tool + self.tools.register( + ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.exec_config.restrict_to_workspace, + ) + ) + + # Web tools + self.tools.register(WebSearchTool(api_key=self.brave_api_key)) + self.tools.register(WebFetchTool()) + + # Message tool + message_tool = MessageTool(send_callback=self.bus.publish_outbound) + self.tools.register(message_tool) + + # Spawn tool (for subagents) + spawn_tool = SpawnTool(manager=self.subagents) + self.tools.register(spawn_tool) + + # Litewrite tools (registered when Litewrite integration is configured) + if self.litewrite_config and self.litewrite_config.api_secret: + self._register_litewrite_tools() + + def _register_litewrite_tools(self) -> None: + """Register Litewrite integration tools.""" + from nanobot.agent.tools.litewrite import ( + LitewriteClient, + LitewriteListProjectsTool, + LitewriteListFilesTool, + LitewriteReadFileTool, + LitewriteEditFileTool, + LitewriteCompileTool, + ) + + client = LitewriteClient( + base_url=self.litewrite_config.url, + api_secret=self.litewrite_config.api_secret, + ) + + # Resolve default owner ID from Feishu config + default_owner_id = "" + if self.feishu_config: + default_owner_id = self.feishu_config.default_litewrite_user_id + + self.tools.register(LitewriteListProjectsTool(client, default_owner_id)) + self.tools.register(LitewriteListFilesTool(client)) + self.tools.register(LitewriteReadFileTool(client)) + self.tools.register(LitewriteEditFileTool(client)) + self.tools.register(LitewriteCompileTool(client)) + + logger.info(f"Litewrite tools registered (url={self.litewrite_config.url})") + + async def run(self) -> None: + """Run the agent loop, processing messages from the bus.""" + self._running = True + logger.info("Agent loop started") + + while self._running: + try: + # Wait for next message + msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) + + # Process it + try: + response = await self._process_message(msg) + if response: + await self.bus.publish_outbound(response) + except Exception as e: + logger.error(f"Error processing message: {e}") + # Send error response + await self.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=f"Sorry, I encountered an error: {str(e)}", + ) + ) + except asyncio.TimeoutError: + continue + + def stop(self) -> None: + """Stop the agent loop.""" + self._running = False + logger.info("Agent loop stopping") + + async def _process_message(self, msg: InboundMessage) -> OutboundMessage | None: + """ + Process a single inbound message. + + Args: + msg: The inbound message to process. + + Returns: + The response message, or None if no response needed. + """ + # Handle system messages (subagent announces) + # The chat_id contains the original "channel:chat_id" to route back to + if msg.channel == "system": + return await self._process_system_message(msg) + + logger.info(f"Processing message from {msg.channel}:{msg.sender_id}") + + # Get or create session + session = self.sessions.get_or_create(msg.session_key) + + # Update tool contexts + message_tool = self.tools.get("message") + if isinstance(message_tool, MessageTool): + message_tool.set_context(msg.channel, msg.chat_id) + + spawn_tool = self.tools.get("spawn") + if isinstance(spawn_tool, SpawnTool): + spawn_tool.set_context(msg.channel, msg.chat_id) + + # Build initial messages (use get_history for LLM-formatted messages) + messages = self.context.build_messages( + history=session.get_history(), + current_message=msg.content, + media=msg.media if msg.media else None, + ) + + # Agent loop + iteration = 0 + final_content = None + + while iteration < self.max_iterations: + iteration += 1 + + # Call LLM + response = await self.provider.chat( + messages=messages, tools=self.tools.get_definitions(), model=self.model + ) + + # Handle tool calls + if response.has_tool_calls: + # Add assistant message with tool calls + tool_call_dicts = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps( + tc.arguments + ), # Must be JSON string + }, + } + for tc in response.tool_calls + ] + messages = self.context.add_assistant_message( + messages, response.content, tool_call_dicts + ) + + # Execute tools + for tool_call in response.tool_calls: + args_str = json.dumps(tool_call.arguments) + logger.debug( + f"Executing tool: {tool_call.name} with arguments: {args_str}" + ) + result = await self.tools.execute( + tool_call.name, tool_call.arguments + ) + messages = self.context.add_tool_result( + messages, tool_call.id, tool_call.name, result + ) + else: + # No tool calls, we're done + final_content = response.content + break + + if final_content is None: + final_content = "I've completed processing but have no response to give." + + # Save to session + session.add_message("user", msg.content) + session.add_message("assistant", final_content) + self.sessions.save(session) + + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content=final_content + ) + + async def _process_system_message( + self, msg: InboundMessage + ) -> OutboundMessage | None: + """ + Process a system message (e.g., subagent announce). + + The chat_id field contains "original_channel:original_chat_id" to route + the response back to the correct destination. + """ + logger.info(f"Processing system message from {msg.sender_id}") + + # Parse origin from chat_id (format: "channel:chat_id") + if ":" in msg.chat_id: + parts = msg.chat_id.split(":", 1) + origin_channel = parts[0] + origin_chat_id = parts[1] + else: + # Fallback + origin_channel = "cli" + origin_chat_id = msg.chat_id + + # Use the origin session for context + session_key = f"{origin_channel}:{origin_chat_id}" + session = self.sessions.get_or_create(session_key) + + # Update tool contexts + message_tool = self.tools.get("message") + if isinstance(message_tool, MessageTool): + message_tool.set_context(origin_channel, origin_chat_id) + + spawn_tool = self.tools.get("spawn") + if isinstance(spawn_tool, SpawnTool): + spawn_tool.set_context(origin_channel, origin_chat_id) + + # Build messages with the announce content + messages = self.context.build_messages( + history=session.get_history(), current_message=msg.content + ) + + # Agent loop (limited for announce handling) + iteration = 0 + final_content = None + + while iteration < self.max_iterations: + iteration += 1 + + response = await self.provider.chat( + messages=messages, tools=self.tools.get_definitions(), model=self.model + ) + + if response.has_tool_calls: + tool_call_dicts = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.arguments), + }, + } + for tc in response.tool_calls + ] + messages = self.context.add_assistant_message( + messages, response.content, tool_call_dicts + ) + + for tool_call in response.tool_calls: + args_str = json.dumps(tool_call.arguments) + logger.debug( + f"Executing tool: {tool_call.name} with arguments: {args_str}" + ) + result = await self.tools.execute( + tool_call.name, tool_call.arguments + ) + messages = self.context.add_tool_result( + messages, tool_call.id, tool_call.name, result + ) + else: + final_content = response.content + break + + if final_content is None: + final_content = "Background task completed." + + # Save to session (mark as system message in history) + session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") + session.add_message("assistant", final_content) + self.sessions.save(session) + + return OutboundMessage( + channel=origin_channel, chat_id=origin_chat_id, content=final_content + ) + + async def process_direct( + self, content: str, session_key: str = "cli:direct" + ) -> str: + """ + Process a message directly (for CLI usage). + + Args: + content: The message content. + session_key: Session identifier. + + Returns: + The agent's response. + """ + msg = InboundMessage( + channel="cli", sender_id="user", chat_id="direct", content=content + ) + + response = await self._process_message(msg) + return response.content if response else "" diff --git a/nanobot/nanobot/agent/memory.py b/nanobot/nanobot/agent/memory.py new file mode 100644 index 0000000..22297f4 --- /dev/null +++ b/nanobot/nanobot/agent/memory.py @@ -0,0 +1,109 @@ +"""Memory system for persistent agent memory.""" + +from pathlib import Path +from datetime import datetime + +from nanobot.utils.helpers import ensure_dir, today_date + + +class MemoryStore: + """ + Memory system for the agent. + + Supports daily notes (memory/YYYY-MM-DD.md) and long-term memory (MEMORY.md). + """ + + def __init__(self, workspace: Path): + self.workspace = workspace + self.memory_dir = ensure_dir(workspace / "memory") + self.memory_file = self.memory_dir / "MEMORY.md" + + def get_today_file(self) -> Path: + """Get path to today's memory file.""" + return self.memory_dir / f"{today_date()}.md" + + def read_today(self) -> str: + """Read today's memory notes.""" + today_file = self.get_today_file() + if today_file.exists(): + return today_file.read_text(encoding="utf-8") + return "" + + def append_today(self, content: str) -> None: + """Append content to today's memory notes.""" + today_file = self.get_today_file() + + if today_file.exists(): + existing = today_file.read_text(encoding="utf-8") + content = existing + "\n" + content + else: + # Add header for new day + header = f"# {today_date()}\n\n" + content = header + content + + today_file.write_text(content, encoding="utf-8") + + def read_long_term(self) -> str: + """Read long-term memory (MEMORY.md).""" + if self.memory_file.exists(): + return self.memory_file.read_text(encoding="utf-8") + return "" + + def write_long_term(self, content: str) -> None: + """Write to long-term memory (MEMORY.md).""" + self.memory_file.write_text(content, encoding="utf-8") + + def get_recent_memories(self, days: int = 7) -> str: + """ + Get memories from the last N days. + + Args: + days: Number of days to look back. + + Returns: + Combined memory content. + """ + from datetime import timedelta + + memories = [] + today = datetime.now().date() + + for i in range(days): + date = today - timedelta(days=i) + date_str = date.strftime("%Y-%m-%d") + file_path = self.memory_dir / f"{date_str}.md" + + if file_path.exists(): + content = file_path.read_text(encoding="utf-8") + memories.append(content) + + return "\n\n---\n\n".join(memories) + + def list_memory_files(self) -> list[Path]: + """List all memory files sorted by date (newest first).""" + if not self.memory_dir.exists(): + return [] + + files = list(self.memory_dir.glob("????-??-??.md")) + return sorted(files, reverse=True) + + def get_memory_context(self) -> str: + """ + Get memory context for the agent. + + Returns: + Formatted memory context including long-term and recent memories. + """ + parts = [] + + # Long-term memory + long_term = self.read_long_term() + if long_term: + parts.append("## Long-term Memory\n" + long_term) + + # Today's notes + today = self.read_today() + if today: + parts.append("## Today's Notes\n" + today) + + return "\n\n".join(parts) if parts else "" diff --git a/nanobot/nanobot/agent/skills.py b/nanobot/nanobot/agent/skills.py new file mode 100644 index 0000000..4779b9c --- /dev/null +++ b/nanobot/nanobot/agent/skills.py @@ -0,0 +1,246 @@ +"""Skills loader for agent capabilities.""" + +import json +import os +import re +import shutil +from pathlib import Path + +# Default builtin skills directory (relative to this file) +BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" + + +class SkillsLoader: + """ + Loader for agent skills. + + Skills are markdown files (SKILL.md) that teach the agent how to use + specific tools or perform certain tasks. + """ + + def __init__(self, workspace: Path, builtin_skills_dir: Path | None = None): + self.workspace = workspace + self.workspace_skills = workspace / "skills" + self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR + + def list_skills(self, filter_unavailable: bool = True) -> list[dict[str, str]]: + """ + List all available skills. + + Args: + filter_unavailable: If True, filter out skills with unmet requirements. + + Returns: + List of skill info dicts with 'name', 'path', 'source'. + """ + skills = [] + + # Workspace skills (highest priority) + if self.workspace_skills.exists(): + for skill_dir in self.workspace_skills.iterdir(): + if skill_dir.is_dir(): + skill_file = skill_dir / "SKILL.md" + if skill_file.exists(): + skills.append( + { + "name": skill_dir.name, + "path": str(skill_file), + "source": "workspace", + } + ) + + # Built-in skills + if self.builtin_skills and self.builtin_skills.exists(): + for skill_dir in self.builtin_skills.iterdir(): + if skill_dir.is_dir(): + skill_file = skill_dir / "SKILL.md" + if skill_file.exists() and not any( + s["name"] == skill_dir.name for s in skills + ): + skills.append( + { + "name": skill_dir.name, + "path": str(skill_file), + "source": "builtin", + } + ) + + # Filter by requirements + if filter_unavailable: + return [ + s + for s in skills + if self._check_requirements(self._get_skill_meta(s["name"])) + ] + return skills + + def load_skill(self, name: str) -> str | None: + """ + Load a skill by name. + + Args: + name: Skill name (directory name). + + Returns: + Skill content or None if not found. + """ + # Check workspace first + workspace_skill = self.workspace_skills / name / "SKILL.md" + if workspace_skill.exists(): + return workspace_skill.read_text(encoding="utf-8") + + # Check built-in + if self.builtin_skills: + builtin_skill = self.builtin_skills / name / "SKILL.md" + if builtin_skill.exists(): + return builtin_skill.read_text(encoding="utf-8") + + return None + + def load_skills_for_context(self, skill_names: list[str]) -> str: + """ + Load specific skills for inclusion in agent context. + + Args: + skill_names: List of skill names to load. + + Returns: + Formatted skills content. + """ + parts = [] + for name in skill_names: + content = self.load_skill(name) + if content: + content = self._strip_frontmatter(content) + parts.append(f"### Skill: {name}\n\n{content}") + + return "\n\n---\n\n".join(parts) if parts else "" + + def build_skills_summary(self) -> str: + """ + Build a summary of all skills (name, description, path, availability). + + This is used for progressive loading - the agent can read the full + skill content using read_file when needed. + + Returns: + XML-formatted skills summary. + """ + all_skills = self.list_skills(filter_unavailable=False) + if not all_skills: + return "" + + def escape_xml(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + lines = [""] + for s in all_skills: + name = escape_xml(s["name"]) + path = s["path"] + desc = escape_xml(self._get_skill_description(s["name"])) + skill_meta = self._get_skill_meta(s["name"]) + available = self._check_requirements(skill_meta) + + lines.append(f' ') + lines.append(f" {name}") + lines.append(f" {desc}") + lines.append(f" {path}") + + # Show missing requirements for unavailable skills + if not available: + missing = self._get_missing_requirements(skill_meta) + if missing: + lines.append(f" {escape_xml(missing)}") + + lines.append(" ") + lines.append("") + + return "\n".join(lines) + + def _get_missing_requirements(self, skill_meta: dict) -> str: + """Get a description of missing requirements.""" + missing = [] + requires = skill_meta.get("requires", {}) + for b in requires.get("bins", []): + if not shutil.which(b): + missing.append(f"CLI: {b}") + for env in requires.get("env", []): + if not os.environ.get(env): + missing.append(f"ENV: {env}") + return ", ".join(missing) + + def _get_skill_description(self, name: str) -> str: + """Get the description of a skill from its frontmatter.""" + meta = self.get_skill_metadata(name) + if meta and meta.get("description"): + return meta["description"] + return name # Fallback to skill name + + def _strip_frontmatter(self, content: str) -> str: + """Remove YAML frontmatter from markdown content.""" + if content.startswith("---"): + match = re.match(r"^---\n.*?\n---\n", content, re.DOTALL) + if match: + return content[match.end() :].strip() + return content + + def _parse_nanobot_metadata(self, raw: str) -> dict: + """Parse nanobot metadata JSON from frontmatter.""" + try: + data = json.loads(raw) + return data.get("nanobot", {}) if isinstance(data, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + + def _check_requirements(self, skill_meta: dict) -> bool: + """Check if skill requirements are met (bins, env vars).""" + requires = skill_meta.get("requires", {}) + for b in requires.get("bins", []): + if not shutil.which(b): + return False + for env in requires.get("env", []): + if not os.environ.get(env): + return False + return True + + def _get_skill_meta(self, name: str) -> dict: + """Get nanobot metadata for a skill (cached in frontmatter).""" + meta = self.get_skill_metadata(name) or {} + return self._parse_nanobot_metadata(meta.get("metadata", "")) + + def get_always_skills(self) -> list[str]: + """Get skills marked as always=true that meet requirements.""" + result = [] + for s in self.list_skills(filter_unavailable=True): + meta = self.get_skill_metadata(s["name"]) or {} + skill_meta = self._parse_nanobot_metadata(meta.get("metadata", "")) + if skill_meta.get("always") or meta.get("always"): + result.append(s["name"]) + return result + + def get_skill_metadata(self, name: str) -> dict | None: + """ + Get metadata from a skill's frontmatter. + + Args: + name: Skill name. + + Returns: + Metadata dict or None. + """ + content = self.load_skill(name) + if not content: + return None + + if content.startswith("---"): + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if match: + # Simple YAML parsing + metadata = {} + for line in match.group(1).split("\n"): + if ":" in line: + key, value = line.split(":", 1) + metadata[key.strip()] = value.strip().strip("\"'") + return metadata + + return None diff --git a/nanobot/nanobot/agent/subagent.py b/nanobot/nanobot/agent/subagent.py new file mode 100644 index 0000000..87e2251 --- /dev/null +++ b/nanobot/nanobot/agent/subagent.py @@ -0,0 +1,262 @@ +"""Subagent manager for background task execution.""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from loguru import logger + +from nanobot.bus.events import InboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMProvider +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool +from nanobot.agent.tools.shell import ExecTool +from nanobot.agent.tools.web import WebSearchTool, WebFetchTool + +if TYPE_CHECKING: + from nanobot.config.schema import ExecToolConfig + + +class SubagentManager: + """ + Manages background subagent execution. + + Subagents are lightweight agent instances that run in the background + to handle specific tasks. They share the same LLM provider but have + isolated context and a focused system prompt. + """ + + def __init__( + self, + provider: LLMProvider, + workspace: Path, + bus: MessageBus, + model: str | None = None, + brave_api_key: str | None = None, + exec_config: "ExecToolConfig | None" = None, + ): + from nanobot.config.schema import ExecToolConfig + + self.provider = provider + self.workspace = workspace + self.bus = bus + self.model = model or provider.get_default_model() + self.brave_api_key = brave_api_key + self.exec_config = exec_config or ExecToolConfig() + self._running_tasks: dict[str, asyncio.Task[None]] = {} + + async def spawn( + self, + task: str, + label: str | None = None, + origin_channel: str = "cli", + origin_chat_id: str = "direct", + ) -> str: + """ + Spawn a subagent to execute a task in the background. + + Args: + task: The task description for the subagent. + label: Optional human-readable label for the task. + origin_channel: The channel to announce results to. + origin_chat_id: The chat ID to announce results to. + + Returns: + Status message indicating the subagent was started. + """ + task_id = str(uuid.uuid4())[:8] + display_label = label or task[:30] + ("..." if len(task) > 30 else "") + + origin = { + "channel": origin_channel, + "chat_id": origin_chat_id, + } + + # Create background task + bg_task = asyncio.create_task( + self._run_subagent(task_id, task, display_label, origin) + ) + self._running_tasks[task_id] = bg_task + + # Cleanup when done + bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None)) + + logger.info(f"Spawned subagent [{task_id}]: {display_label}") + return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." + + async def _run_subagent( + self, + task_id: str, + task: str, + label: str, + origin: dict[str, str], + ) -> None: + """Execute the subagent task and announce the result.""" + logger.info(f"Subagent [{task_id}] starting task: {label}") + + try: + # Build subagent tools (no message tool, no spawn tool) + tools = ToolRegistry() + tools.register(ReadFileTool()) + tools.register(WriteFileTool()) + tools.register(ListDirTool()) + tools.register( + ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.exec_config.restrict_to_workspace, + ) + ) + tools.register(WebSearchTool(api_key=self.brave_api_key)) + tools.register(WebFetchTool()) + + # Build messages with subagent-specific prompt + system_prompt = self._build_subagent_prompt(task) + messages: list[dict[str, Any]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": task}, + ] + + # Run agent loop (limited iterations) + max_iterations = 15 + iteration = 0 + final_result: str | None = None + + while iteration < max_iterations: + iteration += 1 + + response = await self.provider.chat( + messages=messages, + tools=tools.get_definitions(), + model=self.model, + ) + + if response.has_tool_calls: + # Add assistant message with tool calls + tool_call_dicts = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.arguments), + }, + } + for tc in response.tool_calls + ] + messages.append( + { + "role": "assistant", + "content": response.content or "", + "tool_calls": tool_call_dicts, + } + ) + + # Execute tools + for tool_call in response.tool_calls: + logger.debug( + f"Subagent [{task_id}] executing: {tool_call.name}" + ) + result = await tools.execute( + tool_call.name, tool_call.arguments + ) + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.id, + "name": tool_call.name, + "content": result, + } + ) + else: + final_result = response.content + break + + if final_result is None: + final_result = "Task completed but no final response was generated." + + logger.info(f"Subagent [{task_id}] completed successfully") + await self._announce_result( + task_id, label, task, final_result, origin, "ok" + ) + + except Exception as e: + error_msg = f"Error: {str(e)}" + logger.error(f"Subagent [{task_id}] failed: {e}") + await self._announce_result( + task_id, label, task, error_msg, origin, "error" + ) + + async def _announce_result( + self, + task_id: str, + label: str, + task: str, + result: str, + origin: dict[str, str], + status: str, + ) -> None: + """Announce the subagent result to the main agent via the message bus.""" + status_text = "completed successfully" if status == "ok" else "failed" + + announce_content = f"""[Subagent '{label}' {status_text}] + +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.""" + + # Inject as system message to trigger main agent + msg = InboundMessage( + channel="system", + sender_id="subagent", + chat_id=f"{origin['channel']}:{origin['chat_id']}", + content=announce_content, + ) + + await self.bus.publish_inbound(msg) + logger.debug( + f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}" + ) + + def _build_subagent_prompt(self, task: str) -> str: + """Build a focused system prompt for the subagent.""" + return f"""# Subagent + +You are a subagent spawned by the main agent to complete a specific task. + +## Your Task +{task} + +## Rules +1. Stay focused - complete only the assigned task, nothing else +2. Your final response will be reported back to the main agent +3. Do not initiate conversations or take on side tasks +4. Be concise but informative in your findings + +## What You Can Do +- Read and write files in the workspace +- Execute shell commands +- Search the web and fetch web pages +- Complete the task thoroughly + +## What You Cannot Do +- Send messages directly to users (no message tool available) +- Spawn other subagents +- Access the main agent's conversation history + +## Workspace +Your workspace is at: {self.workspace} + +When you have completed the task, provide a clear summary of your findings or actions.""" + + def get_running_count(self) -> int: + """Return the number of currently running subagents.""" + return len(self._running_tasks) diff --git a/nanobot/nanobot/agent/tools/__init__.py b/nanobot/nanobot/agent/tools/__init__.py new file mode 100644 index 0000000..aac5d7d --- /dev/null +++ b/nanobot/nanobot/agent/tools/__init__.py @@ -0,0 +1,6 @@ +"""Agent tools module.""" + +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry + +__all__ = ["Tool", "ToolRegistry"] diff --git a/nanobot/nanobot/agent/tools/base.py b/nanobot/nanobot/agent/tools/base.py new file mode 100644 index 0000000..8e2ae3f --- /dev/null +++ b/nanobot/nanobot/agent/tools/base.py @@ -0,0 +1,108 @@ +"""Base class for agent tools.""" + +from abc import ABC, abstractmethod +from typing import Any + + +class Tool(ABC): + """ + Abstract base class for agent tools. + + Tools are capabilities that the agent can use to interact with + the environment, such as reading files, executing commands, etc. + """ + + _TYPE_MAP = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "array": list, + "object": dict, + } + + @property + @abstractmethod + def name(self) -> str: + """Tool name used in function calls.""" + pass + + @property + @abstractmethod + def description(self) -> str: + """Description of what the tool does.""" + pass + + @property + @abstractmethod + def parameters(self) -> dict[str, Any]: + """JSON Schema for tool parameters.""" + pass + + @abstractmethod + async def execute(self, **kwargs: Any) -> str: + """ + Execute the tool with given parameters. + + Args: + **kwargs: Tool-specific parameters. + + Returns: + String result of the tool execution. + """ + pass + + def validate_params(self, params: dict[str, Any]) -> list[str]: + """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + schema = self.parameters or {} + if schema.get("type", "object") != "object": + raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") + return self._validate(params, {**schema, "type": "object"}, "") + + def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]: + t, label = schema.get("type"), path or "parameter" + if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]): + return [f"{label} should be {t}"] + + errors = [] + if "enum" in schema and val not in schema["enum"]: + errors.append(f"{label} must be one of {schema['enum']}") + if t in ("integer", "number"): + if "minimum" in schema and val < schema["minimum"]: + errors.append(f"{label} must be >= {schema['minimum']}") + if "maximum" in schema and val > schema["maximum"]: + errors.append(f"{label} must be <= {schema['maximum']}") + if t == "string": + if "minLength" in schema and len(val) < schema["minLength"]: + errors.append(f"{label} must be at least {schema['minLength']} chars") + if "maxLength" in schema and len(val) > schema["maxLength"]: + errors.append(f"{label} must be at most {schema['maxLength']} chars") + if t == "object": + props = schema.get("properties", {}) + for k in schema.get("required", []): + if k not in val: + errors.append(f"missing required {path + '.' + k if path else k}") + for k, v in val.items(): + if k in props: + errors.extend( + self._validate(v, props[k], path + "." + k if path else k) + ) + if t == "array" and "items" in schema: + for i, item in enumerate(val): + errors.extend( + self._validate( + item, schema["items"], f"{path}[{i}]" if path else f"[{i}]" + ) + ) + return errors + + def to_schema(self) -> dict[str, Any]: + """Convert tool to OpenAI function schema format.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } diff --git a/nanobot/nanobot/agent/tools/filesystem.py b/nanobot/nanobot/agent/tools/filesystem.py new file mode 100644 index 0000000..a225173 --- /dev/null +++ b/nanobot/nanobot/agent/tools/filesystem.py @@ -0,0 +1,180 @@ +"""File system tools: read, write, edit.""" + +from pathlib import Path +from typing import Any + +from nanobot.agent.tools.base import Tool + + +class ReadFileTool(Tool): + """Tool to read file contents.""" + + @property + def name(self) -> str: + return "read_file" + + @property + def description(self) -> str: + return "Read the contents of a file at the given path." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": {"type": "string", "description": "The file path to read"} + }, + "required": ["path"], + } + + async def execute(self, path: str, **kwargs: Any) -> str: + try: + file_path = Path(path).expanduser() + if not file_path.exists(): + return f"Error: File not found: {path}" + if not file_path.is_file(): + return f"Error: Not a file: {path}" + + content = file_path.read_text(encoding="utf-8") + return content + except PermissionError: + return f"Error: Permission denied: {path}" + except Exception as e: + return f"Error reading file: {str(e)}" + + +class WriteFileTool(Tool): + """Tool to write content to a file.""" + + @property + def name(self) -> str: + return "write_file" + + @property + def description(self) -> str: + return "Write content to a file at the given path. Creates parent directories if needed." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": {"type": "string", "description": "The file path to write to"}, + "content": {"type": "string", "description": "The content to write"}, + }, + "required": ["path", "content"], + } + + async def execute(self, path: str, content: str, **kwargs: Any) -> str: + try: + file_path = Path(path).expanduser() + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + return f"Successfully wrote {len(content)} bytes to {path}" + except PermissionError: + return f"Error: Permission denied: {path}" + except Exception as e: + return f"Error writing file: {str(e)}" + + +class EditFileTool(Tool): + """Tool to edit a file by replacing text.""" + + @property + def name(self) -> str: + return "edit_file" + + @property + def description(self) -> str: + return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": {"type": "string", "description": "The file path to edit"}, + "old_text": { + "type": "string", + "description": "The exact text to find and replace", + }, + "new_text": { + "type": "string", + "description": "The text to replace with", + }, + }, + "required": ["path", "old_text", "new_text"], + } + + async def execute( + self, path: str, old_text: str, new_text: str, **kwargs: Any + ) -> str: + try: + file_path = Path(path).expanduser() + if not file_path.exists(): + return f"Error: File not found: {path}" + + content = file_path.read_text(encoding="utf-8") + + if old_text not in content: + return ( + "Error: old_text not found in file. Make sure it matches exactly." + ) + + # Count occurrences + count = content.count(old_text) + if count > 1: + return f"Warning: old_text appears {count} times. Please provide more context to make it unique." + + new_content = content.replace(old_text, new_text, 1) + file_path.write_text(new_content, encoding="utf-8") + + return f"Successfully edited {path}" + except PermissionError: + return f"Error: Permission denied: {path}" + except Exception as e: + return f"Error editing file: {str(e)}" + + +class ListDirTool(Tool): + """Tool to list directory contents.""" + + @property + def name(self) -> str: + return "list_dir" + + @property + def description(self) -> str: + return "List the contents of a directory." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "path": {"type": "string", "description": "The directory path to list"} + }, + "required": ["path"], + } + + async def execute(self, path: str, **kwargs: Any) -> str: + try: + dir_path = Path(path).expanduser() + if not dir_path.exists(): + return f"Error: Directory not found: {path}" + if not dir_path.is_dir(): + return f"Error: Not a directory: {path}" + + items = [] + for item in sorted(dir_path.iterdir()): + prefix = "📁 " if item.is_dir() else "📄 " + items.append(f"{prefix}{item.name}") + + if not items: + return f"Directory {path} is empty" + + return "\n".join(items) + except PermissionError: + return f"Error: Permission denied: {path}" + except Exception as e: + return f"Error listing directory: {str(e)}" diff --git a/nanobot/nanobot/agent/tools/litewrite.py b/nanobot/nanobot/agent/tools/litewrite.py new file mode 100644 index 0000000..99acac9 --- /dev/null +++ b/nanobot/nanobot/agent/tools/litewrite.py @@ -0,0 +1,314 @@ +"""Litewrite integration tools for managing LaTeX projects.""" + +import base64 +from pathlib import Path +from typing import Any + +import httpx +from loguru import logger + +from nanobot.agent.tools.base import Tool + + +class LitewriteClient: + """HTTP client for Litewrite Internal API.""" + + def __init__(self, base_url: str, api_secret: str): + self.base_url = base_url.rstrip("/") + self.api_secret = api_secret + + async def request(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]: + """Send a POST request to a Litewrite internal API endpoint.""" + url = f"{self.base_url}{endpoint}" + headers = {"X-Internal-Secret": self.api_secret} + + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post(url, json=data, headers=headers) + return resp.json() + + +class LitewriteListProjectsTool(Tool): + """Tool to list/search Litewrite projects.""" + + def __init__(self, client: LitewriteClient, default_owner_id: str = ""): + self._client = client + self._default_owner_id = default_owner_id + + @property + def name(self) -> str: + return "litewrite_list_projects" + + @property + def description(self) -> str: + return ( + "List LaTeX projects in Litewrite. " + "Use the search parameter to find projects by name (partial match)." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "search": { + "type": "string", + "description": "Search keyword to filter projects by name", + }, + }, + } + + async def execute(self, search: str = "", **kwargs: Any) -> str: + data: dict[str, Any] = {} + if search: + data["search"] = search + if self._default_owner_id: + data["ownerId"] = self._default_owner_id + + result = await self._client.request("/api/internal/projects/list", data) + + if not result.get("success"): + return f"Error listing projects: {result.get('error', 'Unknown error')}" + + projects = result.get("data", {}).get("projects", []) + if not projects: + return "No projects found." + + lines = [f"Found {len(projects)} project(s):"] + for p in projects: + lines.append( + f"- [{p['id']}] {p['name']}" + + (f" ({p.get('description', '')})" if p.get("description") else "") + + f" (main: {p.get('mainFile', 'main.tex')})" + ) + return "\n".join(lines) + + +class LitewriteListFilesTool(Tool): + """Tool to list files in a Litewrite project.""" + + def __init__(self, client: LitewriteClient): + self._client = client + + @property + def name(self) -> str: + return "litewrite_list_files" + + @property + def description(self) -> str: + return "List all files in a Litewrite project." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "The project ID", + }, + }, + "required": ["project_id"], + } + + async def execute(self, project_id: str, **kwargs: Any) -> str: + result = await self._client.request( + "/api/internal/files/list", + {"projectId": project_id}, + ) + + if not result.get("success"): + return f"Error listing files: {result.get('error', 'Unknown error')}" + + files = result.get("data", {}).get("files", []) + if not files: + return "No files found in this project." + + lines = [f"Files in project ({len(files)}):"] + for f in files: + size_str = f" ({f['size']} bytes)" if f.get("size") else "" + lines.append(f"- [{f['type']}] {f['path']}{size_str}") + return "\n".join(lines) + + +class LitewriteReadFileTool(Tool): + """Tool to read a file from a Litewrite project.""" + + def __init__(self, client: LitewriteClient): + self._client = client + + @property + def name(self) -> str: + return "litewrite_read_file" + + @property + def description(self) -> str: + return "Read the content of a file in a Litewrite project." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "The project ID", + }, + "file_path": { + "type": "string", + "description": "The file path within the project (e.g. 'main.tex')", + }, + }, + "required": ["project_id", "file_path"], + } + + async def execute(self, project_id: str, file_path: str, **kwargs: Any) -> str: + result = await self._client.request( + "/api/internal/files/read", + {"projectId": project_id, "filePath": file_path}, + ) + + if not result.get("success"): + return f"Error reading file: {result.get('error', 'Unknown error')}" + + content = result.get("data", {}).get("content", "") + total_lines = result.get("data", {}).get("totalLines", 0) + return f"File: {file_path} ({total_lines} lines)\n\n{content}" + + +class LitewriteEditFileTool(Tool): + """Tool to edit (replace) a file in a Litewrite project.""" + + def __init__(self, client: LitewriteClient): + self._client = client + + @property + def name(self) -> str: + return "litewrite_edit_file" + + @property + def description(self) -> str: + return ( + "Replace the entire content of a file in a Litewrite project. " + "You must provide the complete new file content." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "The project ID", + }, + "file_path": { + "type": "string", + "description": "The file path within the project (e.g. 'main.tex')", + }, + "content": { + "type": "string", + "description": "The complete new file content", + }, + }, + "required": ["project_id", "file_path", "content"], + } + + async def execute( + self, project_id: str, file_path: str, content: str, **kwargs: Any + ) -> str: + result = await self._client.request( + "/api/internal/files/edit", + {"projectId": project_id, "filePath": file_path, "content": content}, + ) + + if not result.get("success"): + return f"Error editing file: {result.get('error', 'Unknown error')}" + + length = result.get("data", {}).get("length", len(content)) + return f"Successfully updated {file_path} ({length} chars)" + + +class LitewriteCompileTool(Tool): + """Tool to compile a Litewrite project and get the PDF.""" + + def __init__(self, client: LitewriteClient): + self._client = client + + @property + def name(self) -> str: + return "litewrite_compile" + + @property + def description(self) -> str: + return ( + "Compile a Litewrite LaTeX project to PDF. " + "Supported compilers: pdflatex (default), xelatex, lualatex. " + "Use xelatex when the document contains Chinese/Japanese/Korean text or uses fontspec/xeCJK packages. " + "Returns the local file path of the compiled PDF. " + "Use the message tool with the media parameter to send the PDF to the user." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "project_id": { + "type": "string", + "description": "The project ID to compile", + }, + "compiler": { + "type": "string", + "enum": ["pdflatex", "xelatex", "lualatex"], + "description": ( + "LaTeX compiler to use. Use 'xelatex' for documents with " + "Chinese/Japanese/Korean text or custom fonts. Default: pdflatex" + ), + }, + }, + "required": ["project_id"], + } + + async def execute( + self, project_id: str, compiler: str = "pdflatex", **kwargs: Any + ) -> str: + logger.info(f"Compiling Litewrite project: {project_id} (compiler={compiler})") + + data: dict[str, Any] = {"projectId": project_id} + if compiler and compiler != "pdflatex": + data["compiler"] = compiler + + result = await self._client.request( + "/api/internal/projects/compile", + data, + ) + + if not result.get("success"): + error = result.get("error", "Unknown error") + logs = result.get("logs", "") + return ( + f"Compilation failed: {error}\n{logs}" + if logs + else f"Compilation failed: {error}" + ) + + pdf_base64 = result.get("data", {}).get("pdfBase64", "") + pdf_filename = result.get("data", {}).get("pdfFileName", "output.pdf") + + if not pdf_base64: + return "Compilation succeeded but no PDF was produced." + + # Decode and save PDF to local file + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + pdf_path = media_dir / f"{project_id}_{pdf_filename}" + + pdf_bytes = base64.b64decode(pdf_base64) + pdf_path.write_bytes(pdf_bytes) + + logger.info(f"PDF saved to {pdf_path} ({len(pdf_bytes)} bytes)") + + return ( + f"Compilation successful. PDF saved to: {pdf_path}\n" + f'Use the message tool with media=["{pdf_path}"] to send it to the user.' + ) diff --git a/nanobot/nanobot/agent/tools/message.py b/nanobot/nanobot/agent/tools/message.py new file mode 100644 index 0000000..f62db11 --- /dev/null +++ b/nanobot/nanobot/agent/tools/message.py @@ -0,0 +1,102 @@ +"""Message tool for sending messages to users.""" + +from typing import Any, Callable, Awaitable + +from nanobot.agent.tools.base import Tool +from nanobot.bus.events import OutboundMessage + + +class MessageTool(Tool): + """Tool to send messages to users on chat channels.""" + + def __init__( + self, + send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None, + default_channel: str = "", + default_chat_id: str = "", + ): + self._send_callback = send_callback + self._default_channel = default_channel + self._default_chat_id = default_chat_id + + def set_context(self, channel: str, chat_id: str) -> None: + """Set the current message context.""" + self._default_channel = channel + self._default_chat_id = chat_id + + def set_send_callback( + self, callback: Callable[[OutboundMessage], Awaitable[None]] + ) -> None: + """Set the callback for sending messages.""" + self._send_callback = callback + + @property + def name(self) -> str: + return "message" + + @property + def description(self) -> str: + return ( + "Send a message to the user. Use this when you want to communicate something. " + "You can optionally attach files (e.g. PDF) via the media parameter." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The message content to send", + }, + "channel": { + "type": "string", + "description": "Optional: target channel (telegram, feishu, etc.)", + }, + "chat_id": { + "type": "string", + "description": "Optional: target chat/user ID", + }, + "media": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional: list of local file paths to send as attachments " + "(e.g. compiled PDF files)" + ), + }, + }, + "required": ["content"], + } + + async def execute( + self, + content: str, + channel: str | None = None, + chat_id: str | None = None, + media: list[str] | None = None, + **kwargs: Any, + ) -> str: + channel = channel or self._default_channel + chat_id = chat_id or self._default_chat_id + + if not channel or not chat_id: + return "Error: No target channel/chat specified" + + if not self._send_callback: + return "Error: Message sending not configured" + + msg = OutboundMessage( + channel=channel, + chat_id=chat_id, + content=content, + media=media or [], + ) + + try: + await self._send_callback(msg) + attachment_info = f" with {len(media)} attachment(s)" if media else "" + return f"Message sent to {channel}:{chat_id}{attachment_info}" + except Exception as e: + return f"Error sending message: {str(e)}" diff --git a/nanobot/nanobot/agent/tools/registry.py b/nanobot/nanobot/agent/tools/registry.py new file mode 100644 index 0000000..da64d0a --- /dev/null +++ b/nanobot/nanobot/agent/tools/registry.py @@ -0,0 +1,75 @@ +"""Tool registry for dynamic tool management.""" + +from typing import Any + +from nanobot.agent.tools.base import Tool + + +class ToolRegistry: + """ + Registry for agent tools. + + Allows dynamic registration and execution of tools. + """ + + def __init__(self): + self._tools: dict[str, Tool] = {} + + def register(self, tool: Tool) -> None: + """Register a tool.""" + self._tools[tool.name] = tool + + def unregister(self, name: str) -> None: + """Unregister a tool by name.""" + self._tools.pop(name, None) + + def get(self, name: str) -> Tool | None: + """Get a tool by name.""" + return self._tools.get(name) + + def has(self, name: str) -> bool: + """Check if a tool is registered.""" + return name in self._tools + + def get_definitions(self) -> list[dict[str, Any]]: + """Get all tool definitions in OpenAI format.""" + return [tool.to_schema() for tool in self._tools.values()] + + async def execute(self, name: str, params: dict[str, Any]) -> str: + """ + Execute a tool by name with given parameters. + + Args: + name: Tool name. + params: Tool parameters. + + Returns: + Tool execution result as string. + + Raises: + KeyError: If tool not found. + """ + tool = self._tools.get(name) + if not tool: + return f"Error: Tool '{name}' not found" + + try: + errors = tool.validate_params(params) + if errors: + return f"Error: Invalid parameters for tool '{name}': " + "; ".join( + errors + ) + return await tool.execute(**params) + except Exception as e: + return f"Error executing {name}: {str(e)}" + + @property + def tool_names(self) -> list[str]: + """Get list of registered tool names.""" + return list(self._tools.keys()) + + def __len__(self) -> int: + return len(self._tools) + + def __contains__(self, name: str) -> bool: + return name in self._tools diff --git a/nanobot/nanobot/agent/tools/shell.py b/nanobot/nanobot/agent/tools/shell.py new file mode 100644 index 0000000..207b747 --- /dev/null +++ b/nanobot/nanobot/agent/tools/shell.py @@ -0,0 +1,147 @@ +"""Shell execution tool.""" + +import asyncio +import os +import re +from pathlib import Path +from typing import Any + +from nanobot.agent.tools.base import Tool + + +class ExecTool(Tool): + """Tool to execute shell commands.""" + + def __init__( + self, + timeout: int = 60, + working_dir: str | None = None, + deny_patterns: list[str] | None = None, + allow_patterns: list[str] | None = None, + restrict_to_workspace: bool = False, + ): + self.timeout = timeout + self.working_dir = working_dir + self.deny_patterns = deny_patterns or [ + r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr + r"\bdel\s+/[fq]\b", # del /f, del /q + r"\brmdir\s+/s\b", # rmdir /s + r"\b(format|mkfs|diskpart)\b", # disk operations + r"\bdd\s+if=", # dd + r">\s*/dev/sd", # write to disk + r"\b(shutdown|reboot|poweroff)\b", # system power + r":\(\)\s*\{.*\};\s*:", # fork bomb + ] + self.allow_patterns = allow_patterns or [] + self.restrict_to_workspace = restrict_to_workspace + + @property + def name(self) -> str: + return "exec" + + @property + def description(self) -> str: + return "Execute a shell command and return its output. Use with caution." + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute", + }, + "working_dir": { + "type": "string", + "description": "Optional working directory for the command", + }, + }, + "required": ["command"], + } + + async def execute( + self, command: str, working_dir: str | None = None, **kwargs: Any + ) -> str: + cwd = working_dir or self.working_dir or os.getcwd() + guard_error = self._guard_command(command, cwd) + if guard_error: + return guard_error + + try: + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), timeout=self.timeout + ) + except asyncio.TimeoutError: + process.kill() + return f"Error: Command timed out after {self.timeout} seconds" + + output_parts = [] + + if stdout: + output_parts.append(stdout.decode("utf-8", errors="replace")) + + if stderr: + stderr_text = stderr.decode("utf-8", errors="replace") + if stderr_text.strip(): + output_parts.append(f"STDERR:\n{stderr_text}") + + if process.returncode != 0: + output_parts.append(f"\nExit code: {process.returncode}") + + result = "\n".join(output_parts) if output_parts else "(no output)" + + # Truncate very long output + max_len = 10000 + if len(result) > max_len: + result = ( + result[:max_len] + + f"\n... (truncated, {len(result) - max_len} more chars)" + ) + + return result + + except Exception as e: + return f"Error executing command: {str(e)}" + + def _guard_command(self, command: str, cwd: str) -> str | None: + """Best-effort safety guard for potentially destructive commands.""" + cmd = command.strip() + lower = cmd.lower() + + for pattern in self.deny_patterns: + if re.search(pattern, lower): + return "Error: Command blocked by safety guard (dangerous pattern detected)" + + if self.allow_patterns: + if not any(re.search(p, lower) for p in self.allow_patterns): + return "Error: Command blocked by safety guard (not in allowlist)" + + if self.restrict_to_workspace: + if "..\\" in cmd or "../" in cmd: + return ( + "Error: Command blocked by safety guard (path traversal detected)" + ) + + cwd_path = Path(cwd).resolve() + + win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd) + posix_paths = re.findall(r"/[^\s\"']+", cmd) + + for raw in win_paths + posix_paths: + try: + p = Path(raw).resolve() + except Exception: + continue + if cwd_path not in p.parents and p != cwd_path: + return "Error: Command blocked by safety guard (path outside working dir)" + + return None diff --git a/nanobot/nanobot/agent/tools/spawn.py b/nanobot/nanobot/agent/tools/spawn.py new file mode 100644 index 0000000..4510aab --- /dev/null +++ b/nanobot/nanobot/agent/tools/spawn.py @@ -0,0 +1,65 @@ +"""Spawn tool for creating background subagents.""" + +from typing import Any, TYPE_CHECKING + +from nanobot.agent.tools.base import Tool + +if TYPE_CHECKING: + from nanobot.agent.subagent import SubagentManager + + +class SpawnTool(Tool): + """ + Tool to spawn a subagent for background task execution. + + The subagent runs asynchronously and announces its result back + to the main agent when complete. + """ + + def __init__(self, manager: "SubagentManager"): + self._manager = manager + self._origin_channel = "cli" + self._origin_chat_id = "direct" + + def set_context(self, channel: str, chat_id: str) -> None: + """Set the origin context for subagent announcements.""" + self._origin_channel = channel + self._origin_chat_id = chat_id + + @property + def name(self) -> str: + return "spawn" + + @property + def description(self) -> str: + return ( + "Spawn a subagent to handle a task in the background. " + "Use this for complex or time-consuming tasks that can run independently. " + "The subagent will complete the task and report back when done." + ) + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The task for the subagent to complete", + }, + "label": { + "type": "string", + "description": "Optional short label for the task (for display)", + }, + }, + "required": ["task"], + } + + async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str: + """Spawn a subagent to execute the given task.""" + return await self._manager.spawn( + task=task, + label=label, + origin_channel=self._origin_channel, + origin_chat_id=self._origin_chat_id, + ) diff --git a/nanobot/nanobot/agent/tools/web.py b/nanobot/nanobot/agent/tools/web.py new file mode 100644 index 0000000..cd48d66 --- /dev/null +++ b/nanobot/nanobot/agent/tools/web.py @@ -0,0 +1,209 @@ +"""Web tools: web_search and web_fetch.""" + +import html +import json +import os +import re +from typing import Any +from urllib.parse import urlparse + +import httpx + +from nanobot.agent.tools.base import Tool + +# Shared constants +USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36" +MAX_REDIRECTS = 5 # Limit redirects to prevent DoS attacks + + +def _strip_tags(text: str) -> str: + """Remove HTML tags and decode entities.""" + text = re.sub(r"", "", text, flags=re.I) + text = re.sub(r"", "", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + return html.unescape(text).strip() + + +def _normalize(text: str) -> str: + """Normalize whitespace.""" + text = re.sub(r"[ \t]+", " ", text) + return re.sub(r"\n{3,}", "\n\n", text).strip() + + +def _validate_url(url: str) -> tuple[bool, str]: + """Validate URL: must be http(s) with valid domain.""" + try: + p = urlparse(url) + if p.scheme not in ("http", "https"): + return False, f"Only http/https allowed, got '{p.scheme or 'none'}'" + if not p.netloc: + return False, "Missing domain" + return True, "" + except Exception as e: + return False, str(e) + + +class WebSearchTool(Tool): + """Search the web using Brave Search API.""" + + name = "web_search" + description = "Search the web. Returns titles, URLs, and snippets." + parameters = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "count": { + "type": "integer", + "description": "Results (1-10)", + "minimum": 1, + "maximum": 10, + }, + }, + "required": ["query"], + } + + def __init__(self, api_key: str | None = None, max_results: int = 5): + self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "") + self.max_results = max_results + + async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str: + if not self.api_key: + return "Error: BRAVE_API_KEY not configured" + + try: + n = min(max(count or self.max_results, 1), 10) + async with httpx.AsyncClient() as client: + r = await client.get( + "https://api.search.brave.com/res/v1/web/search", + params={"q": query, "count": n}, + headers={ + "Accept": "application/json", + "X-Subscription-Token": self.api_key, + }, + timeout=10.0, + ) + r.raise_for_status() + + results = r.json().get("web", {}).get("results", []) + if not results: + return f"No results for: {query}" + + lines = [f"Results for: {query}\n"] + for i, item in enumerate(results[:n], 1): + lines.append(f"{i}. {item.get('title', '')}\n {item.get('url', '')}") + if desc := item.get("description"): + lines.append(f" {desc}") + return "\n".join(lines) + except Exception as e: + return f"Error: {e}" + + +class WebFetchTool(Tool): + """Fetch and extract content from a URL using Readability.""" + + name = "web_fetch" + description = "Fetch URL and extract readable content (HTML → markdown/text)." + parameters = { + "type": "object", + "properties": { + "url": {"type": "string", "description": "URL to fetch"}, + "extractMode": { + "type": "string", + "enum": ["markdown", "text"], + "default": "markdown", + }, + "maxChars": {"type": "integer", "minimum": 100}, + }, + "required": ["url"], + } + + def __init__(self, max_chars: int = 50000): + self.max_chars = max_chars + + async def execute( + self, + url: str, + extractMode: str = "markdown", + maxChars: int | None = None, + **kwargs: Any, + ) -> str: + from readability import Document + + max_chars = maxChars or self.max_chars + + # Validate URL before fetching + is_valid, error_msg = _validate_url(url) + if not is_valid: + return json.dumps( + {"error": f"URL validation failed: {error_msg}", "url": url} + ) + + try: + async with httpx.AsyncClient( + follow_redirects=True, max_redirects=MAX_REDIRECTS, timeout=30.0 + ) as client: + r = await client.get(url, headers={"User-Agent": USER_AGENT}) + r.raise_for_status() + + ctype = r.headers.get("content-type", "") + + # JSON + if "application/json" in ctype: + text, extractor = json.dumps(r.json(), indent=2), "json" + # HTML + elif "text/html" in ctype or r.text[:256].lower().startswith( + (" max_chars + if truncated: + text = text[:max_chars] + + return json.dumps( + { + "url": url, + "finalUrl": str(r.url), + "status": r.status_code, + "extractor": extractor, + "truncated": truncated, + "length": len(text), + "text": text, + } + ) + except Exception as e: + return json.dumps({"error": str(e), "url": url}) + + def _to_markdown(self, html: str) -> str: + """Convert HTML to markdown.""" + # Convert links, headings, lists before stripping tags + text = re.sub( + r']*href=["\']([^"\']+)["\'][^>]*>([\s\S]*?)', + lambda m: f"[{_strip_tags(m[2])}]({m[1]})", + html, + flags=re.I, + ) + text = re.sub( + r"]*>([\s\S]*?)", + lambda m: f"\n{'#' * int(m[1])} {_strip_tags(m[2])}\n", + text, + flags=re.I, + ) + text = re.sub( + r"]*>([\s\S]*?)", + lambda m: f"\n- {_strip_tags(m[1])}", + text, + flags=re.I, + ) + text = re.sub(r"", "\n\n", text, flags=re.I) + text = re.sub(r"<(br|hr)\s*/?>", "\n", text, flags=re.I) + return _normalize(_strip_tags(text)) diff --git a/nanobot/nanobot/bus/__init__.py b/nanobot/nanobot/bus/__init__.py new file mode 100644 index 0000000..c7b282d --- /dev/null +++ b/nanobot/nanobot/bus/__init__.py @@ -0,0 +1,6 @@ +"""Message bus module for decoupled channel-agent communication.""" + +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus + +__all__ = ["MessageBus", "InboundMessage", "OutboundMessage"] diff --git a/nanobot/nanobot/bus/events.py b/nanobot/nanobot/bus/events.py new file mode 100644 index 0000000..8d622eb --- /dev/null +++ b/nanobot/nanobot/bus/events.py @@ -0,0 +1,35 @@ +"""Event types for the message bus.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +@dataclass +class InboundMessage: + """Message received from a chat channel.""" + + channel: str # telegram, discord, slack, whatsapp + sender_id: str # User identifier + chat_id: str # Chat/channel identifier + content: str # Message text + timestamp: datetime = field(default_factory=datetime.now) + media: list[str] = field(default_factory=list) # Media URLs + metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data + + @property + def session_key(self) -> str: + """Unique key for session identification.""" + return f"{self.channel}:{self.chat_id}" + + +@dataclass +class OutboundMessage: + """Message to send to a chat channel.""" + + channel: str + chat_id: str + content: str + reply_to: str | None = None + media: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) diff --git a/nanobot/nanobot/bus/queue.py b/nanobot/nanobot/bus/queue.py new file mode 100644 index 0000000..2044929 --- /dev/null +++ b/nanobot/nanobot/bus/queue.py @@ -0,0 +1,81 @@ +"""Async message queue for decoupled channel-agent communication.""" + +import asyncio +from typing import Callable, Awaitable + +from loguru import logger + +from nanobot.bus.events import InboundMessage, OutboundMessage + + +class MessageBus: + """ + Async message bus that decouples chat channels from the agent core. + + Channels push messages to the inbound queue, and the agent processes + them and pushes responses to the outbound queue. + """ + + def __init__(self): + self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() + self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() + self._outbound_subscribers: dict[ + str, list[Callable[[OutboundMessage], Awaitable[None]]] + ] = {} + self._running = False + + async def publish_inbound(self, msg: InboundMessage) -> None: + """Publish a message from a channel to the agent.""" + await self.inbound.put(msg) + + async def consume_inbound(self) -> InboundMessage: + """Consume the next inbound message (blocks until available).""" + return await self.inbound.get() + + async def publish_outbound(self, msg: OutboundMessage) -> None: + """Publish a response from the agent to channels.""" + await self.outbound.put(msg) + + async def consume_outbound(self) -> OutboundMessage: + """Consume the next outbound message (blocks until available).""" + return await self.outbound.get() + + def subscribe_outbound( + self, channel: str, callback: Callable[[OutboundMessage], Awaitable[None]] + ) -> None: + """Subscribe to outbound messages for a specific channel.""" + if channel not in self._outbound_subscribers: + self._outbound_subscribers[channel] = [] + self._outbound_subscribers[channel].append(callback) + + async def dispatch_outbound(self) -> None: + """ + Dispatch outbound messages to subscribed channels. + Run this as a background task. + """ + self._running = True + while self._running: + try: + msg = await asyncio.wait_for(self.outbound.get(), timeout=1.0) + subscribers = self._outbound_subscribers.get(msg.channel, []) + for callback in subscribers: + try: + await callback(msg) + except Exception as e: + logger.error(f"Error dispatching to {msg.channel}: {e}") + except asyncio.TimeoutError: + continue + + def stop(self) -> None: + """Stop the dispatcher loop.""" + self._running = False + + @property + def inbound_size(self) -> int: + """Number of pending inbound messages.""" + return self.inbound.qsize() + + @property + def outbound_size(self) -> int: + """Number of pending outbound messages.""" + return self.outbound.qsize() diff --git a/nanobot/nanobot/channels/__init__.py b/nanobot/nanobot/channels/__init__.py new file mode 100644 index 0000000..588169d --- /dev/null +++ b/nanobot/nanobot/channels/__init__.py @@ -0,0 +1,6 @@ +"""Chat channels module with plugin architecture.""" + +from nanobot.channels.base import BaseChannel +from nanobot.channels.manager import ChannelManager + +__all__ = ["BaseChannel", "ChannelManager"] diff --git a/nanobot/nanobot/channels/base.py b/nanobot/nanobot/channels/base.py new file mode 100644 index 0000000..b104980 --- /dev/null +++ b/nanobot/nanobot/channels/base.py @@ -0,0 +1,121 @@ +"""Base channel interface for chat platforms.""" + +from abc import ABC, abstractmethod +from typing import Any + +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus + + +class BaseChannel(ABC): + """ + Abstract base class for chat channel implementations. + + Each channel (Telegram, Discord, etc.) should implement this interface + to integrate with the nanobot message bus. + """ + + name: str = "base" + + def __init__(self, config: Any, bus: MessageBus): + """ + Initialize the channel. + + Args: + config: Channel-specific configuration. + bus: The message bus for communication. + """ + self.config = config + self.bus = bus + self._running = False + + @abstractmethod + async def start(self) -> None: + """ + Start the channel and begin listening for messages. + + This should be a long-running async task that: + 1. Connects to the chat platform + 2. Listens for incoming messages + 3. Forwards messages to the bus via _handle_message() + """ + pass + + @abstractmethod + async def stop(self) -> None: + """Stop the channel and clean up resources.""" + pass + + @abstractmethod + async def send(self, msg: OutboundMessage) -> None: + """ + Send a message through this channel. + + Args: + msg: The message to send. + """ + pass + + def is_allowed(self, sender_id: str) -> bool: + """ + Check if a sender is allowed to use this bot. + + Args: + sender_id: The sender's identifier. + + Returns: + True if allowed, False otherwise. + """ + allow_list = getattr(self.config, "allow_from", []) + + # If no allow list, allow everyone + if not allow_list: + return True + + sender_str = str(sender_id) + if sender_str in allow_list: + return True + if "|" in sender_str: + for part in sender_str.split("|"): + if part and part in allow_list: + return True + return False + + async def _handle_message( + self, + sender_id: str, + chat_id: str, + content: str, + media: list[str] | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + """ + Handle an incoming message from the chat platform. + + This method checks permissions and forwards to the bus. + + Args: + sender_id: The sender's identifier. + chat_id: The chat/channel identifier. + content: Message text content. + media: Optional list of media URLs. + metadata: Optional channel-specific metadata. + """ + if not self.is_allowed(sender_id): + return + + msg = InboundMessage( + channel=self.name, + sender_id=str(sender_id), + chat_id=str(chat_id), + content=content, + media=media or [], + metadata=metadata or {}, + ) + + await self.bus.publish_inbound(msg) + + @property + def is_running(self) -> bool: + """Check if the channel is running.""" + return self._running diff --git a/nanobot/nanobot/channels/feishu.py b/nanobot/nanobot/channels/feishu.py new file mode 100644 index 0000000..fd6dc03 --- /dev/null +++ b/nanobot/nanobot/channels/feishu.py @@ -0,0 +1,275 @@ +"""Feishu/Lark channel implementation using lark-oapi SDK.""" + +import asyncio +import json +from pathlib import Path +from typing import Any + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import FeishuConfig + +try: + import lark_oapi as lark + from lark_oapi import ws, EventDispatcherHandler + from lark_oapi.api.im.v1 import ( + CreateMessageRequest, + CreateMessageRequestBody, + CreateFileRequest, + CreateFileRequestBody, + ) + + FEISHU_AVAILABLE = True +except ImportError: + FEISHU_AVAILABLE = False + + +class FeishuChannel(BaseChannel): + """ + Feishu/Lark channel using WebSocket long-connection. + + Uses lark-oapi SDK's ws.Client for receiving messages (no public IP needed). + Uses lark-oapi API client for sending messages and uploading files. + """ + + name = "feishu" + + def __init__(self, config: FeishuConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: FeishuConfig = config + self._ws_client: Any = None + self._lark_client: Any = None + self._loop: asyncio.AbstractEventLoop | None = None + + async def start(self) -> None: + """Start the Feishu bot with WebSocket long-connection.""" + if not FEISHU_AVAILABLE: + logger.error("lark-oapi not installed. Run: pip install lark-oapi") + return + + if not self.config.app_id or not self.config.app_secret: + logger.error("Feishu app_id or app_secret not configured") + return + + self._running = True + self._loop = asyncio.get_running_loop() + + # Create lark API client for sending messages + self._lark_client = ( + lark.Client.builder() + .app_id(self.config.app_id) + .app_secret(self.config.app_secret) + .log_level(lark.LogLevel.DEBUG) + .build() + ) + + logger.info("Starting Feishu bot (WebSocket mode)...") + + # Run blocking WebSocket client in a separate thread + await asyncio.to_thread(self._run_ws_client) + + def _run_ws_client(self) -> None: + """Run the blocking WebSocket client (called in a thread).""" + # Build event handler + handler = ( + EventDispatcherHandler.builder("", "") + .register_p2_im_message_receive_v1(self._on_message_sync) + .build() + ) + + # Create and start WebSocket client + self._ws_client = ws.Client( + self.config.app_id, + self.config.app_secret, + event_handler=handler, + log_level=lark.LogLevel.DEBUG, + ) + + logger.info("Feishu WebSocket client connecting...") + self._ws_client.start() + + def _on_message_sync(self, data: Any) -> None: + """ + Handle incoming message from Feishu SDK (called from SDK thread). + Bridges to async via run_coroutine_threadsafe. + """ + if self._loop is None: + return + + asyncio.run_coroutine_threadsafe(self._handle_feishu_message(data), self._loop) + + async def _handle_feishu_message(self, data: Any) -> None: + """Process a Feishu message event and forward to the message bus.""" + try: + # Extract message data from the event + event = data.event + message = event.message + sender = event.sender + + # Get sender open_id + sender_id = sender.sender_id.open_id if sender.sender_id else "" + chat_id = message.chat_id or "" + + # Only handle text messages for now + msg_type = message.message_type + if msg_type != "text": + logger.debug(f"Ignoring non-text message type: {msg_type}") + return + + # Parse message content (Feishu wraps text in JSON: {"text": "..."}) + content = "" + try: + content_json = json.loads(message.content) + content = content_json.get("text", "") + except (json.JSONDecodeError, TypeError): + content = message.content or "" + + if not content: + return + + logger.debug(f"Feishu message from {sender_id}: {content[:50]}...") + + # Forward to the message bus + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=content, + metadata={ + "message_id": message.message_id, + "chat_type": message.chat_type, + "msg_type": msg_type, + }, + ) + + except Exception as e: + logger.error(f"Error handling Feishu message: {e}") + + async def stop(self) -> None: + """Stop the Feishu bot.""" + self._running = False + # The ws.Client doesn't have a clean stop mechanism; + # it will terminate when the process exits. + logger.info("Feishu channel stopped") + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Feishu.""" + if not self._lark_client: + logger.warning("Feishu client not initialized") + return + + try: + # Send text content + if msg.content: + await self._send_text(msg.chat_id, msg.content) + + # Send file attachments + if msg.media: + for file_path in msg.media: + await self._send_file(msg.chat_id, file_path) + + except Exception as e: + logger.error(f"Error sending Feishu message: {e}") + + async def _send_text(self, chat_id: str, content: str) -> None: + """Send a text message to a Feishu chat.""" + body = ( + CreateMessageRequestBody.builder() + .receive_id(chat_id) + .msg_type("text") + .content(json.dumps({"text": content})) + .build() + ) + + request = ( + CreateMessageRequest.builder() + .receive_id_type("chat_id") + .request_body(body) + .build() + ) + + # Run sync API call in thread to avoid blocking the event loop + response = await asyncio.to_thread( + self._lark_client.im.v1.message.create, request + ) + + if not response.success(): + logger.error( + f"Failed to send Feishu text: code={response.code}, msg={response.msg}" + ) + + async def _send_file(self, chat_id: str, file_path: str) -> None: + """Upload a file to Feishu and send it as a file message.""" + path = Path(file_path) + if not path.exists(): + logger.error(f"File not found: {file_path}") + return + + # Determine file type for Feishu API + suffix = path.suffix.lower() + if suffix == ".pdf": + file_type = "pdf" + elif suffix in (".png", ".jpg", ".jpeg", ".gif"): + file_type = "image" + else: + file_type = "stream" + + try: + # Step 1: Upload file to Feishu + upload_body = ( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(path.name) + .file(open(path, "rb")) + .build() + ) + + upload_request = ( + CreateFileRequest.builder().request_body(upload_body).build() + ) + + upload_response = await asyncio.to_thread( + self._lark_client.im.v1.file.create, upload_request + ) + + if not upload_response.success(): + logger.error( + f"Failed to upload file to Feishu: code={upload_response.code}, " + f"msg={upload_response.msg}" + ) + return + + file_key = upload_response.data.file_key + + # Step 2: Send file message + msg_body = ( + CreateMessageRequestBody.builder() + .receive_id(chat_id) + .msg_type("file") + .content(json.dumps({"file_key": file_key})) + .build() + ) + + msg_request = ( + CreateMessageRequest.builder() + .receive_id_type("chat_id") + .request_body(msg_body) + .build() + ) + + msg_response = await asyncio.to_thread( + self._lark_client.im.v1.message.create, msg_request + ) + + if not msg_response.success(): + logger.error( + f"Failed to send file message: code={msg_response.code}, " + f"msg={msg_response.msg}" + ) + else: + logger.info(f"Sent file {path.name} to Feishu chat {chat_id}") + + except Exception as e: + logger.error(f"Error sending file to Feishu: {e}") diff --git a/nanobot/nanobot/channels/manager.py b/nanobot/nanobot/channels/manager.py new file mode 100644 index 0000000..ea0a5a1 --- /dev/null +++ b/nanobot/nanobot/channels/manager.py @@ -0,0 +1,146 @@ +"""Channel manager for coordinating chat channels.""" + +import asyncio +from typing import Any + +from loguru import logger + +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import Config + + +class ChannelManager: + """ + Manages chat channels and coordinates message routing. + + Responsibilities: + - Initialize enabled channels (Telegram, WhatsApp, etc.) + - Start/stop channels + - Route outbound messages + """ + + def __init__(self, config: Config, bus: MessageBus): + self.config = config + self.bus = bus + self.channels: dict[str, BaseChannel] = {} + self._dispatch_task: asyncio.Task | None = None + + self._init_channels() + + def _init_channels(self) -> None: + """Initialize channels based on config.""" + + # Telegram channel + if self.config.channels.telegram.enabled: + try: + from nanobot.channels.telegram import TelegramChannel + + self.channels["telegram"] = TelegramChannel( + self.config.channels.telegram, + self.bus, + groq_api_key=self.config.providers.groq.api_key, + ) + logger.info("Telegram channel enabled") + except ImportError as e: + logger.warning(f"Telegram channel not available: {e}") + + # WhatsApp channel + if self.config.channels.whatsapp.enabled: + try: + from nanobot.channels.whatsapp import WhatsAppChannel + + self.channels["whatsapp"] = WhatsAppChannel( + self.config.channels.whatsapp, self.bus + ) + logger.info("WhatsApp channel enabled") + except ImportError as e: + logger.warning(f"WhatsApp channel not available: {e}") + + # Feishu channel + if self.config.channels.feishu.enabled: + try: + from nanobot.channels.feishu import FeishuChannel + + self.channels["feishu"] = FeishuChannel( + self.config.channels.feishu, self.bus + ) + logger.info("Feishu channel enabled") + except ImportError as e: + logger.warning(f"Feishu channel not available: {e}") + + async def start_all(self) -> None: + """Start WhatsApp channel and the outbound dispatcher.""" + if not self.channels: + logger.warning("No channels enabled") + return + + # Start outbound dispatcher + self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) + + # Start WhatsApp channel + tasks = [] + for name, channel in self.channels.items(): + logger.info(f"Starting {name} channel...") + tasks.append(asyncio.create_task(channel.start())) + + # Wait for all to complete (they should run forever) + await asyncio.gather(*tasks, return_exceptions=True) + + async def stop_all(self) -> None: + """Stop all channels and the dispatcher.""" + logger.info("Stopping all channels...") + + # Stop dispatcher + if self._dispatch_task: + self._dispatch_task.cancel() + try: + await self._dispatch_task + except asyncio.CancelledError: + pass + + # Stop all channels + for name, channel in self.channels.items(): + try: + await channel.stop() + logger.info(f"Stopped {name} channel") + except Exception as e: + logger.error(f"Error stopping {name}: {e}") + + async def _dispatch_outbound(self) -> None: + """Dispatch outbound messages to the appropriate channel.""" + logger.info("Outbound dispatcher started") + + while True: + try: + msg = await asyncio.wait_for(self.bus.consume_outbound(), timeout=1.0) + + channel = self.channels.get(msg.channel) + if channel: + try: + await channel.send(msg) + except Exception as e: + logger.error(f"Error sending to {msg.channel}: {e}") + else: + logger.warning(f"Unknown channel: {msg.channel}") + + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + + def get_channel(self, name: str) -> BaseChannel | None: + """Get a channel by name.""" + return self.channels.get(name) + + def get_status(self) -> dict[str, Any]: + """Get status of all channels.""" + return { + name: {"enabled": True, "running": channel.is_running} + for name, channel in self.channels.items() + } + + @property + def enabled_channels(self) -> list[str]: + """Get list of enabled channel names.""" + return list(self.channels.keys()) diff --git a/nanobot/nanobot/channels/telegram.py b/nanobot/nanobot/channels/telegram.py new file mode 100644 index 0000000..ece74b3 --- /dev/null +++ b/nanobot/nanobot/channels/telegram.py @@ -0,0 +1,320 @@ +"""Telegram channel implementation using python-telegram-bot.""" + +import asyncio +import re + +from loguru import logger +from telegram import Update +from telegram.ext import Application, MessageHandler, filters, ContextTypes + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import TelegramConfig + + +def _markdown_to_telegram_html(text: str) -> str: + """ + Convert markdown to Telegram-safe HTML. + """ + if not text: + return "" + + # 1. Extract and protect code blocks (preserve content from other processing) + code_blocks: list[str] = [] + + def save_code_block(m: re.Match) -> str: + code_blocks.append(m.group(1)) + return f"\x00CB{len(code_blocks) - 1}\x00" + + text = re.sub(r"```[\w]*\n?([\s\S]*?)```", save_code_block, text) + + # 2. Extract and protect inline code + inline_codes: list[str] = [] + + def save_inline_code(m: re.Match) -> str: + inline_codes.append(m.group(1)) + return f"\x00IC{len(inline_codes) - 1}\x00" + + text = re.sub(r"`([^`]+)`", save_inline_code, text) + + # 3. Headers # Title -> just the title text + text = re.sub(r"^#{1,6}\s+(.+)$", r"\1", text, flags=re.MULTILINE) + + # 4. Blockquotes > text -> just the text (before HTML escaping) + text = re.sub(r"^>\s*(.*)$", r"\1", text, flags=re.MULTILINE) + + # 5. Escape HTML special characters + text = text.replace("&", "&").replace("<", "<").replace(">", ">") + + # 6. Links [text](url) - must be before bold/italic to handle nested cases + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', text) + + # 7. Bold **text** or __text__ + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + + # 8. Italic _text_ (avoid matching inside words like some_var_name) + text = re.sub(r"(?\1", text) + + # 9. Strikethrough ~~text~~ + text = re.sub(r"~~(.+?)~~", r"\1", text) + + # 10. Bullet lists - item -> • item + text = re.sub(r"^[-*]\s+", "• ", text, flags=re.MULTILINE) + + # 11. Restore inline code with HTML tags + for i, code in enumerate(inline_codes): + # Escape HTML in code content + escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") + text = text.replace(f"\x00IC{i}\x00", f"{escaped}") + + # 12. Restore code blocks with HTML tags + for i, code in enumerate(code_blocks): + # Escape HTML in code content + escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") + text = text.replace(f"\x00CB{i}\x00", f"
{escaped}
") + + return text + + +class TelegramChannel(BaseChannel): + """ + Telegram channel using long polling. + + Simple and reliable - no webhook/public IP needed. + """ + + name = "telegram" + + def __init__(self, config: TelegramConfig, bus: MessageBus, groq_api_key: str = ""): + super().__init__(config, bus) + self.config: TelegramConfig = config + self.groq_api_key = groq_api_key + self._app: Application | None = None + self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies + + async def start(self) -> None: + """Start the Telegram bot with long polling.""" + if not self.config.token: + logger.error("Telegram bot token not configured") + return + + self._running = True + + # Build the application + self._app = Application.builder().token(self.config.token).build() + + # Add message handler for text, photos, voice, documents + self._app.add_handler( + MessageHandler( + ( + filters.TEXT + | filters.PHOTO + | filters.VOICE + | filters.AUDIO + | filters.Document.ALL + ) + & ~filters.COMMAND, + self._on_message, + ) + ) + + # Add /start command handler + from telegram.ext import CommandHandler + + self._app.add_handler(CommandHandler("start", self._on_start)) + + logger.info("Starting Telegram bot (polling mode)...") + + # Initialize and start polling + await self._app.initialize() + await self._app.start() + + # Get bot info + bot_info = await self._app.bot.get_me() + logger.info(f"Telegram bot @{bot_info.username} connected") + + # Start polling (this runs until stopped) + await self._app.updater.start_polling( + allowed_updates=["message"], + drop_pending_updates=True, # Ignore old messages on startup + ) + + # Keep running until stopped + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the Telegram bot.""" + self._running = False + + if self._app: + logger.info("Stopping Telegram bot...") + await self._app.updater.stop() + await self._app.stop() + await self._app.shutdown() + self._app = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Telegram.""" + if not self._app: + logger.warning("Telegram bot not running") + return + + try: + # chat_id should be the Telegram chat ID (integer) + chat_id = int(msg.chat_id) + # Convert markdown to Telegram HTML + html_content = _markdown_to_telegram_html(msg.content) + await self._app.bot.send_message( + chat_id=chat_id, text=html_content, parse_mode="HTML" + ) + except ValueError: + logger.error(f"Invalid chat_id: {msg.chat_id}") + except Exception as e: + # Fallback to plain text if HTML parsing fails + logger.warning(f"HTML parse failed, falling back to plain text: {e}") + try: + await self._app.bot.send_message( + chat_id=int(msg.chat_id), text=msg.content + ) + except Exception as e2: + logger.error(f"Error sending Telegram message: {e2}") + + async def _on_start( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Handle /start command.""" + if not update.message or not update.effective_user: + return + + user = update.effective_user + await update.message.reply_text( + f"👋 Hi {user.first_name}! I'm nanobot.\n\n" + "Send me a message and I'll respond!" + ) + + async def _on_message( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Handle incoming messages (text, photos, voice, documents).""" + if not update.message or not update.effective_user: + return + + message = update.message + user = update.effective_user + chat_id = message.chat_id + + # Use stable numeric ID, but keep username for allowlist compatibility + sender_id = str(user.id) + if user.username: + sender_id = f"{sender_id}|{user.username}" + + # Store chat_id for replies + self._chat_ids[sender_id] = chat_id + + # Build content from text and/or media + content_parts = [] + media_paths = [] + + # Text content + if message.text: + content_parts.append(message.text) + if message.caption: + content_parts.append(message.caption) + + # Handle media files + media_file = None + media_type = None + + if message.photo: + media_file = message.photo[-1] # Largest photo + media_type = "image" + elif message.voice: + media_file = message.voice + media_type = "voice" + elif message.audio: + media_file = message.audio + media_type = "audio" + elif message.document: + media_file = message.document + media_type = "file" + + # Download media if present + if media_file and self._app: + try: + file = await self._app.bot.get_file(media_file.file_id) + ext = self._get_extension( + media_type, getattr(media_file, "mime_type", None) + ) + + # Save to workspace/media/ + from pathlib import Path + + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" + await file.download_to_drive(str(file_path)) + + media_paths.append(str(file_path)) + + # Handle voice transcription + if media_type == "voice" or media_type == "audio": + from nanobot.providers.transcription import ( + GroqTranscriptionProvider, + ) + + transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) + transcription = await transcriber.transcribe(file_path) + if transcription: + logger.info( + f"Transcribed {media_type}: {transcription[:50]}..." + ) + content_parts.append(f"[transcription: {transcription}]") + else: + content_parts.append(f"[{media_type}: {file_path}]") + else: + content_parts.append(f"[{media_type}: {file_path}]") + + logger.debug(f"Downloaded {media_type} to {file_path}") + except Exception as e: + logger.error(f"Failed to download media: {e}") + content_parts.append(f"[{media_type}: download failed]") + + content = "\n".join(content_parts) if content_parts else "[empty message]" + + logger.debug(f"Telegram message from {sender_id}: {content[:50]}...") + + # Forward to the message bus + await self._handle_message( + sender_id=sender_id, + chat_id=str(chat_id), + content=content, + media=media_paths, + metadata={ + "message_id": message.message_id, + "user_id": user.id, + "username": user.username, + "first_name": user.first_name, + "is_group": message.chat.type != "private", + }, + ) + + def _get_extension(self, media_type: str, mime_type: str | None) -> str: + """Get file extension based on media type.""" + if mime_type: + ext_map = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "audio/ogg": ".ogg", + "audio/mpeg": ".mp3", + "audio/mp4": ".m4a", + } + if mime_type in ext_map: + return ext_map[mime_type] + + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} + return type_map.get(media_type, "") diff --git a/nanobot/nanobot/channels/whatsapp.py b/nanobot/nanobot/channels/whatsapp.py new file mode 100644 index 0000000..7c0009b --- /dev/null +++ b/nanobot/nanobot/channels/whatsapp.py @@ -0,0 +1,140 @@ +"""WhatsApp channel implementation using Node.js bridge.""" + +import asyncio +import json + +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import WhatsAppConfig + + +class WhatsAppChannel(BaseChannel): + """ + WhatsApp channel that connects to a Node.js bridge. + + The bridge uses @whiskeysockets/baileys to handle the WhatsApp Web protocol. + Communication between Python and Node.js is via WebSocket. + """ + + name = "whatsapp" + + def __init__(self, config: WhatsAppConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: WhatsAppConfig = config + self._ws = None + self._connected = False + + async def start(self) -> None: + """Start the WhatsApp channel by connecting to the bridge.""" + import websockets + + bridge_url = self.config.bridge_url + + logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...") + + self._running = True + + while self._running: + try: + async with websockets.connect(bridge_url) as ws: + self._ws = ws + self._connected = True + logger.info("Connected to WhatsApp bridge") + + # Listen for messages + async for message in ws: + try: + await self._handle_bridge_message(message) + except Exception as e: + logger.error(f"Error handling bridge message: {e}") + + except asyncio.CancelledError: + break + except Exception as e: + self._connected = False + self._ws = None + logger.warning(f"WhatsApp bridge connection error: {e}") + + if self._running: + logger.info("Reconnecting in 5 seconds...") + await asyncio.sleep(5) + + async def stop(self) -> None: + """Stop the WhatsApp channel.""" + self._running = False + self._connected = False + + if self._ws: + await self._ws.close() + self._ws = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through WhatsApp.""" + if not self._ws or not self._connected: + logger.warning("WhatsApp bridge not connected") + return + + try: + payload = {"type": "send", "to": msg.chat_id, "text": msg.content} + await self._ws.send(json.dumps(payload)) + except Exception as e: + logger.error(f"Error sending WhatsApp message: {e}") + + async def _handle_bridge_message(self, raw: str) -> None: + """Handle a message from the bridge.""" + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning(f"Invalid JSON from bridge: {raw[:100]}") + return + + msg_type = data.get("type") + + if msg_type == "message": + # Incoming message from WhatsApp + sender = data.get("sender", "") + content = data.get("content", "") + + # sender is typically: @s.whatsapp.net + # Extract just the phone number as chat_id + chat_id = sender.split("@")[0] if "@" in sender else sender + + # Handle voice transcription if it's a voice message + if content == "[Voice Message]": + logger.info( + f"Voice message received from {chat_id}, but direct download from bridge is not yet supported." + ) + content = ( + "[Voice Message: Transcription not available for WhatsApp yet]" + ) + + await self._handle_message( + sender_id=chat_id, + chat_id=sender, # Use full JID for replies + content=content, + metadata={ + "message_id": data.get("id"), + "timestamp": data.get("timestamp"), + "is_group": data.get("isGroup", False), + }, + ) + + elif msg_type == "status": + # Connection status update + status = data.get("status") + logger.info(f"WhatsApp status: {status}") + + if status == "connected": + self._connected = True + elif status == "disconnected": + self._connected = False + + elif msg_type == "qr": + # QR code for authentication + logger.info("Scan QR code in the bridge terminal to connect WhatsApp") + + elif msg_type == "error": + logger.error(f"WhatsApp bridge error: {data.get('error')}") diff --git a/nanobot/nanobot/cli/__init__.py b/nanobot/nanobot/cli/__init__.py new file mode 100644 index 0000000..b023cad --- /dev/null +++ b/nanobot/nanobot/cli/__init__.py @@ -0,0 +1 @@ +"""CLI module for nanobot.""" diff --git a/nanobot/nanobot/cli/commands.py b/nanobot/nanobot/cli/commands.py new file mode 100644 index 0000000..c5bad8e --- /dev/null +++ b/nanobot/nanobot/cli/commands.py @@ -0,0 +1,691 @@ +"""CLI commands for nanobot.""" + +import asyncio +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +from nanobot import __version__, __logo__ + +app = typer.Typer( + name="nanobot", + help=f"{__logo__} nanobot - Personal AI Assistant", + no_args_is_help=True, +) + +console = Console() + + +def version_callback(value: bool): + if value: + console.print(f"{__logo__} nanobot v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: bool = typer.Option( + None, "--version", "-v", callback=version_callback, is_eager=True + ), +): + """nanobot - Personal AI Assistant.""" + pass + + +# ============================================================================ +# Onboard / Setup +# ============================================================================ + + +@app.command() +def onboard(): + """Initialize nanobot configuration and workspace.""" + from nanobot.config.loader import get_config_path, save_config + from nanobot.config.schema import Config + from nanobot.utils.helpers import get_workspace_path + + config_path = get_config_path() + + if config_path.exists(): + console.print(f"[yellow]Config already exists at {config_path}[/yellow]") + if not typer.confirm("Overwrite?"): + raise typer.Exit() + + # Create default config + config = Config() + save_config(config) + console.print(f"[green]✓[/green] Created config at {config_path}") + + # Create workspace + workspace = get_workspace_path() + console.print(f"[green]✓[/green] Created workspace at {workspace}") + + # Create default bootstrap files + _create_workspace_templates(workspace) + + console.print(f"\n{__logo__} nanobot is ready!") + console.print("\nNext steps:") + console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") + console.print(" Get one at: https://openrouter.ai/keys") + console.print(' 2. Chat: [cyan]nanobot agent -m "Hello!"[/cyan]') + console.print( + "\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]" + ) + + +def _create_workspace_templates(workspace: Path): + """Create default workspace template files.""" + templates = { + "AGENTS.md": """# Agent Instructions + +You are a helpful AI assistant. Be concise, accurate, and friendly. + +## Guidelines + +- Always explain what you're doing before taking actions +- Ask for clarification when the request is ambiguous +- Use tools to help accomplish tasks +- Remember important information in your memory files +""", + "SOUL.md": """# Soul + +I am nanobot, a lightweight AI assistant. + +## Personality + +- Helpful and friendly +- Concise and to the point +- Curious and eager to learn + +## Values + +- Accuracy over speed +- User privacy and safety +- Transparency in actions +""", + "USER.md": """# User + +Information about the user goes here. + +## Preferences + +- Communication style: (casual/formal) +- Timezone: (your timezone) +- Language: (your preferred language) +""", + } + + for filename, content in templates.items(): + file_path = workspace / filename + if not file_path.exists(): + file_path.write_text(content) + console.print(f" [dim]Created {filename}[/dim]") + + # Create memory directory and MEMORY.md + memory_dir = workspace / "memory" + memory_dir.mkdir(exist_ok=True) + memory_file = memory_dir / "MEMORY.md" + if not memory_file.exists(): + memory_file.write_text("""# Long-term Memory + +This file stores important information that should persist across sessions. + +## User Information + +(Important facts about the user) + +## Preferences + +(User preferences learned over time) + +## Important Notes + +(Things to remember) +""") + console.print(" [dim]Created memory/MEMORY.md[/dim]") + + +# ============================================================================ +# Gateway / Server +# ============================================================================ + + +@app.command() +def gateway( + port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), +): + """Start the nanobot gateway.""" + from nanobot.config.loader import load_config, get_data_dir + from nanobot.bus.queue import MessageBus + from nanobot.providers.litellm_provider import LiteLLMProvider + from nanobot.agent.loop import AgentLoop + from nanobot.channels.manager import ChannelManager + from nanobot.cron.service import CronService + from nanobot.cron.types import CronJob + from nanobot.heartbeat.service import HeartbeatService + + if verbose: + import logging + + logging.basicConfig(level=logging.DEBUG) + + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") + + config = load_config() + + # Create components + bus = MessageBus() + + # Create provider (supports OpenRouter, Anthropic, OpenAI, Bedrock) + api_key = config.get_api_key() + api_base = config.get_api_base() + model = config.agents.defaults.model + is_bedrock = model.startswith("bedrock/") + + if not api_key and not is_bedrock: + console.print("[red]Error: No API key configured.[/red]") + console.print( + "Set one in ~/.nanobot/config.json under providers.openrouter.apiKey" + ) + raise typer.Exit(1) + + provider = LiteLLMProvider( + api_key=api_key, api_base=api_base, default_model=config.agents.defaults.model + ) + + # Create agent + agent = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + model=config.agents.defaults.model, + max_iterations=config.agents.defaults.max_tool_iterations, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + litewrite_config=config.litewrite, + feishu_config=config.channels.feishu, + ) + + # Create cron service + async def on_cron_job(job: CronJob) -> str | None: + """Execute a cron job through the agent.""" + response = await agent.process_direct( + job.payload.message, session_key=f"cron:{job.id}" + ) + # Optionally deliver to channel + if job.payload.deliver and job.payload.to: + from nanobot.bus.events import OutboundMessage + + await bus.publish_outbound( + OutboundMessage( + channel=job.payload.channel or "whatsapp", + chat_id=job.payload.to, + content=response or "", + ) + ) + return response + + cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron = CronService(cron_store_path, on_job=on_cron_job) + + # Create heartbeat service + async def on_heartbeat(prompt: str) -> str: + """Execute heartbeat through the agent.""" + return await agent.process_direct(prompt, session_key="heartbeat") + + heartbeat = HeartbeatService( + workspace=config.workspace_path, + on_heartbeat=on_heartbeat, + interval_s=30 * 60, # 30 minutes + enabled=True, + ) + + # Create channel manager + channels = ChannelManager(config, bus) + + if channels.enabled_channels: + console.print( + f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}" + ) + else: + console.print("[yellow]Warning: No channels enabled[/yellow]") + + cron_status = cron.status() + if cron_status["jobs"] > 0: + console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") + + console.print("[green]✓[/green] Heartbeat: every 30m") + + async def run(): + try: + await cron.start() + await heartbeat.start() + await asyncio.gather( + agent.run(), + channels.start_all(), + ) + except KeyboardInterrupt: + console.print("\nShutting down...") + heartbeat.stop() + cron.stop() + agent.stop() + await channels.stop_all() + + asyncio.run(run()) + + +# ============================================================================ +# Agent Commands +# ============================================================================ + + +@app.command() +def agent( + message: str = typer.Option( + None, "--message", "-m", help="Message to send to the agent" + ), + session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"), +): + """Interact with the agent directly.""" + from nanobot.config.loader import load_config + from nanobot.bus.queue import MessageBus + from nanobot.providers.litellm_provider import LiteLLMProvider + from nanobot.agent.loop import AgentLoop + + config = load_config() + + api_key = config.get_api_key() + api_base = config.get_api_base() + model = config.agents.defaults.model + is_bedrock = model.startswith("bedrock/") + + if not api_key and not is_bedrock: + console.print("[red]Error: No API key configured.[/red]") + raise typer.Exit(1) + + bus = MessageBus() + provider = LiteLLMProvider( + api_key=api_key, api_base=api_base, default_model=config.agents.defaults.model + ) + + agent_loop = AgentLoop( + bus=bus, + provider=provider, + workspace=config.workspace_path, + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, + litewrite_config=config.litewrite, + feishu_config=config.channels.feishu, + ) + + if message: + # Single message mode + async def run_once(): + response = await agent_loop.process_direct(message, session_id) + console.print(f"\n{__logo__} {response}") + + asyncio.run(run_once()) + else: + # Interactive mode + console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n") + + async def run_interactive(): + while True: + try: + user_input = console.input("[bold blue]You:[/bold blue] ") + if not user_input.strip(): + continue + + response = await agent_loop.process_direct(user_input, session_id) + console.print(f"\n{__logo__} {response}\n") + except KeyboardInterrupt: + console.print("\nGoodbye!") + break + + asyncio.run(run_interactive()) + + +# ============================================================================ +# Channel Commands +# ============================================================================ + + +channels_app = typer.Typer(help="Manage channels") +app.add_typer(channels_app, name="channels") + + +@channels_app.command("status") +def channels_status(): + """Show channel status.""" + from nanobot.config.loader import load_config + + config = load_config() + + table = Table(title="Channel Status") + table.add_column("Channel", style="cyan") + table.add_column("Enabled", style="green") + table.add_column("Configuration", style="yellow") + + # WhatsApp + wa = config.channels.whatsapp + table.add_row("WhatsApp", "✓" if wa.enabled else "✗", wa.bridge_url) + + # Telegram + tg = config.channels.telegram + tg_config = ( + f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" + ) + table.add_row("Telegram", "✓" if tg.enabled else "✗", tg_config) + + console.print(table) + + +def _get_bridge_dir() -> Path: + """Get the bridge directory, setting it up if needed.""" + import shutil + import subprocess + + # User's bridge location + user_bridge = Path.home() / ".nanobot" / "bridge" + + # Check if already built + if (user_bridge / "dist" / "index.js").exists(): + return user_bridge + + # Check for npm + if not shutil.which("npm"): + console.print("[red]npm not found. Please install Node.js >= 18.[/red]") + raise typer.Exit(1) + + # Find source bridge: first check package data, then source dir + pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) + src_bridge = ( + Path(__file__).parent.parent.parent / "bridge" + ) # repo root/bridge (dev) + + source = None + if (pkg_bridge / "package.json").exists(): + source = pkg_bridge + elif (src_bridge / "package.json").exists(): + source = src_bridge + + if not source: + console.print("[red]Bridge source not found.[/red]") + console.print("Try reinstalling: pip install --force-reinstall nanobot") + raise typer.Exit(1) + + console.print(f"{__logo__} Setting up bridge...") + + # Copy to user directory + user_bridge.parent.mkdir(parents=True, exist_ok=True) + if user_bridge.exists(): + shutil.rmtree(user_bridge) + shutil.copytree( + source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist") + ) + + # Install and build + try: + console.print(" Installing dependencies...") + subprocess.run( + ["npm", "install"], cwd=user_bridge, check=True, capture_output=True + ) + + console.print(" Building...") + subprocess.run( + ["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True + ) + + console.print("[green]✓[/green] Bridge ready\n") + except subprocess.CalledProcessError as e: + console.print(f"[red]Build failed: {e}[/red]") + if e.stderr: + console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") + raise typer.Exit(1) + + return user_bridge + + +@channels_app.command("login") +def channels_login(): + """Link device via QR code.""" + import subprocess + + bridge_dir = _get_bridge_dir() + + console.print(f"{__logo__} Starting bridge...") + console.print("Scan the QR code to connect.\n") + + try: + subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) + except subprocess.CalledProcessError as e: + console.print(f"[red]Bridge failed: {e}[/red]") + except FileNotFoundError: + console.print("[red]npm not found. Please install Node.js.[/red]") + + +# ============================================================================ +# Cron Commands +# ============================================================================ + +cron_app = typer.Typer(help="Manage scheduled tasks") +app.add_typer(cron_app, name="cron") + + +@cron_app.command("list") +def cron_list( + all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"), +): + """List scheduled jobs.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + jobs = service.list_jobs(include_disabled=all) + + if not jobs: + console.print("No scheduled jobs.") + return + + table = Table(title="Scheduled Jobs") + table.add_column("ID", style="cyan") + table.add_column("Name") + table.add_column("Schedule") + table.add_column("Status") + table.add_column("Next Run") + + import time + + for job in jobs: + # Format schedule + if job.schedule.kind == "every": + sched = f"every {(job.schedule.every_ms or 0) // 1000}s" + elif job.schedule.kind == "cron": + sched = job.schedule.expr or "" + else: + sched = "one-time" + + # Format next run + next_run = "" + if job.state.next_run_at_ms: + next_time = time.strftime( + "%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000) + ) + next_run = next_time + + status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" + + table.add_row(job.id, job.name, sched, status, next_run) + + console.print(table) + + +@cron_app.command("add") +def cron_add( + name: str = typer.Option(..., "--name", "-n", help="Job name"), + message: str = typer.Option(..., "--message", "-m", help="Message for agent"), + every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), + cron_expr: str = typer.Option( + None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')" + ), + at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), + deliver: bool = typer.Option( + False, "--deliver", "-d", help="Deliver response to channel" + ), + to: str = typer.Option(None, "--to", help="Recipient for delivery"), + channel: str = typer.Option( + None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')" + ), +): + """Add a scheduled job.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + from nanobot.cron.types import CronSchedule + + # Determine schedule type + if every: + schedule = CronSchedule(kind="every", every_ms=every * 1000) + elif cron_expr: + schedule = CronSchedule(kind="cron", expr=cron_expr) + elif at: + import datetime + + dt = datetime.datetime.fromisoformat(at) + schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) + else: + console.print("[red]Error: Must specify --every, --cron, or --at[/red]") + raise typer.Exit(1) + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + job = service.add_job( + name=name, + schedule=schedule, + message=message, + deliver=deliver, + to=to, + channel=channel, + ) + + console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") + + +@cron_app.command("remove") +def cron_remove( + job_id: str = typer.Argument(..., help="Job ID to remove"), +): + """Remove a scheduled job.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + if service.remove_job(job_id): + console.print(f"[green]✓[/green] Removed job {job_id}") + else: + console.print(f"[red]Job {job_id} not found[/red]") + + +@cron_app.command("enable") +def cron_enable( + job_id: str = typer.Argument(..., help="Job ID"), + disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"), +): + """Enable or disable a job.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + job = service.enable_job(job_id, enabled=not disable) + if job: + status = "disabled" if disable else "enabled" + console.print(f"[green]✓[/green] Job '{job.name}' {status}") + else: + console.print(f"[red]Job {job_id} not found[/red]") + + +@cron_app.command("run") +def cron_run( + job_id: str = typer.Argument(..., help="Job ID to run"), + force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), +): + """Manually run a job.""" + from nanobot.config.loader import get_data_dir + from nanobot.cron.service import CronService + + store_path = get_data_dir() / "cron" / "jobs.json" + service = CronService(store_path) + + async def run(): + return await service.run_job(job_id, force=force) + + if asyncio.run(run()): + console.print("[green]✓[/green] Job executed") + else: + console.print(f"[red]Failed to run job {job_id}[/red]") + + +# ============================================================================ +# Status Commands +# ============================================================================ + + +@app.command() +def status(): + """Show nanobot status.""" + from nanobot.config.loader import load_config, get_config_path + + config_path = get_config_path() + config = load_config() + workspace = config.workspace_path + + console.print(f"{__logo__} nanobot Status\n") + + console.print( + f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}" + ) + console.print( + f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}" + ) + + if config_path.exists(): + console.print(f"Model: {config.agents.defaults.model}") + + # Check API keys + has_openrouter = bool(config.providers.openrouter.api_key) + has_anthropic = bool(config.providers.anthropic.api_key) + has_openai = bool(config.providers.openai.api_key) + has_gemini = bool(config.providers.gemini.api_key) + has_vllm = bool(config.providers.vllm.api_base) + + console.print( + f"OpenRouter API: {'[green]✓[/green]' if has_openrouter else '[dim]not set[/dim]'}" + ) + console.print( + f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}" + ) + console.print( + f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}" + ) + console.print( + f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}" + ) + vllm_status = ( + f"[green]✓ {config.providers.vllm.api_base}[/green]" + if has_vllm + else "[dim]not set[/dim]" + ) + console.print(f"vLLM/Local: {vllm_status}") + + +if __name__ == "__main__": + app() diff --git a/nanobot/nanobot/config/__init__.py b/nanobot/nanobot/config/__init__.py new file mode 100644 index 0000000..88e8e9b --- /dev/null +++ b/nanobot/nanobot/config/__init__.py @@ -0,0 +1,6 @@ +"""Configuration module for nanobot.""" + +from nanobot.config.loader import load_config, get_config_path +from nanobot.config.schema import Config + +__all__ = ["Config", "load_config", "get_config_path"] diff --git a/nanobot/nanobot/config/loader.py b/nanobot/nanobot/config/loader.py new file mode 100644 index 0000000..2c58dbe --- /dev/null +++ b/nanobot/nanobot/config/loader.py @@ -0,0 +1,103 @@ +"""Configuration loading utilities.""" + +import json +from pathlib import Path +from typing import Any + +from nanobot.config.schema import Config + + +def get_config_path() -> Path: + """Get the default configuration file path.""" + return Path.home() / ".nanobot" / "config.json" + + +def get_data_dir() -> Path: + """Get the nanobot data directory.""" + from nanobot.utils.helpers import get_data_path + + return get_data_path() + + +def load_config(config_path: Path | None = None) -> Config: + """ + Load configuration from file and/or environment variables. + + Priority: environment variables > config file > defaults. + Environment variables use the NANOBOT__ prefix with __ as nested delimiter, + e.g. NANOBOT__PROVIDERS__OPENROUTER__API_KEY. + + Args: + config_path: Optional path to config file. Uses default if not provided. + + Returns: + Loaded configuration object. + """ + path = config_path or get_config_path() + + if path.exists(): + try: + with open(path) as f: + data = json.load(f) + # Load from file first, then env vars override via Pydantic Settings + file_config = convert_keys(data) + return Config(**file_config) + except (json.JSONDecodeError, ValueError) as e: + print(f"Warning: Failed to load config from {path}: {e}") + print("Using default configuration + environment variables.") + + # No config file: Pydantic Settings auto-reads NANOBOT__* env vars + return Config() + + +def save_config(config: Config, config_path: Path | None = None) -> None: + """ + Save configuration to file. + + Args: + config: Configuration to save. + config_path: Optional path to save to. Uses default if not provided. + """ + path = config_path or get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + + # Convert to camelCase format + data = config.model_dump() + data = convert_to_camel(data) + + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +def convert_keys(data: Any) -> Any: + """Convert camelCase keys to snake_case for Pydantic.""" + if isinstance(data, dict): + return {camel_to_snake(k): convert_keys(v) for k, v in data.items()} + if isinstance(data, list): + return [convert_keys(item) for item in data] + return data + + +def convert_to_camel(data: Any) -> Any: + """Convert snake_case keys to camelCase.""" + if isinstance(data, dict): + return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()} + if isinstance(data, list): + return [convert_to_camel(item) for item in data] + return data + + +def camel_to_snake(name: str) -> str: + """Convert camelCase to snake_case.""" + result = [] + for i, char in enumerate(name): + if char.isupper() and i > 0: + result.append("_") + result.append(char.lower()) + return "".join(result) + + +def snake_to_camel(name: str) -> str: + """Convert snake_case to camelCase.""" + components = name.split("_") + return components[0] + "".join(x.title() for x in components[1:]) diff --git a/nanobot/nanobot/config/schema.py b/nanobot/nanobot/config/schema.py new file mode 100644 index 0000000..075655f --- /dev/null +++ b/nanobot/nanobot/config/schema.py @@ -0,0 +1,160 @@ +"""Configuration schema using Pydantic.""" + +from pathlib import Path +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings + + +class WhatsAppConfig(BaseModel): + """WhatsApp channel configuration.""" + + enabled: bool = False + bridge_url: str = "ws://localhost:3001" + allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers + + +class TelegramConfig(BaseModel): + """Telegram channel configuration.""" + + enabled: bool = False + token: str = "" # Bot token from @BotFather + allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames + + +class FeishuConfig(BaseModel): + """Feishu/Lark channel configuration.""" + + enabled: bool = False + app_id: str = "" + app_secret: str = "" + allow_from: list[str] = Field(default_factory=list) # Allowed Feishu open_ids + default_litewrite_user_id: str = "" # Litewrite user ID for project operations + + +class ChannelsConfig(BaseModel): + """Configuration for chat channels.""" + + whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) + telegram: TelegramConfig = Field(default_factory=TelegramConfig) + feishu: FeishuConfig = Field(default_factory=FeishuConfig) + + +class AgentDefaults(BaseModel): + """Default agent configuration.""" + + workspace: str = "~/.nanobot/workspace" + model: str = "minimax/minimax-m2.1" + max_tokens: int = 8192 + temperature: float = 0.7 + max_tool_iterations: int = 20 + + +class AgentsConfig(BaseModel): + """Agent configuration.""" + + defaults: AgentDefaults = Field(default_factory=AgentDefaults) + + +class ProviderConfig(BaseModel): + """LLM provider configuration.""" + + api_key: str = "" + api_base: str | None = None + + +class ProvidersConfig(BaseModel): + """Configuration for LLM providers.""" + + anthropic: ProviderConfig = Field(default_factory=ProviderConfig) + openai: ProviderConfig = Field(default_factory=ProviderConfig) + openrouter: ProviderConfig = Field(default_factory=ProviderConfig) + groq: ProviderConfig = Field(default_factory=ProviderConfig) + zhipu: ProviderConfig = Field(default_factory=ProviderConfig) + vllm: ProviderConfig = Field(default_factory=ProviderConfig) + gemini: ProviderConfig = Field(default_factory=ProviderConfig) + + +class GatewayConfig(BaseModel): + """Gateway/server configuration.""" + + host: str = "0.0.0.0" + port: int = 18790 + + +class WebSearchConfig(BaseModel): + """Web search tool configuration.""" + + api_key: str = "" # Brave Search API key + max_results: int = 5 + + +class WebToolsConfig(BaseModel): + """Web tools configuration.""" + + search: WebSearchConfig = Field(default_factory=WebSearchConfig) + + +class ExecToolConfig(BaseModel): + """Shell exec tool configuration.""" + + timeout: int = 60 + restrict_to_workspace: bool = ( + False # If true, block commands accessing paths outside workspace + ) + + +class ToolsConfig(BaseModel): + """Tools configuration.""" + + web: WebToolsConfig = Field(default_factory=WebToolsConfig) + exec: ExecToolConfig = Field(default_factory=ExecToolConfig) + + +class LitewriteConfig(BaseModel): + """Litewrite integration configuration.""" + + url: str = "http://web:3000" # Litewrite API base URL (Docker network) + api_secret: str = "" # INTERNAL_API_SECRET for authentication + + +class Config(BaseSettings): + """Root configuration for nanobot.""" + + agents: AgentsConfig = Field(default_factory=AgentsConfig) + channels: ChannelsConfig = Field(default_factory=ChannelsConfig) + providers: ProvidersConfig = Field(default_factory=ProvidersConfig) + gateway: GatewayConfig = Field(default_factory=GatewayConfig) + tools: ToolsConfig = Field(default_factory=ToolsConfig) + litewrite: LitewriteConfig = Field(default_factory=LitewriteConfig) + + @property + def workspace_path(self) -> Path: + """Get expanded workspace path.""" + return Path(self.agents.defaults.workspace).expanduser() + + def get_api_key(self) -> str | None: + """Get API key in priority order: OpenRouter > Anthropic > OpenAI > Gemini > Zhipu > Groq > vLLM.""" + return ( + self.providers.openrouter.api_key + or self.providers.anthropic.api_key + or self.providers.openai.api_key + or self.providers.gemini.api_key + or self.providers.zhipu.api_key + or self.providers.groq.api_key + or self.providers.vllm.api_key + or None + ) + + def get_api_base(self) -> str | None: + """Get API base URL if using OpenRouter, Zhipu or vLLM.""" + if self.providers.openrouter.api_key: + return self.providers.openrouter.api_base or "https://openrouter.ai/api/v1" + if self.providers.zhipu.api_key: + return self.providers.zhipu.api_base + if self.providers.vllm.api_base: + return self.providers.vllm.api_base + return None + + class Config: + env_prefix = "NANOBOT__" + env_nested_delimiter = "__" diff --git a/nanobot/nanobot/cron/__init__.py b/nanobot/nanobot/cron/__init__.py new file mode 100644 index 0000000..a9d4cad --- /dev/null +++ b/nanobot/nanobot/cron/__init__.py @@ -0,0 +1,6 @@ +"""Cron service for scheduled agent tasks.""" + +from nanobot.cron.service import CronService +from nanobot.cron.types import CronJob, CronSchedule + +__all__ = ["CronService", "CronJob", "CronSchedule"] diff --git a/nanobot/nanobot/cron/service.py b/nanobot/nanobot/cron/service.py new file mode 100644 index 0000000..90f7bf6 --- /dev/null +++ b/nanobot/nanobot/cron/service.py @@ -0,0 +1,362 @@ +"""Cron service for scheduling agent tasks.""" + +import asyncio +import json +import time +import uuid +from pathlib import Path +from typing import Any, Callable, Coroutine + +from loguru import logger + +from nanobot.cron.types import ( + CronJob, + CronJobState, + CronPayload, + CronSchedule, + CronStore, +) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: + """Compute next run time in ms.""" + if schedule.kind == "at": + return schedule.at_ms if schedule.at_ms and schedule.at_ms > now_ms else None + + if schedule.kind == "every": + if not schedule.every_ms or schedule.every_ms <= 0: + return None + # Next interval from now + return now_ms + schedule.every_ms + + if schedule.kind == "cron" and schedule.expr: + try: + from croniter import croniter + + cron = croniter(schedule.expr, time.time()) + next_time = cron.get_next() + return int(next_time * 1000) + except Exception: + return None + + return None + + +class CronService: + """Service for managing and executing scheduled jobs.""" + + def __init__( + self, + store_path: Path, + on_job: Callable[[CronJob], Coroutine[Any, Any, str | None]] | None = None, + ): + self.store_path = store_path + self.on_job = on_job # Callback to execute job, returns response text + self._store: CronStore | None = None + self._timer_task: asyncio.Task | None = None + self._running = False + + def _load_store(self) -> CronStore: + """Load jobs from disk.""" + if self._store: + return self._store + + if self.store_path.exists(): + try: + data = json.loads(self.store_path.read_text()) + jobs = [] + for j in data.get("jobs", []): + jobs.append( + CronJob( + id=j["id"], + name=j["name"], + enabled=j.get("enabled", True), + schedule=CronSchedule( + kind=j["schedule"]["kind"], + at_ms=j["schedule"].get("atMs"), + every_ms=j["schedule"].get("everyMs"), + expr=j["schedule"].get("expr"), + tz=j["schedule"].get("tz"), + ), + payload=CronPayload( + kind=j["payload"].get("kind", "agent_turn"), + message=j["payload"].get("message", ""), + deliver=j["payload"].get("deliver", False), + channel=j["payload"].get("channel"), + to=j["payload"].get("to"), + ), + state=CronJobState( + next_run_at_ms=j.get("state", {}).get("nextRunAtMs"), + last_run_at_ms=j.get("state", {}).get("lastRunAtMs"), + last_status=j.get("state", {}).get("lastStatus"), + last_error=j.get("state", {}).get("lastError"), + ), + created_at_ms=j.get("createdAtMs", 0), + updated_at_ms=j.get("updatedAtMs", 0), + delete_after_run=j.get("deleteAfterRun", False), + ) + ) + self._store = CronStore(jobs=jobs) + except Exception as e: + logger.warning(f"Failed to load cron store: {e}") + self._store = CronStore() + else: + self._store = CronStore() + + return self._store + + def _save_store(self) -> None: + """Save jobs to disk.""" + if not self._store: + return + + self.store_path.parent.mkdir(parents=True, exist_ok=True) + + data = { + "version": self._store.version, + "jobs": [ + { + "id": j.id, + "name": j.name, + "enabled": j.enabled, + "schedule": { + "kind": j.schedule.kind, + "atMs": j.schedule.at_ms, + "everyMs": j.schedule.every_ms, + "expr": j.schedule.expr, + "tz": j.schedule.tz, + }, + "payload": { + "kind": j.payload.kind, + "message": j.payload.message, + "deliver": j.payload.deliver, + "channel": j.payload.channel, + "to": j.payload.to, + }, + "state": { + "nextRunAtMs": j.state.next_run_at_ms, + "lastRunAtMs": j.state.last_run_at_ms, + "lastStatus": j.state.last_status, + "lastError": j.state.last_error, + }, + "createdAtMs": j.created_at_ms, + "updatedAtMs": j.updated_at_ms, + "deleteAfterRun": j.delete_after_run, + } + for j in self._store.jobs + ], + } + + self.store_path.write_text(json.dumps(data, indent=2)) + + async def start(self) -> None: + """Start the cron service.""" + self._running = True + self._load_store() + self._recompute_next_runs() + self._save_store() + self._arm_timer() + logger.info( + f"Cron service started with {len(self._store.jobs if self._store else [])} jobs" + ) + + def stop(self) -> None: + """Stop the cron service.""" + self._running = False + if self._timer_task: + self._timer_task.cancel() + self._timer_task = None + + def _recompute_next_runs(self) -> None: + """Recompute next run times for all enabled jobs.""" + if not self._store: + return + now = _now_ms() + for job in self._store.jobs: + if job.enabled: + job.state.next_run_at_ms = _compute_next_run(job.schedule, now) + + def _get_next_wake_ms(self) -> int | None: + """Get the earliest next run time across all jobs.""" + if not self._store: + return None + times = [ + j.state.next_run_at_ms + for j in self._store.jobs + if j.enabled and j.state.next_run_at_ms + ] + return min(times) if times else None + + def _arm_timer(self) -> None: + """Schedule the next timer tick.""" + if self._timer_task: + self._timer_task.cancel() + + next_wake = self._get_next_wake_ms() + if not next_wake or not self._running: + return + + delay_ms = max(0, next_wake - _now_ms()) + delay_s = delay_ms / 1000 + + async def tick(): + await asyncio.sleep(delay_s) + if self._running: + await self._on_timer() + + self._timer_task = asyncio.create_task(tick()) + + async def _on_timer(self) -> None: + """Handle timer tick - run due jobs.""" + if not self._store: + return + + now = _now_ms() + due_jobs = [ + j + for j in self._store.jobs + if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms + ] + + for job in due_jobs: + await self._execute_job(job) + + self._save_store() + self._arm_timer() + + async def _execute_job(self, job: CronJob) -> None: + """Execute a single job.""" + start_ms = _now_ms() + logger.info(f"Cron: executing job '{job.name}' ({job.id})") + + try: + if self.on_job: + await self.on_job(job) + + job.state.last_status = "ok" + job.state.last_error = None + logger.info(f"Cron: job '{job.name}' completed") + + except Exception as e: + job.state.last_status = "error" + job.state.last_error = str(e) + logger.error(f"Cron: job '{job.name}' failed: {e}") + + job.state.last_run_at_ms = start_ms + job.updated_at_ms = _now_ms() + + # Handle one-shot jobs + if job.schedule.kind == "at": + if job.delete_after_run: + self._store.jobs = [j for j in self._store.jobs if j.id != job.id] + else: + job.enabled = False + job.state.next_run_at_ms = None + else: + # Compute next run + job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms()) + + # ========== Public API ========== + + def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: + """List all jobs.""" + store = self._load_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")) + + def add_job( + self, + name: str, + schedule: CronSchedule, + message: str, + deliver: bool = False, + channel: str | None = None, + to: str | None = None, + delete_after_run: bool = False, + ) -> CronJob: + """Add a new job.""" + store = self._load_store() + now = _now_ms() + + job = CronJob( + id=str(uuid.uuid4())[:8], + name=name, + enabled=True, + schedule=schedule, + payload=CronPayload( + kind="agent_turn", + message=message, + deliver=deliver, + channel=channel, + to=to, + ), + state=CronJobState(next_run_at_ms=_compute_next_run(schedule, now)), + created_at_ms=now, + updated_at_ms=now, + delete_after_run=delete_after_run, + ) + + store.jobs.append(job) + self._save_store() + self._arm_timer() + + logger.info(f"Cron: added job '{name}' ({job.id})") + return job + + def remove_job(self, job_id: str) -> bool: + """Remove a job by ID.""" + store = self._load_store() + before = len(store.jobs) + store.jobs = [j for j in store.jobs if j.id != job_id] + removed = len(store.jobs) < before + + if removed: + self._save_store() + self._arm_timer() + logger.info(f"Cron: removed job {job_id}") + + return removed + + def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None: + """Enable or disable a job.""" + store = self._load_store() + for job in store.jobs: + if job.id == job_id: + job.enabled = enabled + job.updated_at_ms = _now_ms() + if enabled: + job.state.next_run_at_ms = _compute_next_run( + job.schedule, _now_ms() + ) + else: + job.state.next_run_at_ms = None + self._save_store() + self._arm_timer() + return job + return None + + async def run_job(self, job_id: str, force: bool = False) -> bool: + """Manually run a job.""" + store = self._load_store() + for job in store.jobs: + if job.id == job_id: + if not force and not job.enabled: + return False + await self._execute_job(job) + self._save_store() + self._arm_timer() + return True + return False + + def status(self) -> dict: + """Get service status.""" + store = self._load_store() + return { + "enabled": self._running, + "jobs": len(store.jobs), + "next_wake_at_ms": self._get_next_wake_ms(), + } diff --git a/nanobot/nanobot/cron/types.py b/nanobot/nanobot/cron/types.py new file mode 100644 index 0000000..d814189 --- /dev/null +++ b/nanobot/nanobot/cron/types.py @@ -0,0 +1,64 @@ +"""Cron types.""" + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass +class CronSchedule: + """Schedule definition for a cron job.""" + + kind: Literal["at", "every", "cron"] + # For "at": timestamp in ms + at_ms: int | None = None + # For "every": interval in ms + every_ms: int | None = None + # For "cron": cron expression (e.g. "0 9 * * *") + expr: str | None = None + # Timezone for cron expressions + tz: str | None = None + + +@dataclass +class CronPayload: + """What to do when the job runs.""" + + kind: Literal["system_event", "agent_turn"] = "agent_turn" + message: str = "" + # Deliver response to channel + deliver: bool = False + channel: str | None = None # e.g. "whatsapp" + to: str | None = None # e.g. phone number + + +@dataclass +class CronJobState: + """Runtime state of a job.""" + + next_run_at_ms: int | None = None + last_run_at_ms: int | None = None + last_status: Literal["ok", "error", "skipped"] | None = None + last_error: str | None = None + + +@dataclass +class CronJob: + """A scheduled job.""" + + id: str + name: str + enabled: bool = True + schedule: CronSchedule = field(default_factory=lambda: CronSchedule(kind="every")) + payload: CronPayload = field(default_factory=CronPayload) + state: CronJobState = field(default_factory=CronJobState) + created_at_ms: int = 0 + updated_at_ms: int = 0 + delete_after_run: bool = False + + +@dataclass +class CronStore: + """Persistent store for cron jobs.""" + + version: int = 1 + jobs: list[CronJob] = field(default_factory=list) diff --git a/nanobot/nanobot/heartbeat/__init__.py b/nanobot/nanobot/heartbeat/__init__.py new file mode 100644 index 0000000..2ecd879 --- /dev/null +++ b/nanobot/nanobot/heartbeat/__init__.py @@ -0,0 +1,5 @@ +"""Heartbeat service for periodic agent wake-ups.""" + +from nanobot.heartbeat.service import HeartbeatService + +__all__ = ["HeartbeatService"] diff --git a/nanobot/nanobot/heartbeat/service.py b/nanobot/nanobot/heartbeat/service.py new file mode 100644 index 0000000..ff2f065 --- /dev/null +++ b/nanobot/nanobot/heartbeat/service.py @@ -0,0 +1,137 @@ +"""Heartbeat service - periodic agent wake-up to check for tasks.""" + +import asyncio +from pathlib import Path +from typing import Any, Callable, Coroutine + +from loguru import logger + +# Default interval: 30 minutes +DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60 + +# The prompt sent to agent during heartbeat +HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists). +Follow any instructions or tasks listed there. +If nothing needs attention, reply with just: HEARTBEAT_OK""" + +# Token that indicates "nothing to do" +HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK" + + +def _is_heartbeat_empty(content: str | None) -> bool: + """Check if HEARTBEAT.md has no actionable content.""" + if not content: + return True + + # Lines to skip: empty, headers, HTML comments, empty checkboxes + skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"} + + for line in content.split("\n"): + line = line.strip() + if ( + not line + or line.startswith("#") + or line.startswith("