UNPKG

@probelabs/probe

Version:

Node.js wrapper for the probe code search tool

393 lines (341 loc) 13.3 kB
/** * Delegate functionality for the probe package - used automatically by AI agents * Uses ProbeAgent SDK directly instead of spawning processes for better performance * @module delegate */ import { randomUUID } from 'crypto'; import { ProbeAgent } from './agent/ProbeAgent.js'; /** * DelegationManager - Simple delegation tracking with proper resource management * Note: In single-threaded Node.js, simple counter operations are atomic within the event loop. * No mutex/locking needed since operations are synchronous. * * Design notes: * - Uses Map instead of WeakMap because sessionIds are strings (UUIDs), not objects * - WeakMap only accepts objects as keys, so it cannot be used for string-based session IDs * - Session entries are automatically cleaned up when their count reaches 0 * - For long-running processes, periodic cleanup of stale sessions may be needed */ class DelegationManager { constructor() { this.maxConcurrent = parseInt(process.env.MAX_CONCURRENT_DELEGATIONS || '3', 10); this.maxPerSession = parseInt(process.env.MAX_DELEGATIONS_PER_SESSION || '10', 10); // Track delegations per session with timestamp for potential TTL cleanup // Map<string, { count: number, lastUpdated: number }> this.sessionDelegations = new Map(); this.globalActive = 0; // Start periodic cleanup of stale sessions (every 5 minutes) // Wrapped in try-catch to prevent interval errors from crashing the process this.cleanupInterval = setInterval(() => { try { this.cleanupStaleSessions(); } catch (error) { console.error('[DelegationManager] Error during cleanup:', error); } }, 5 * 60 * 1000); // Allow Node.js to exit even if interval is active if (this.cleanupInterval.unref) { this.cleanupInterval.unref(); } } /** * Check limits and increment counters (synchronous, atomic in Node.js event loop) * @param {string|null|undefined} parentSessionId - Parent session ID for tracking */ tryAcquire(parentSessionId) { // Validate parentSessionId parameter if (parentSessionId !== null && parentSessionId !== undefined && typeof parentSessionId !== 'string') { throw new TypeError('parentSessionId must be a string, null, or undefined'); } // Check global limit if (this.globalActive >= this.maxConcurrent) { throw new Error(`Maximum concurrent delegations (${this.maxConcurrent}) reached. Please wait for some delegations to complete.`); } // Check per-session limit if (parentSessionId) { const sessionData = this.sessionDelegations.get(parentSessionId); const sessionCount = sessionData?.count || 0; if (sessionCount >= this.maxPerSession) { throw new Error(`Maximum delegations per session (${this.maxPerSession}) reached for session ${parentSessionId}`); } } // Increment counters (atomic in single-threaded Node.js) this.globalActive++; if (parentSessionId) { const sessionData = this.sessionDelegations.get(parentSessionId); if (sessionData) { sessionData.count++; sessionData.lastUpdated = Date.now(); } else { this.sessionDelegations.set(parentSessionId, { count: 1, lastUpdated: Date.now() }); } } return true; } /** * Decrement counters (synchronous, atomic in Node.js event loop) */ release(parentSessionId, debug = false) { this.globalActive = Math.max(0, this.globalActive - 1); if (parentSessionId) { const sessionData = this.sessionDelegations.get(parentSessionId); if (sessionData) { sessionData.count = Math.max(0, sessionData.count - 1); // Clean up if count reaches 0 if (sessionData.count === 0) { this.sessionDelegations.delete(parentSessionId); } } } if (debug) { console.error(`[DELEGATE] Released. Global active: ${this.globalActive}`); } } /** * Get current stats for monitoring */ getStats() { return { globalActive: this.globalActive, maxConcurrent: this.maxConcurrent, maxPerSession: this.maxPerSession, sessionCount: this.sessionDelegations.size }; } /** * Clean up stale sessions (sessions with count=0 that haven't been updated in 1 hour) */ cleanupStaleSessions() { const oneHourAgo = Date.now() - (60 * 60 * 1000); for (const [sessionId, data] of this.sessionDelegations.entries()) { if (data.count === 0 && data.lastUpdated < oneHourAgo) { this.sessionDelegations.delete(sessionId); } } } /** * Cleanup all resources (for testing or shutdown) */ cleanup() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } this.sessionDelegations.clear(); this.globalActive = 0; } } // Singleton instance for the module const delegationManager = new DelegationManager(); /** * Delegate a big distinct task to a probe subagent (used automatically by AI agents) * * This function is designed for automatic use within the agentic loop. AI agents * should automatically identify complex multi-part requests and break them down * into focused, parallel tasks using this delegation mechanism. * * Creates a new ProbeAgent instance with a clean environment that automatically: * - Uses the default 'code-researcher' prompt (not inherited) * - Disables schema validation for simpler responses * - Disables mermaid validation for faster processing * - Disables delegation to prevent recursion * - Limits iterations to remaining parent iterations * * @param {Object} options - Delegate options * @param {string} options.task - A complete, self-contained task for the subagent. Should be specific and focused on one area of expertise. * @param {number} [options.timeout=300] - Timeout in seconds (default: 5 minutes) * @param {boolean} [options.debug=false] - Enable debug logging * @param {number} [options.currentIteration=0] - Current tool iteration count from parent agent * @param {number} [options.maxIterations=30] - Maximum tool iterations allowed * @param {string} [options.parentSessionId=null] - Parent session ID for tracking * @param {string} [options.path] - Search directory path (inherited from parent) * @param {string} [options.provider] - AI provider (inherited from parent) * @param {string} [options.model] - AI model (inherited from parent) * @param {Object} [options.tracer=null] - Telemetry tracer instance * @returns {Promise<string>} The response from the delegate agent */ export async function delegate({ task, timeout = 300, debug = false, currentIteration = 0, maxIterations = 30, tracer = null, parentSessionId = null, path = null, provider = null, model = null }) { if (!task || typeof task !== 'string') { throw new Error('Task parameter is required and must be a string'); } const sessionId = randomUUID(); const startTime = Date.now(); // Calculate remaining iterations for subagent const remainingIterations = Math.max(1, maxIterations - currentIteration); // Create delegation span for telemetry if tracer is available const delegationSpan = tracer ? tracer.createDelegationSpan(sessionId, task) : null; let timeoutId = null; let acquired = false; try { // Check limits and acquire delegation slot inside try block for proper cleanup delegationManager.tryAcquire(parentSessionId); acquired = true; if (debug) { const stats = delegationManager.getStats(); console.error(`[DELEGATE] Starting delegation session ${sessionId}`); console.error(`[DELEGATE] Parent session: ${parentSessionId || 'none'}`); console.error(`[DELEGATE] Task: ${task}`); console.error(`[DELEGATE] Current iteration: ${currentIteration}/${maxIterations}`); console.error(`[DELEGATE] Remaining iterations for subagent: ${remainingIterations}`); console.error(`[DELEGATE] Timeout configured: ${timeout} seconds`); console.error(`[DELEGATE] Global active delegations: ${stats.globalActive}/${stats.maxConcurrent}`); console.error(`[DELEGATE] Using ProbeAgent SDK with code-researcher prompt`); } // Create a new ProbeAgent instance for the delegated task const subagent = new ProbeAgent({ sessionId, promptType: 'code-researcher', // Clean prompt, not inherited from parent enableDelegate: false, // Explicitly disable delegation to prevent recursion disableMermaidValidation: true, // Faster processing disableJsonValidation: true, // Simpler responses maxIterations: remainingIterations, debug, tracer, path, // Inherit from parent provider, // Inherit from parent model // Inherit from parent }); if (debug) { console.error(`[DELEGATE] Created subagent with session ${sessionId}`); console.error(`[DELEGATE] Subagent config: promptType=code-researcher, enableDelegate=false, maxIterations=${remainingIterations}`); } // Set up timeout with proper cleanup // TODO: Implement AbortController support in ProbeAgent.answer() for proper cancellation // Current limitation: When timeout occurs, subagent.answer() continues running in background // This is acceptable since: // 1. The promise will eventually resolve/reject and be garbage collected // 2. The delegation slot is properly released on timeout // 3. The parent receives timeout error and can handle it // Future improvement: Add signal parameter to ProbeAgent.answer(task, [], { signal }) const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`Delegation timed out after ${timeout} seconds`)); }, timeout * 1000); }); // Execute the task with timeout const answerPromise = subagent.answer(task); const response = await Promise.race([answerPromise, timeoutPromise]); // Clear timeout immediately after race completes to prevent memory leak // Note: timeoutId is always set by this point (synchronous in Promise constructor) // but we keep the null check for defensive programming if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; } const duration = Date.now() - startTime; // Validate response (check for type first, then content) if (typeof response !== 'string') { throw new Error('Delegate agent returned invalid response (not a string)'); } const trimmedResponse = response.trim(); if (trimmedResponse.length === 0) { throw new Error('Delegate agent returned empty or whitespace-only response'); } // Check for null bytes (edge case) if (trimmedResponse.includes('\0')) { throw new Error('Delegate agent returned response containing null bytes'); } if (debug) { console.error(`[DELEGATE] Task completed successfully for session ${sessionId}`); console.error(`[DELEGATE] Duration: ${(duration / 1000).toFixed(2)}s`); console.error(`[DELEGATE] Response length: ${response.length} chars`); } // Record successful completion in telemetry if (tracer) { tracer.recordDelegationEvent('completed', { 'delegation.session_id': sessionId, 'delegation.parent_session_id': parentSessionId, 'delegation.duration_ms': duration, 'delegation.response_length': response.length, 'delegation.success': true }); if (delegationSpan) { delegationSpan.setAttributes({ 'delegation.result.success': true, 'delegation.result.response_length': response.length, 'delegation.result.duration_ms': duration }); delegationSpan.setStatus({ code: 1 }); // OK delegationSpan.end(); } } // Release delegation slot if (acquired) { delegationManager.release(parentSessionId, debug); } return response; } catch (error) { // Clear timeout if still active if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; } const duration = Date.now() - startTime; // Release delegation slot on error (only if it was acquired) if (acquired) { delegationManager.release(parentSessionId, debug); } if (debug) { console.error(`[DELEGATE] Task failed for session ${sessionId} after ${duration}ms`); console.error(`[DELEGATE] Error: ${error.message}`); console.error(`[DELEGATE] Stack: ${error.stack}`); } // Record failure in telemetry if (tracer) { tracer.recordDelegationEvent('failed', { 'delegation.session_id': sessionId, 'delegation.parent_session_id': parentSessionId, 'delegation.duration_ms': duration, 'delegation.error_message': error.message, 'delegation.success': false }); if (delegationSpan) { delegationSpan.setAttributes({ 'delegation.result.success': false, 'delegation.result.error': error.message, 'delegation.result.duration_ms': duration }); delegationSpan.setStatus({ code: 2, message: error.message }); // ERROR delegationSpan.end(); } } throw new Error(`Delegation failed: ${error.message}`); } } /** * Check if delegate functionality is available * * @returns {Promise<boolean>} True if delegate is available */ export async function isDelegateAvailable() { // Delegate is always available when using SDK-based approach return true; } /** * Get delegation statistics (for monitoring/debugging) * * @returns {Object} Current delegation stats */ export function getDelegationStats() { return delegationManager.getStats(); } /** * Cleanup delegation manager (for testing or shutdown) */ export async function cleanupDelegationManager() { return delegationManager.cleanup(); }