UNPKG

@iflow-mcp/ejmockler-brutalist

Version:

Deploy Claude, Codex & Gemini CLI agents to demolish your work before users do. Real file analysis. Brutal honesty. Now with conversation continuation & intelligent pagination.

308 lines 18.5 kB
import { logger } from '../logger.js'; import { extractPaginationParams, parseCursor } from '../utils/pagination.js'; import { getSystemPrompt } from '../system-prompts.js'; /** * ToolHandler - Handles roast tool execution with caching and pagination * Extracted from BrutalistServer to follow Single Responsibility Principle */ export class ToolHandler { cliOrchestrator; responseCache; formatter; config; activeSessions; handleStreamingEvent; handleProgressUpdate; ensureSessionCapacity; constructor(cliOrchestrator, responseCache, formatter, config, activeSessions, handleStreamingEvent, handleProgressUpdate, ensureSessionCapacity) { this.cliOrchestrator = cliOrchestrator; this.responseCache = responseCache; this.formatter = formatter; this.config = config; this.activeSessions = activeSessions; this.handleStreamingEvent = handleStreamingEvent; this.handleProgressUpdate = handleProgressUpdate; this.ensureSessionCapacity = ensureSessionCapacity; } /** * Unified handler for all roast tools - DRY principle */ async handleRoastTool(config, args, extra) { try { // CRITICAL: Prevent recursion - reject tool calls from brutalist-spawned subprocesses if (process.env.BRUTALIST_SUBPROCESS === '1') { logger.warn(`🚫 Rejecting tool call from brutalist subprocess (recursion prevented)`); return { content: [{ type: "text", text: `ERROR: Brutalist MCP tools cannot be used from within a brutalist-spawned CLI subprocess (recursion prevented)` }] }; } const progressToken = extra._meta?.progressToken; // Extract session context for security // IMPORTANT: Use consistent "anonymous" for all anonymous users to enable cache sharing const sessionId = extra?.sessionId || extra?._meta?.sessionId || extra?.headers?.['mcp-session-id'] || 'anonymous'; // Consistent for cache sharing across pagination requests const requestId = `${sessionId}-${Date.now()}-${Math.random().toString(36).substring(7)}`; logger.debug(`🔐 Processing request with session: ${sessionId.substring(0, 8)}..., request: ${requestId.substring(0, 12)}...`); // Track session activity if (!this.activeSessions.has(sessionId)) { this.ensureSessionCapacity(); // Ensure capacity before adding new session this.activeSessions.set(sessionId, { startTime: Date.now(), requestCount: 0, lastActivity: Date.now() }); } const sessionInfo = this.activeSessions.get(sessionId); sessionInfo.requestCount++; sessionInfo.lastActivity = Date.now(); logger.debug(`Tool execution: ${config.name}, primaryArgField=${config.primaryArgField}`); logger.debug(`Args: ${JSON.stringify(args, null, 2)}`); // Extract pagination parameters const paginationParams = extractPaginationParams(args); if (args.cursor) { const cursorParams = parseCursor(args.cursor); Object.assign(paginationParams, cursorParams); } // Determine if pagination was explicitly requested by the user const explicitPaginationRequested = args.offset !== undefined || args.limit !== undefined || args.cursor !== undefined || args.context_id !== undefined; logger.info(`🔧 DEBUG: explicitPaginationRequested=${explicitPaginationRequested}, offset=${args.offset}, limit=${args.limit}, cursor=${args.cursor}, context_id=${args.context_id}, resume=${args.resume}`); // Validate resume flag requires context_id if (args.resume && !args.context_id) { throw new Error(`The 'resume' flag requires a 'context_id' from a previous response. ` + `Run an initial analysis first, then use the returned context_id with resume: true.`); } // Check cache if context_id provided // Two modes: PAGINATION (context_id alone) vs CONTINUATION (context_id + resume: true) let conversationHistory; let resumeFollowUpQuestion; // Store follow-up for conversation history let resumeOriginalParams; // Original params for filesystem tools if (args.context_id && !args.force_refresh) { const cachedResponse = await this.responseCache.getByContextId(args.context_id, sessionId); if (cachedResponse) { logger.info(`🎯 Cache HIT for context_id: ${args.context_id}`); if (args.resume === true) { // CONVERSATION CONTINUATION: User explicitly wants to continue with history injection const textContent = args.content || args.idea || args.architecture || args.research || args.product || args.security || args.infrastructure; const primaryArg = textContent || args[config.primaryArgField]; if (!primaryArg || primaryArg.trim() === '') { throw new Error(`Conversation continuation (resume: true) requires new content/prompt. ` + `Provide your follow-up question or comment in the content field.`); } // Store the follow-up question for conversation history resumeFollowUpQuestion = primaryArg; // Store original request params (for filesystem tools that need original targetPath) resumeOriginalParams = cachedResponse.requestParams; logger.info(`💬 Conversation continuation - new prompt: "${primaryArg.substring(0, 50)}..."`); conversationHistory = cachedResponse.conversationHistory || []; // Fall through to execute new analysis with history } else { // PAGINATION: Just retrieving previous response (no resume flag) logger.info(`📖 Pagination request - returning cached response`); const cachedResult = { success: true, responses: [{ agent: 'cached', success: true, output: cachedResponse.content, executionTime: 0 }] }; return this.formatter.formatToolResponse(cachedResult, args.verbose, paginationParams, args.context_id, explicitPaginationRequested); } } else { logger.warn(`❌ Cache MISS for context_id: ${args.context_id}, session: ${sessionId}`); throw new Error(`Context ID "${args.context_id}" not found in cache. ` + `It may have expired (2 hour TTL) or belong to a different session. ` + `Remove context_id parameter to run a new analysis.`); } } // Generate cache key for this request const cacheKey = this.responseCache.generateCacheKey(config.cacheKeyFields.reduce((acc, field) => { acc.tool = config.name; if (args[field] !== undefined) acc[field] = args[field]; return acc; }, {})); // Check if we have a cached result (unless forcing refresh) if (!args.force_refresh) { const cachedContent = await this.responseCache.get(cacheKey, sessionId); if (cachedContent) { // Get existing context_id or create new alias const existingContextId = this.responseCache.findContextIdForKey(cacheKey); const contextId = existingContextId ? this.responseCache.createAlias(existingContextId, cacheKey) : this.responseCache.generateContextId(cacheKey); logger.info(`🎯 Cache hit for new request, using context_id: ${contextId}`); const cachedResult = { success: true, responses: [{ agent: 'cached', success: true, output: cachedContent, executionTime: 0 }] }; return this.formatter.formatToolResponse(cachedResult, args.verbose, paginationParams, contextId, explicitPaginationRequested); } } // Build context with custom builder if available let context = config.contextBuilder ? config.contextBuilder(args) : args.context; // Get the primary argument (targetPath or content) // All abstract tools now use 'content', filesystem tools use 'targetPath' let primaryArg = args[config.primaryArgField]; // For resume mode with filesystem tools, use original targetPath from cached params // and inject the follow-up question into context instead const filesystemTools = ['codebase', 'fileStructure', 'dependencies', 'gitHistory', 'testCoverage']; if (resumeOriginalParams && filesystemTools.includes(config.analysisType)) { // Use original targetPath for the CLI execution (needed for path validation) const originalTargetPath = resumeOriginalParams.targetPath; if (originalTargetPath) { logger.info(`🔄 Resume mode: Using original targetPath="${originalTargetPath}" for filesystem tool`); primaryArg = originalTargetPath; // Also restore workingDirectory if available if (resumeOriginalParams.workingDirectory) { args.workingDirectory = resumeOriginalParams.workingDirectory; } } } // Validate that primary argument is provided if (!primaryArg) { throw new Error(`Missing required argument: ${config.primaryArgField}`); } // Type narrowing: primaryArg is now guaranteed to be a string const validatedPrimaryArg = primaryArg; // If we have conversation history, inject it into the context if (conversationHistory && conversationHistory.length > 0) { const conversationContext = conversationHistory.map(msg => { const role = msg.role === 'user' ? 'User' : 'Assistant'; return `${role}: ${msg.content}`; }).join('\n\n---\n\n'); // For resume mode, inject the follow-up question into the context const followUpContent = resumeFollowUpQuestion || ''; const contextPrefix = `## Previous Conversation\n\n${conversationContext}\n\n---\n\n## New User Prompt\n\n${followUpContent}\n\n`; context = contextPrefix + (context || ''); logger.info(`💬 Injected ${conversationHistory.length} previous messages into context`); } logger.debug(`Primary arg: ${config.primaryArgField}="${validatedPrimaryArg}", analysisType="${config.analysisType}"`); // Get system prompt (from deprecated field or system-prompts.ts) const systemPrompt = config.systemPrompt || getSystemPrompt(config.analysisType, args.mcp_servers, args.url); // Run the analysis const result = await this.executeBrutalistAnalysis(config.analysisType, validatedPrimaryArg, systemPrompt, context, args.workingDirectory, args.clis, args.verbose, args.models, progressToken, sessionId, requestId, args.mcp_servers); // Cache the result if successful let contextId; if (result.success && result.responses.length > 0) { const fullContent = this.formatter.extractFullContent(result); if (fullContent) { const cacheData = config.cacheKeyFields.reduce((acc, field) => { acc.tool = config.name; if (args[field] !== undefined) acc[field] = args[field]; return acc; }, {}); // Build updated conversation history // For resume mode, use the follow-up question; otherwise use primaryArg const now = Date.now(); const userMessageContent = resumeFollowUpQuestion || primaryArg; const updatedConversation = [ ...(conversationHistory || []), { role: 'user', content: userMessageContent, timestamp: now }, { role: 'assistant', content: fullContent, timestamp: now } ]; // If continuing a conversation (resume: true), update existing context_id if (args.resume && args.context_id && conversationHistory) { // Update existing cache entry with extended conversation contextId = args.context_id; await this.responseCache.updateByContextId(contextId, fullContent, updatedConversation, sessionId || 'anonymous'); logger.info(`✅ Updated conversation ${contextId} (now ${updatedConversation.length} messages)`); } else { // New conversation - create new context_id const { contextId: newId } = await this.responseCache.set(cacheData, fullContent, cacheKey, sessionId, requestId, updatedConversation); contextId = newId; logger.info(`✅ Cached new conversation with context ID: ${contextId} for session: ${sessionId?.substring(0, 8)}`); } } } return this.formatter.formatToolResponse(result, args.verbose, paginationParams, contextId, explicitPaginationRequested); } catch (error) { return this.formatter.formatErrorResponse(error); } } /** * Execute brutalist analysis with CLI orchestrator */ async executeBrutalistAnalysis(analysisType, primaryContent, systemPromptSpec, context, workingDirectory, clis, verbose, models, progressToken, sessionId, requestId, mcpServers) { logger.info(`🏢 Starting brutalist analysis: ${analysisType}`); logger.info(`🔧 DEBUG: clis=${clis?.join(',') || 'all'}, primaryContent=${primaryContent}`); logger.debug("Executing brutalist analysis", { primaryContent, analysisType, systemPromptSpec, workingDirectory, clis }); try { // Get CLI context for execution summary logger.info(`🔧 DEBUG: About to detect CLI context`); await this.cliOrchestrator.detectCLIContext(); logger.info(`🔧 DEBUG: CLI context detected successfully`); // Execute CLI agent analysis (single or multi-CLI based on preferences) logger.info(`🔍 Executing brutalist analysis with timeout: ${this.config.defaultTimeout}ms`); logger.info(`🔧 DEBUG: About to call cliOrchestrator.executeBrutalistAnalysis`); const responses = await this.cliOrchestrator.executeBrutalistAnalysis(analysisType, primaryContent, systemPromptSpec, context, { workingDirectory: workingDirectory || this.config.workingDirectory, timeout: this.config.defaultTimeout, clis, analysisType: analysisType, models, onStreamingEvent: this.handleStreamingEvent, progressToken, onProgress: progressToken && sessionId ? (progress, total, message) => this.handleProgressUpdate(progressToken, progress, total, message, sessionId) : undefined, sessionId, requestId, mcpServers }); logger.info(`🔧 DEBUG: cliOrchestrator.executeBrutalistAnalysis returned ${responses.length} responses`); const successfulResponses = responses.filter(r => r.success); const totalExecutionTime = responses.reduce((sum, r) => sum + r.executionTime, 0); logger.info(`📊 Analysis complete: ${successfulResponses.length}/${responses.length} CLIs successful (${totalExecutionTime}ms total)`); logger.info(`🔧 DEBUG: About to synthesize feedback`); const synthesis = this.cliOrchestrator.synthesizeBrutalistFeedback(responses, analysisType); logger.info(`🔧 DEBUG: Synthesis length: ${synthesis.length} characters`); const result = { success: successfulResponses.length > 0, responses, synthesis, analysisType, targetPath: primaryContent, executionSummary: { totalCLIs: responses.length, successfulCLIs: successfulResponses.length, failedCLIs: responses.length - successfulResponses.length, totalExecutionTime, selectedCLI: responses.length === 1 ? responses[0].agent : undefined, selectionMethod: responses.length === 1 ? responses[0].selectionMethod : 'multi-cli' } }; logger.info(`🔧 DEBUG: Returning result with success=${result.success}`); return result; } catch (error) { logger.error("Brutalist analysis execution failed", error); throw error; } } } //# sourceMappingURL=tool-handler.js.map