UNPKG

pocketsmith-mcp

Version:

MCP server for managing budgets via PocketSmith API

381 lines (367 loc) 14.9 kB
/** * @fileoverview Defines the core Agent class for the AI agent. * This file contains the main agent logic, including its lifecycle, * interaction with MCP servers, and integration with LLM services. * @module src/agent/agent-core/agent */ import { loadMcpClientConfig } from "../../mcp-client/client-config/configLoader.js"; import { createMcpClientManager, } from "../../mcp-client/index.js"; import { openRouterProvider, } from "../../services/llm-providers/openRouterProvider.js"; import { BaseErrorCode, McpError } from "../../types-global/errors.js"; import { ErrorHandler, jsonParser, logger, requestContextService, } from "../../utils/index.js"; export class Agent { constructor(config) { this.availableTools = new Map(); this.config = config; this.context = requestContextService.createRequestContext({ agentId: this.config.agentId, operation: "Agent.constructor", }); this.mcpClientManager = createMcpClientManager(); logger.info(`Agent ${this.config.agentId} initialized.`, this.context); } async run(initialPrompt, onStreamChunk) { const runContext = requestContextService.createRequestContext({ ...this.context, operation: "Agent.run", }); logger.info(`Agent ${this.config.agentId} starting run with prompt: "${initialPrompt}"`, runContext); try { await this.connectToMcpServers(runContext); this.availableTools = await this.mcpClientManager.getAllTools(runContext); const toolList = JSON.stringify(Array.from(this.availableTools.values()), null, 2); const systemPrompt = `You are an autonomous agent. Your entire response MUST be a single JSON object. This object must have a "command" field and an "arguments" field. The "command" field must be one of the following strings: 1. "mcp_tool_call": To execute a tool. 2. "display_message_to_user": To show a message to the user. 3. "terminate_loop": To end the mission. The "arguments" field must be an object containing the parameters for the command. - For "mcp_tool_call", arguments are: { "name": "<tool_name>", "arguments": { ... } } - For "display_message_to_user", arguments are: { "message": "<text_to_display>" } - For "terminate_loop", arguments are: { "reason": "<final_answer_and_reason>" } <BEGIN_EXAMPLE_LOOP> Example of a tool call: { "command": "mcp_tool_call", "arguments": { "name": "example_tool_name", "arguments": { "param1": "value1" } } } Example of displaying a message: { "command": "display_message_to_user", "arguments": { "message": "I am now starting the research phase to look into <specific topic>." } } Example of terminating the loop: { "command": "terminate_loop", "arguments": { "reason": "I have completed the task. Your final answer is <xyz>; or the file was saved to <path>." } } Example conversation loop: Initial User Prompt: "Review the latest research on quantum computing." You: { "command": "display_message_to_user", "arguments": { "message": "I will now start the research phase to look into the latest research on quantum computing." } } You: { "command": "mcp_tool_call", "arguments": { "name": "research_tool", "arguments": { "query": "latest research on quantum computing" } } } You: { "command": "display_message_to_user", "arguments": { "message": "I have found some interesting papers. Here's a summary: ..." } } You: { "command": "terminate_loop", "arguments": { "reason": "I have completed the task. Your final answer is <concise summary of results>." } } <END_EXAMPLE_LOOP> Here is the list of available tools for the "mcp_tool_call" command: <AVAILABLE_TOOLS> ${toolList} </AVAILABLE_TOOLS> As a reminder: <BEGIN_EXAMPLE_LOOP> Example of a tool call: { "command": "mcp_tool_call", "arguments": { "name": "example_tool_name", "arguments": { "param1": "value1" } } } Example of displaying a message: { "command": "display_message_to_user", "arguments": { "message": "I am now starting the research phase to look into <specific topic>." } } Example of terminating the loop: { "command": "terminate_loop", "arguments": { "reason": "I have completed the task. Your final answer is <xyz>; or the file was saved to <path>." } } Example conversation loop: Initial User Prompt: "Review the latest research on quantum computing." You: { "command": "display_message_to_user", "arguments": { "message": "I will now start the research phase to look into the latest research on quantum computing." } } You: { "command": "mcp_tool_call", "arguments": { "name": "research_tool", "arguments": { "query": "latest research on quantum computing" } } } You: { "command": "display_message_to_user", "arguments": { "message": "I have found some interesting papers. Here's a summary: ..." } } You: { "command": "terminate_loop", "arguments": { "reason": "I have completed the task. Your final answer is <concise summary of results>." } } <END_EXAMPLE_LOOP> Begin the task. Your response must be only the JSON object.`; const messages = [ { role: "system", content: systemPrompt }, { role: "user", content: initialPrompt }, ]; while (true) { const llmParams = { messages, model: "google/gemini-2.5-flash", stream: true, temperature: 0.4, }; const llmResponse = await this.think(llmParams, runContext, onStreamChunk); messages.push({ role: "assistant", content: llmResponse }); let commandJson; try { commandJson = jsonParser.parse(llmResponse); } catch (_e) { logger.warning("LLM response was not valid JSON. Treating as a conversational message.", { ...runContext, llmResponse }); if (onStreamChunk) { onStreamChunk(`\n[AGENT_NOTE]: The AI responded with conversational text instead of a command. I will remind it of the protocol.\n[AI]: ${llmResponse}\n`); } messages.push({ role: "user", content: "Your previous response was not a valid JSON object. Please remember to respond with only a single JSON object with a 'command' and 'arguments' field.", }); continue; // Continue to the next loop iteration to get a new response } const { command, arguments: args } = commandJson; if (command === "mcp_tool_call") { const toolResult = await this._executeToolCall(args, runContext); if (typeof args.name !== "string") { throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Tool call name is missing or not a string.", runContext); } const toolMessage = { role: "tool", tool_call_id: args.name, content: JSON.stringify(toolResult), }; messages.push(toolMessage); } else if (command === "display_message_to_user") { if (onStreamChunk && args.message) { onStreamChunk(`\n[AGENT]: ${args.message}\n`); } messages.push({ role: "user", content: "Message displayed to user. Continue.", }); } else if (command === "terminate_loop") { logger.info("LLM terminated loop.", { ...runContext, reason: args.reason, }); return `Loop terminated by LLM. Reason: ${args.reason}`; } else { throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Unknown command received from LLM: ${command}`, runContext); } } } catch (error) { const handledError = ErrorHandler.handleError(error, { operation: runContext.operation, context: runContext, errorCode: BaseErrorCode.AGENT_EXECUTION_ERROR, critical: true, }); return `Agent run failed: ${handledError.message}`; } finally { logger.info(`Agent ${this.config.agentId} shutting down connections.`, runContext); await this.mcpClientManager.disconnectAllMcpClients(runContext); } } async connectToMcpServers(parentContext) { const context = requestContextService.createRequestContext({ ...parentContext, operation: "Agent.connectToMcpServers", }); const config = loadMcpClientConfig(context); const serverNames = Object.keys(config.mcpServers); const enabledServers = serverNames.filter((name) => !config.mcpServers[name].disabled); logger.info(`Connecting to ${enabledServers.length} enabled servers in parallel.`, context); const connectionPromises = enabledServers.map((serverName) => this.mcpClientManager.connectMcpClient(serverName, context)); const results = await Promise.allSettled(connectionPromises); results.forEach((result, index) => { const serverName = enabledServers[index]; if (result.status === "fulfilled") { logger.info(`Successfully connected to MCP server: ${serverName}`, { ...context, serverName, }); } else { logger.error(`Failed to connect to MCP server: ${serverName}`, { ...context, serverName, error: result.reason?.message, }); } }); const successfulConnections = results.filter((r) => r.status === "fulfilled").length; logger.info(`Finished connection attempts. Successfully connected to ${successfulConnections} out of ${enabledServers.length} servers.`, context); if (successfulConnections > 0) { logger.info("Now waiting for tools to become available...", context); const startTime = Date.now(); const timeout = 10000; // 10 seconds let toolsFound = 0; while (Date.now() - startTime < timeout) { const tools = await this.mcpClientManager.getAllTools(context); toolsFound = tools.size; if (toolsFound > 0) { logger.info(`Confirmed ${toolsFound} tools are available.`, context); return; } await new Promise((resolve) => setTimeout(resolve, 500)); // Poll every 500ms } if (toolsFound === 0) { logger.warning("Timed out waiting for tools to become available. Proceeding with an empty tool list.", context); } } } async _executeToolCall(params, parentContext) { const context = requestContextService.createRequestContext({ ...parentContext, operation: "Agent._executeToolCall", toolCallId: params.name, }); try { const { name: toolName, arguments: args } = params; if (!toolName || typeof toolName !== "string") { throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Malformed tool call: 'name' field is missing or not a string.", context); } const serverName = this.mcpClientManager.getServerForTool(toolName, this.availableTools); if (!serverName) { throw new McpError(BaseErrorCode.NOT_FOUND, `Tool '${toolName}' not found on any connected server.`, context); } logger.info(`Executing tool '${toolName}' on server '${serverName}'`, { ...context, args, }); const client = await this.mcpClientManager.connectMcpClient(serverName, context); if (args !== undefined && (typeof args !== "object" || args === null || Array.isArray(args))) { throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Tool arguments for '${toolName}' must be a plain object or undefined.`, context); } const toolResult = await client.callTool({ name: toolName, arguments: args, }); logger.logInteraction("McpToolResponse", { context, toolName, serverName, result: toolResult, }); return toolResult; } catch (error) { const handledError = ErrorHandler.handleError(error, { operation: context.operation, context, }); const mcpError = handledError instanceof McpError ? handledError : new McpError(BaseErrorCode.AGENT_EXECUTION_ERROR, handledError.message, { cause: handledError }); return { error: { message: mcpError.message, code: mcpError.code, details: mcpError.details, }, }; } } async think(params, parentContext, onStreamChunk) { const context = requestContextService.createRequestContext({ ...parentContext, operation: "Agent.think", }); return await ErrorHandler.tryCatch(async () => { const stream = await openRouterProvider.chatCompletionStream(params, context); let fullResponse = ""; for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content || ""; if (content) { fullResponse += content; // Do not call onStreamChunk here, as we need the full JSON object first } } if (fullResponse) { // The full response is the complete JSON object (or conversational text) if (onStreamChunk) { onStreamChunk(fullResponse); } return fullResponse; } else { throw new McpError(BaseErrorCode.INTERNAL_ERROR, "LLM stream did not produce content.", context); } }, { operation: "Agent.think", context, input: params }); } }