/** * Agent API — exposes the active agent harness to workspace code. * * Single endpoint: POST /api/agent/query * * If `systemPromptPath` is provided → read it from workspace, use as systemPrompt. * If omitted → harness picks its own default coding-agent prompt * (Claude → `claude_code` preset; Codex → its built-in agent prompt). * * Supports session persistence via `sessionId` (pass the one returned from a * previous call). Provider-specific: Claude uses SDK session_id, Codex uses * threadId — but the value is opaque to the caller. * * Auth: per-session secret injected into the workspace backend as BLOBY_AGENT_SECRET. * Safety: localhost-only, path traversal prevention, concurrency + rate limits, timeouts. */ import fs from 'fs'; import path from 'path'; import { WORKSPACE_DIR } from '../shared/paths.js'; import { runAgentQuery } from './bloby-agent.js'; // ── Types ────────────────────────────────────────────────────────────────── export interface AgentQueryRequest { message: string; systemPromptPath?: string; // relative to workspace, e.g. "skills/my-skill/prompt.txt" sessionId?: string; // resume a previous session maxTurns?: number; // default 25, max 50 (Claude only) timeout?: number; // ms, default 120_000, max 300_000 } export interface AgentQueryResponse { ok: boolean; response?: string; sessionId?: string; toolsUsed?: string[]; usedFileTools?: boolean; error?: string; } // ── Concurrency & Rate Limiting ──────────────────────────────────────────── const MAX_CONCURRENT = 3; const RATE_LIMIT_WINDOW = 60_000; // 1 minute const RATE_LIMIT_MAX = 10; let activeQueries = 0; const requestTimestamps: number[] = []; function checkRateLimit(): string | null { if (activeQueries >= MAX_CONCURRENT) { return `Too many concurrent queries (max ${MAX_CONCURRENT}). Try again shortly.`; } const now = Date.now(); while (requestTimestamps.length && requestTimestamps[0]! < now - RATE_LIMIT_WINDOW) { requestTimestamps.shift(); } if (requestTimestamps.length >= RATE_LIMIT_MAX) { return `Rate limit exceeded (max ${RATE_LIMIT_MAX} requests per minute).`; } return null; } // ── Path Validation ──────────────────────────────────────────────────────── function resolveSystemPromptPath(relPath: string): { content: string } | { error: string } { const resolved = path.resolve(WORKSPACE_DIR, relPath); const workspaceBoundary = WORKSPACE_DIR + path.sep; if (!resolved.startsWith(workspaceBoundary) && resolved !== WORKSPACE_DIR) { return { error: 'System prompt path must be within the workspace directory.' }; } if (!fs.existsSync(resolved)) { return { error: `System prompt file not found: ${relPath}` }; } try { const content = fs.readFileSync(resolved, 'utf-8').trim(); if (!content) return { error: 'System prompt file is empty.' }; return { content }; } catch (err: any) { return { error: `Failed to read system prompt: ${err.message}` }; } } // ── Main Query Handler ───────────────────────────────────────────────────── export async function handleAgentQuery(req: AgentQueryRequest): Promise { if (!req.message || typeof req.message !== 'string') { return { ok: false, error: 'Missing or invalid "message" field.' }; } const rateLimitError = checkRateLimit(); if (rateLimitError) return { ok: false, error: rateLimitError }; let systemPrompt: string | undefined; if (req.systemPromptPath) { const result = resolveSystemPromptPath(req.systemPromptPath); if ('error' in result) return { ok: false, error: result.error }; systemPrompt = result.content; } activeQueries++; requestTimestamps.push(Date.now()); try { const result = await runAgentQuery({ message: req.message, systemPrompt, sessionId: req.sessionId, maxTurns: req.maxTurns, timeout: req.timeout, }); return result; } finally { activeQueries--; } }