UNPKG

mnemos-coder

Version:

CLI-based coding agent with graph-based execution loop and terminal UI

723 lines (722 loc) 32.4 kB
/** * Main Agent class - TypeScript version of seahorse agent.py * Implements the same processing pattern with tool calling support */ import { LLMClient } from './llm-client.js'; import { MCPClient } from './mcp-client-sdk.js'; import { GlobalConfig } from './config/GlobalConfig.js'; import { SubagentManager } from './subagents/SubagentManager.js'; import { ContextMemoryManager } from './context/ContextMemoryManager.js'; import { EnhancedContextManager } from './context/EnhancedContextManager.js'; import { AutoContextCollector } from './context/AutoContextCollector.js'; import { validateTaskArguments } from './tools/TaskToolSpec.js'; import { getLogger } from './utils/Logger.js'; export class SeahorseAgent { llmClient; mcpClient; subagentManager; contextMemory; enhancedContext; autoContext; maxIterations; systemPrompt; sessionId; projectRoot; logger; constructor(sessionId, projectRoot) { // Initialize with GlobalConfig settings const globalConfig = GlobalConfig.getInstance(); const llmConfig = globalConfig.getLLMConfig(); const agentConfig = globalConfig.getAgentConfig(); this.llmClient = new LLMClient(llmConfig); this.mcpClient = new MCPClient(); this.projectRoot = projectRoot || process.cwd(); this.autoContext = new AutoContextCollector(this.projectRoot); this.subagentManager = new SubagentManager(this.mcpClient); this.maxIterations = agentConfig.max_iterations; this.systemPrompt = agentConfig.system_prompt; this.sessionId = sessionId || this.generateSessionId(); // Initialize logger this.logger = getLogger('Agent', this.sessionId); this.logger.info('Agent initialized', { sessionId: this.sessionId, projectRoot: this.projectRoot, maxIterations: this.maxIterations }); // Initialize context memory manager this.contextMemory = new ContextMemoryManager(projectRoot || process.cwd(), this.sessionId); // Initialize enhanced context manager this.enhancedContext = new EnhancedContextManager(this.projectRoot, this.sessionId); } generateSessionId() { return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } async initialize() { // Get MCP servers configuration from GlobalConfig const globalConfig = GlobalConfig.getInstance(); const mcpServers = globalConfig.getMCPServersConfig(); this.logger.info(`Initializing ${Object.keys(mcpServers).length} MCP servers in parallel`); // Start MCP servers in parallel instead of sequentially const serverPromises = Object.entries(mcpServers).map(async ([name, serverConfig]) => { try { // Only support stdio transport for SDK client if (serverConfig.transport === 'stdio') { await this.mcpClient.startServer(name, { transport: 'stdio', command: serverConfig.command, args: serverConfig.args, env: serverConfig.env }); this.logger.debug(`MCP server ${name} initialized successfully`); return { name, success: true }; } else { this.logger.warn(`MCP server ${name} uses ${serverConfig.transport} transport, skipping (SDK only supports stdio)`); return { name, success: false, error: `Unsupported transport: ${serverConfig.transport}` }; } } catch (error) { this.logger.error(`Failed to start MCP server ${name}`, error); return { name, success: false, error }; } }); // Wait for all servers to initialize const results = await Promise.allSettled(serverPromises); const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length; this.logger.info(`MCP servers ready: ${successful}/${Object.keys(mcpServers).length}`); // Initialize subagent manager (no artificial delay needed) await this.subagentManager.initialize(); // Initialize enhanced context manager await this.enhancedContext.initialize(); } async *processQuery(query, stream = false) { try { // Add user message to conversation history await this.contextMemory.addUserMessage(query); // Collect automatic context const autoContext = await this.autoContext.collectContext(); // Check connected servers const connectedServers = this.mcpClient.getConnectedServers(); if (connectedServers.length > 0) { yield { type: 'status', content: `Connected MCP servers: ${connectedServers.join(', ')}` }; // Get capabilities for each server for (const serverName of connectedServers) { try { const capabilities = await this.mcpClient.getServerCapabilities(serverName); const toolCount = capabilities.tools.length; yield { type: 'status', content: `${serverName}: ${toolCount} tools available (${capabilities.tools.map(t => t.name).join(', ')})` }; } catch (error) { this.logger.warn(`Failed to get capabilities for ${serverName}`, error); } } } else { yield { type: 'status', content: 'No MCP servers connected. Running in basic LLM mode.' }; } // Test LLM connection first yield { type: 'status', content: 'Testing LLM connection...' }; // Handle /subagent command if (query.startsWith('/subagent ')) { const subagentCommand = query.substring('/subagent '.length); const parts = subagentCommand.match(/^(\S+)\s+(.+)$/); if (parts) { const [, subagentName, taskDescription] = parts; yield { type: 'status', content: `Delegating to ${subagentName} subagent...` }; // Create context for subagent const context = { sessionId: this.sessionId, taskDescription: taskDescription, availableTools: [], workingDirectory: process.cwd(), mcpClient: this.mcpClient }; // Execute with specific subagent for await (const response of this.subagentManager.executeWithSubagent(subagentName, taskDescription, context)) { yield response; } return; } } // For Java file creation request, provide immediate help if (query.toLowerCase().includes('java') && query.toLowerCase().includes('hello world')) { yield { type: 'llm_response', content: `I'll help you create a Java "Hello World" file! Let me use the file creation tool to make this for you. Here's what I'll create:` }; // Create Java file using read server tool yield { type: 'tool_call', content: { name: 'file-server_write_file', arguments: { filePath: 'HelloWorld.java', content: `public class HelloWorld { public static void main(String[] args) { System.out.println("Hello, World!"); } }` } } }; // Actually execute the tool try { const toolResult = await this.executeToolCall({ name: 'file-server_write_file', arguments: { filePath: 'HelloWorld.java', content: `public class HelloWorld { public static void main(String[] args) { System.out.println("Hello, World!"); } }` } }); yield { type: 'tool_response', content: { name: 'file-server_write_file', result: toolResult.result, success: toolResult.success } }; } catch (error) { yield { type: 'error', content: `Failed to create Java file: ${error instanceof Error ? error.message : String(error)}` }; } return; } // Process through simplified pattern // LLM will decide whether to use Task tool for delegation yield* this.simplifiedProcess(query, stream); } catch (error) { yield { type: 'error', content: `Agent processing error: ${error instanceof Error ? error.message : String(error)}` }; } } async *simplifiedProcess(query, stream) { // Collect available tools const availableTools = {}; const connectedServers = this.mcpClient.getConnectedServers(); if (connectedServers.length > 0) { try { const toolsData = await this.mcpClient.getAllTools(); for (const [serverName, toolsList] of Object.entries(toolsData)) { for (const tool of toolsList) { const toolKey = `${serverName}_${tool.name}`; availableTools[toolKey] = { server: serverName, name: tool.name, description: tool.description, parameters: tool.inputSchema }; } } } catch (error) { console.error('Failed to collect tool information:', error); } } // Build system prompt const systemPrompt = this.buildSystemPrompt(availableTools); // Get conversation history from memory const contextWindow = await this.contextMemory.getContextWindow(); const messages = [{ role: 'system', content: systemPrompt }]; // Add recent conversation history if available if (contextWindow.messages.length > 0) { // Convert conversation history to ChatMessage format for (const turn of contextWindow.messages) { if (turn.role === 'tool') { // Format tool calls as user messages showing the result const toolMessage = `Tool: ${turn.toolName}\nResult: ${JSON.stringify(turn.toolResult, null, 2)}`; messages.push({ role: 'user', content: toolMessage }); } else if (turn.role === 'user' || turn.role === 'assistant') { messages.push({ role: turn.role, content: turn.content }); } } } // Add current query messages.push({ role: 'user', content: query }); let iteration = 0; let consecutiveNoToolCalls = 0; while (iteration < this.maxIterations) { iteration++; try { let llmResponse = ''; // LLM call and response processing if (stream) { // Streaming mode - yield chunks as they arrive try { for await (const chunk of this.llmClient.streamChat(messages)) { if (chunk.type === 'content') { llmResponse += chunk.content; // Yield each chunk for real-time display yield { type: 'llm_response', content: chunk.content, iteration }; } } } catch (streamError) { console.warn('Streaming failed, falling back to non-streaming:', streamError); // Fallback to non-streaming llmResponse = await this.llmClient.chat(messages); yield { type: 'llm_response', content: llmResponse, iteration }; } } else { // Non-streaming mode - yield complete response llmResponse = await this.llmClient.chat(messages); yield { type: 'llm_response', content: llmResponse, iteration }; } // Add response to message history messages.push({ role: 'assistant', content: llmResponse }); // Save assistant response to conversation memory await this.contextMemory.addAssistantMessage(llmResponse); // Parse tool calls const parseResult = this.parseToolCalls(llmResponse); const { toolCalls, errors } = parseResult; // If there were parsing errors, inform the LLM if (errors.length > 0) { const errorMessage = `Tool call parsing errors occurred:\n${errors.map(e => `- ${e}`).join('\n')}\n\nPlease check your tool call format and try again. Use this format:\n<tool_call>\n{\n "name": "tool_name",\n "arguments": {\n "param": "value"\n }\n}\n</tool_call>`; messages.push({ role: 'user', content: errorMessage }); yield { type: 'error', content: `Tool call parsing failed: ${errors.length} error(s). LLM has been notified.` }; // Continue to next iteration to let LLM correct the format continue; } if (toolCalls.length === 0) { // No tool calls - conversation complete consecutiveNoToolCalls++; if (consecutiveNoToolCalls >= 2) { // If no tool calls for 2 consecutive responses, end conversation break; } // Continue for one more iteration to see if LLM provides final response continue; } else { consecutiveNoToolCalls = 0; // Reset counter } // Execute tool calls if (connectedServers.length === 0) { yield { type: 'error', content: 'Tool call requested but no MCP servers are connected.' }; break; } // Execute each tool call const toolResults = []; for (const toolCall of toolCalls) { // Display tool call yield { type: 'tool_call', content: { name: toolCall.name, arguments: toolCall.arguments } }; // Execute tool const result = await this.executeToolCall(toolCall); toolResults.push(result); // Store tool call and result in conversation history await this.contextMemory.addToolCall(toolCall.name, toolCall.arguments, result.result || result.error, { success: result.success }); // Display tool response yield { type: 'tool_response', content: { name: toolCall.name, result: result.success ? result.result : { error: result.error }, success: result.success } }; } // Add tool results to next LLM call const toolSummary = this.formatToolResults(toolCalls, toolResults); const nextMessage = `Tool execution results:\n${toolSummary}\n`; messages.push({ role: 'user', content: nextMessage }); // Continue to next iteration } catch (error) { yield { type: 'error', content: `Processing error (iteration ${iteration}): ${error instanceof Error ? error.message : String(error)}` }; break; } } if (iteration >= this.maxIterations) { yield { type: 'status', content: `Reached ${this.maxIterations} iterations. Task may be complex - continuing if needed.` }; } } buildSystemPrompt(availableTools) { let systemPrompt = this.systemPrompt; // Use SubagentRegistry for dynamic system prompt and tool specs const systemPromptFragment = this.subagentManager.getSystemPromptFragment(); const taskToolSpec = this.subagentManager.getTaskToolSpec(); // Build enhanced tools with both Task tool and direct MCP tools const enhancedTools = {}; // Add Task tool for delegation if (taskToolSpec) { enhancedTools.Task = { description: taskToolSpec.description, parameters: taskToolSpec.parameters }; } // Add available MCP tools from connected servers for (const [toolKey, toolInfo] of Object.entries(availableTools)) { enhancedTools[toolKey] = toolInfo; } // Use registry-generated system prompt fragment const delegationGuidance = systemPromptFragment; if (Object.keys(enhancedTools).length > 0) { let toolsInfo = delegationGuidance + '\n\nAvailable tools:\n\n'; for (const [toolKey, toolInfo] of Object.entries(enhancedTools)) { toolsInfo += `**${toolKey}**\n`; toolsInfo += `- Description: ${toolInfo.description}\n`; const parameters = toolInfo.parameters; if (parameters && parameters.properties) { toolsInfo += '- Parameters:\n'; const properties = parameters.properties; const required = parameters.required || []; for (const [paramName, paramInfo] of Object.entries(properties)) { const paramType = paramInfo.type || 'string'; const paramDesc = paramInfo.description || ''; const isRequired = required.includes(paramName); const requiredText = isRequired ? ' (required)' : ' (optional)'; toolsInfo += ` - ${paramName} (${paramType}${requiredText}): ${paramDesc}\n`; } } toolsInfo += '\n'; } toolsInfo += 'Use the following format for tool calls:\n\n'; toolsInfo += '<tool_call>\n'; toolsInfo += '{\n'; toolsInfo += ' "name": "tool_name",\n'; toolsInfo += ' "arguments": {\n'; toolsInfo += ' "param1": "value1",\n'; toolsInfo += ' "param2": "value2"\n'; toolsInfo += ' }\n'; toolsInfo += '}\n'; toolsInfo += '</tool_call>\n\n'; systemPrompt += toolsInfo; } else { systemPrompt += delegationGuidance + '\n\nNo tools are currently available.\nAnswer user questions using general knowledge and reasoning.\n\n'; } return systemPrompt; } parseToolCalls(text) { const toolCalls = []; const errors = []; // Find <tool_call> XML tags const toolCallPattern = /<tool_call>\s*(.*?)\s*<\/tool_call>/gs; let match; while ((match = toolCallPattern.exec(text)) !== null) { try { // Parse JSON inside XML tags const jsonObj = JSON.parse(match[1].trim()); if (jsonObj.name) { toolCalls.push({ name: jsonObj.name, arguments: jsonObj.arguments || {} }); } else { errors.push(`Tool call missing 'name' field: ${match[1].trim()}`); } } catch (error) { const errorMsg = `Tool call JSON parsing failed: ${error instanceof Error ? error.message : String(error)}. Raw content: ${match[1].trim()}`; console.warn(errorMsg); errors.push(errorMsg); } } return { toolCalls, errors }; } async executeToolCall(toolCall) { this.logger.functionStart('executeToolCall', toolCall); try { const toolName = toolCall.name; const arguments_ = toolCall.arguments; // Handle Task tool for subagent delegation if (toolName === 'Task') { this.logger.info('Executing Task tool for delegation', arguments_); return await this.executeTaskTool(arguments_); } // Allow direct tool calls for basic operations // The main agent can now use both Task tool and direct MCP tools // Determine server name and actual tool name const connectedServers = this.mcpClient.getConnectedServers(); let serverName = null; let actualToolName = toolName; // Find server by tool name prefix for (const connectedServer of connectedServers) { if (toolName.startsWith(connectedServer + '_')) { serverName = connectedServer; actualToolName = toolName.slice(connectedServer.length + 1); break; } } if (!serverName) { // Use first connected server as default if (connectedServers.length === 0) { throw new Error('No connected MCP servers'); } serverName = connectedServers[0]; actualToolName = toolName; } // Check server connection if (!serverName || !this.mcpClient.isServerConnected(serverName)) { throw new Error(`MCP server ${serverName || 'unknown'} is not connected`); } // Inject session ID for todos server calls let finalArguments = arguments_; if (serverName === 'todos') { finalArguments = { ...arguments_, session_id: this.sessionId }; } // Call tool const result = await this.mcpClient.callTool(serverName, actualToolName, finalArguments); // Determine success based on MCP structure const isSuccess = !result.isError; return { server_name: serverName || undefined, tool_name: actualToolName, arguments: arguments_, result: result.content || result, success: isSuccess, error: result.error }; } catch (error) { return { tool_name: toolCall.name, arguments: toolCall.arguments, error: error instanceof Error ? error.message : String(error), success: false }; } } /** * Execute Task tool for subagent delegation */ async executeTaskTool(arguments_) { this.logger.functionStart('executeTaskTool', arguments_); try { // Validate arguments if (!validateTaskArguments(arguments_)) { this.logger.error('Invalid Task tool arguments', arguments_); throw new Error('Invalid Task tool arguments'); } const { subagent_type, prompt, context: additionalContext } = arguments_; this.logger.info('Delegating to subagent', { subagent_type, prompt, hasAdditionalContext: !!additionalContext }); // Create subagent context with shared MCP client const context = { sessionId: this.sessionId, taskDescription: prompt, availableTools: [], // Managed by SubagentManager workingDirectory: process.cwd(), mcpClient: this.mcpClient, // Pass the shared MCP client ...additionalContext }; this.logger.info('Created subagent context', { sessionId: context.sessionId, workingDirectory: context.workingDirectory, hasMCPClient: !!context.mcpClient, mcpConnectedServers: this.mcpClient.getConnectedServers() }); // Collect subagent responses const allResponses = []; const allLLMResponses = []; let finalResponse = ''; let hasError = false; // Execute subagent this.logger.info('Starting subagent execution', { subagent_type }); for await (const subagentResponse of this.subagentManager.executeWithSubagent(subagent_type, prompt, context)) { this.logger.debug('Received subagent response', { type: subagentResponse.type, hasContent: !!subagentResponse.content }); allResponses.push(subagentResponse); // Collect all LLM responses for potential fallback if (subagentResponse.type === 'llm_response') { const responseText = typeof subagentResponse.content === 'string' ? subagentResponse.content : JSON.stringify(subagentResponse.content); allLLMResponses.push(responseText); } // Track errors if (subagentResponse.type === 'error') { hasError = true; finalResponse = `Error: ${subagentResponse.content}`; } } // Extract final response with priority: task_tool_response tags > last LLM response if (!finalResponse && !hasError) { // First, try to extract content from <task_tool_response> tags from all LLM responses let taskToolResponse = ''; for (const llmResponse of allLLMResponses) { const tagMatch = llmResponse.match(/<task_tool_response>\s*([\s\S]*?)\s*<\/task_tool_response>/); if (tagMatch && tagMatch[1].trim()) { taskToolResponse = tagMatch[1].trim(); // Use the last found task_tool_response (most recent) } } if (taskToolResponse) { finalResponse = taskToolResponse; this.logger.info('Extracted response from <task_tool_response> tags'); } else { // Fallback: use last LLM response with tool calls removed if (allLLMResponses.length > 0) { const lastResponse = allLLMResponses[allLLMResponses.length - 1]; const cleanedResponse = lastResponse.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '').trim(); finalResponse = cleanedResponse || 'Task completed successfully'; this.logger.info('Using fallback: last LLM response with tool calls removed'); } else { // Last resort: check for completion response const completionResponse = allResponses.find(r => r.type === 'completion'); finalResponse = completionResponse?.content || 'Task completed successfully'; this.logger.info('Using completion response as fallback'); } } } return { tool_name: 'Task', arguments: arguments_, result: finalResponse || 'Subagent completed the task', success: !hasError }; } catch (error) { return { tool_name: 'Task', arguments: arguments_, error: error instanceof Error ? error.message : String(error), success: false }; } } formatToolResults(toolCalls, toolResults) { const formattedResults = []; for (let i = 0; i < toolCalls.length; i++) { const toolCall = toolCalls[i]; const result = toolResults[i]; if (result.success) { const content = typeof result.result === 'object' ? JSON.stringify(result.result, null, 2) : String(result.result || ''); formattedResults.push(`[${toolCall.name}]\n${content}`); } else { formattedResults.push(`[${toolCall.name}]\nError: ${result.error || 'unknown error'}`); } } return formattedResults.join('\n\n'); } /** * 사용 가능한 subagent 목록 반환 */ getAvailableSubagents() { return this.subagentManager.listAvailableSubagents(); } /** * 특정 subagent로 직접 실행 (CLI나 테스트용) */ async *executeWithSubagent(subagentName, query) { const context = { sessionId: this.sessionId, taskDescription: query, availableTools: [], workingDirectory: process.cwd() }; for await (const subagentResponse of this.subagentManager.executeWithSubagent(subagentName, query, context)) { // Convert subagent response to agent response yield { type: subagentResponse.type, content: subagentResponse.content, subagentName: subagentResponse.subagentName }; } } async shutdown() { await this.mcpClient.shutdown(); await this.subagentManager.shutdown(); } /** * Get MCP client for direct access */ getMcpClient() { return this.mcpClient; } /** * Update session ID (for session switching) */ async setSessionId(sessionId) { this.sessionId = sessionId; await this.contextMemory.switchSession(sessionId); } /** * Get current session ID */ getSessionId() { return this.sessionId; } /** * Get conversation history for display */ async getConversationHistory(limit = 10) { return this.contextMemory.getRecentHistory(limit); } /** * Clear current session history */ async clearSession() { await this.contextMemory.clearSession(); } } //# sourceMappingURL=agent.js.map