mnemos-coder
Version:
CLI-based coding agent with graph-based execution loop and terminal UI
723 lines (722 loc) • 32.4 kB
JavaScript
/**
* 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