UNPKG

@hashgraphonline/conversational-agent

Version:

Hashgraph Online conversational AI agent implementing HCS-10 communication, HCS-2 registries, and content inscription on Hedera

557 lines (475 loc) 16.8 kB
import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { StructuredTool } from '@langchain/core/tools'; import { createOpenAIToolsAgent } from 'langchain/agents'; import { ContentAwareAgentExecutor } from './langchain/ContentAwareAgentExecutor'; import { ChatPromptTemplate, MessagesPlaceholder, } from '@langchain/core/prompts'; import { ChatOpenAI } from '@langchain/openai'; import { calculateTokenCostSync, getAllHederaCorePlugins, HederaAgentKit, TokenUsageCallbackHandler, } from 'hedera-agent-kit'; import type { TokenUsage, CostCalculation } from 'hedera-agent-kit'; import { BaseAgent, type ConversationContext, type ChatResponse, type OperationalMode, type UsageStats, } from './base-agent'; import { MCPClientManager } from './mcp/MCPClientManager'; import { convertMCPToolToLangChain } from './mcp/adapters/langchain'; import { SmartMemoryManager } from './memory/SmartMemoryManager'; import type { MCPConnectionStatus } from './mcp/types'; export class LangChainAgent extends BaseAgent { private executor: ContentAwareAgentExecutor | undefined; private systemMessage = ''; private mcpManager?: MCPClientManager; private smartMemory: SmartMemoryManager | undefined; private mcpConnectionStatus: Map<string, MCPConnectionStatus> = new Map(); async boot(): Promise<void> { if (this.initialized) { this.logger.warn('Agent already initialized'); return; } try { this.agentKit = await this.createAgentKit(); await this.agentKit.initialize(); const modelName = this.config.ai?.modelName || process.env.OPENAI_MODEL_NAME || 'gpt-4o-mini'; this.tokenTracker = new TokenUsageCallbackHandler(modelName); const allTools = this.agentKit.getAggregatedLangChainTools(); this.tools = this.filterTools(allTools); if (this.config.mcp?.servers && this.config.mcp.servers.length > 0) { if (this.config.mcp.autoConnect !== false) { await this.initializeMCP(); } else { this.logger.info( 'MCP servers configured but autoConnect=false, skipping synchronous connection' ); this.mcpManager = new MCPClientManager(this.logger); } } this.smartMemory = new SmartMemoryManager({ modelName, maxTokens: 90000, reserveTokens: 10000, storageLimit: 1000, }); this.logger.info('SmartMemoryManager initialized:', { modelName, toolsCount: this.tools.length, maxTokens: 90000, reserveTokens: 10000, }); this.systemMessage = this.buildSystemPrompt(); this.smartMemory.setSystemPrompt(this.systemMessage); await this.createExecutor(); this.initialized = true; this.logger.info('LangChain Hedera agent initialized'); } catch (error) { this.logger.error('Failed to initialize agent:', error); throw error; } } async chat( message: string, context?: ConversationContext ): Promise<ChatResponse> { if (!this.initialized || !this.executor || !this.smartMemory) { throw new Error('Agent not initialized. Call boot() first.'); } try { this.logger.info('LangChainAgent.chat called with:', { message, contextLength: context?.messages?.length || 0, }); if (context?.messages && context.messages.length > 0) { this.smartMemory.clear(); for (const msg of context.messages) { this.smartMemory.addMessage(msg); } } const { HumanMessage } = await import('@langchain/core/messages'); this.smartMemory.addMessage(new HumanMessage(message)); const memoryStats = this.smartMemory.getMemoryStats(); this.logger.info('Memory stats before execution:', { totalMessages: memoryStats.totalActiveMessages, currentTokens: memoryStats.currentTokenCount, maxTokens: memoryStats.maxTokens, usagePercentage: memoryStats.usagePercentage, toolsCount: this.tools.length, }); const result = await this.executor.invoke({ input: message, chat_history: this.smartMemory.getMessages(), }); this.logger.info('LangChainAgent executor result:', result); let response: ChatResponse = { output: result.output || '', message: result.output || '', notes: [], intermediateSteps: result.intermediateSteps, }; if (result.intermediateSteps && Array.isArray(result.intermediateSteps)) { const toolCalls = result.intermediateSteps.map( (step: any, index: number) => ({ id: `call_${index}`, name: step.action?.tool || 'unknown', args: step.action?.toolInput || {}, output: typeof step.observation === 'string' ? step.observation : JSON.stringify(step.observation), }) ); if (toolCalls.length > 0) { response.tool_calls = toolCalls; } } const parsedSteps = result?.intermediateSteps?.[0]?.observation; if ( parsedSteps && typeof parsedSteps === 'string' && this.isJSON(parsedSteps) ) { try { const parsed = JSON.parse(parsedSteps); response = { ...response, ...parsed }; } catch (error) { this.logger.error('Error parsing intermediate steps:', error); } } if (!response.output || response.output.trim() === '') { response.output = 'Agent action complete.'; } if (response.output) { const { AIMessage } = await import('@langchain/core/messages'); this.smartMemory.addMessage(new AIMessage(response.output)); } if (this.tokenTracker) { const tokenUsage = this.tokenTracker.getLatestTokenUsage(); if (tokenUsage) { response.tokenUsage = tokenUsage; response.cost = calculateTokenCostSync(tokenUsage); } } const finalMemoryStats = this.smartMemory.getMemoryStats(); response.metadata = { ...response.metadata, memoryStats: { activeMessages: finalMemoryStats.totalActiveMessages, tokenUsage: finalMemoryStats.currentTokenCount, maxTokens: finalMemoryStats.maxTokens, usagePercentage: finalMemoryStats.usagePercentage, }, }; this.logger.info('LangChainAgent.chat returning response:', response); return response; } catch (error) { this.logger.error('LangChainAgent.chat error:', error); return this.handleError(error); } } async shutdown(): Promise<void> { if (this.mcpManager) { await this.mcpManager.disconnectAll(); } if (this.smartMemory) { this.smartMemory.dispose(); this.smartMemory = undefined; } this.executor = undefined; this.agentKit = undefined; this.tools = []; this.initialized = false; this.logger.info('Agent cleaned up'); } switchMode(mode: OperationalMode): void { if (this.config.execution) { this.config.execution.operationalMode = mode; } else { this.config.execution = { operationalMode: mode }; } if (this.agentKit) { this.agentKit.operationalMode = mode; } this.systemMessage = this.buildSystemPrompt(); this.logger.info(`Operational mode switched to: ${mode}`); } getUsageStats(): UsageStats { if (!this.tokenTracker) { return { promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: { totalCost: 0 } as CostCalculation, }; } const usage = this.tokenTracker.getTotalTokenUsage(); const cost = calculateTokenCostSync(usage); return { ...usage, cost }; } getUsageLog(): UsageStats[] { if (!this.tokenTracker) { return []; } return this.tokenTracker.getTokenUsageHistory().map((usage) => ({ ...usage, cost: calculateTokenCostSync(usage), })); } clearUsageStats(): void { if (this.tokenTracker) { this.tokenTracker.reset(); this.logger.info('Usage statistics cleared'); } } getMCPConnectionStatus(): Map<string, MCPConnectionStatus> { return new Map(this.mcpConnectionStatus); } private async createAgentKit(): Promise<HederaAgentKit> { const corePlugins = getAllHederaCorePlugins(); const extensionPlugins = this.config.extensions?.plugins || []; const plugins = [...corePlugins, ...extensionPlugins]; const operationalMode = this.config.execution?.operationalMode || 'returnBytes'; const modelName = this.config.ai?.modelName || 'gpt-4o'; return new HederaAgentKit( this.config.signer, { plugins }, operationalMode, this.config.execution?.userAccountId, this.config.execution?.scheduleUserTransactionsInBytesMode ?? false, undefined, modelName, this.config.extensions?.mirrorConfig, this.config.debug?.silent ?? false ); } private async createExecutor(): Promise<void> { let llm: BaseChatModel; if (this.config.ai?.provider && this.config.ai.provider.getModel) { llm = this.config.ai.provider.getModel() as BaseChatModel; } else if (this.config.ai?.llm) { llm = this.config.ai.llm as BaseChatModel; } else { const apiKey = this.config.ai?.apiKey || process.env.OPENAI_API_KEY; if (!apiKey) { throw new Error('OpenAI API key required'); } const modelName = this.config.ai?.modelName || 'gpt-4o-mini'; const isGPT5Model = modelName.toLowerCase().includes('gpt-5') || modelName.toLowerCase().includes('gpt5'); llm = new ChatOpenAI({ apiKey, modelName, callbacks: this.tokenTracker ? [this.tokenTracker] : [], ...(isGPT5Model ? { temperature: 1 } : {}), }); } const prompt = ChatPromptTemplate.fromMessages([ ['system', this.systemMessage], new MessagesPlaceholder('chat_history'), ['human', '{input}'], new MessagesPlaceholder('agent_scratchpad'), ]); const langchainTools = this.tools as unknown as StructuredTool[]; const agent = await createOpenAIToolsAgent({ llm, tools: langchainTools, prompt, }); this.executor = new ContentAwareAgentExecutor({ agent, tools: langchainTools, verbose: this.config.debug?.verbose ?? false, returnIntermediateSteps: true, }); } private handleError(error: unknown): ChatResponse { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; this.logger.error('Chat error:', error); let tokenUsage: TokenUsage | undefined; let cost: CostCalculation | undefined; if (this.tokenTracker) { tokenUsage = this.tokenTracker.getLatestTokenUsage(); if (tokenUsage) { cost = calculateTokenCostSync(tokenUsage); } } let userFriendlyMessage = errorMessage; let userFriendlyOutput = errorMessage; if (errorMessage.includes('429')) { if (errorMessage.includes('quota')) { userFriendlyMessage = 'API quota exceeded. Please check your OpenAI billing and usage limits.'; userFriendlyOutput = "I'm currently unable to respond because the API quota has been exceeded. Please check your OpenAI account billing and usage limits, then try again."; } else { userFriendlyMessage = 'Too many requests. Please wait a moment and try again.'; userFriendlyOutput = "I'm receiving too many requests right now. Please wait a moment and try again."; } } else if ( errorMessage.includes('401') || errorMessage.includes('unauthorized') ) { userFriendlyMessage = 'API authentication failed. Please check your API key configuration.'; userFriendlyOutput = "There's an issue with the API authentication. Please check your OpenAI API key configuration in settings."; } else if (errorMessage.includes('timeout')) { userFriendlyMessage = 'Request timed out. Please try again.'; userFriendlyOutput = 'The request took too long to process. Please try again.'; } else if ( errorMessage.includes('network') || errorMessage.includes('fetch') ) { userFriendlyMessage = 'Network error. Please check your internet connection and try again.'; userFriendlyOutput = 'There was a network error. Please check your internet connection and try again.'; } else if (errorMessage.includes('400')) { userFriendlyMessage = errorMessage; userFriendlyOutput = errorMessage; } const errorResponse: ChatResponse = { output: userFriendlyOutput, message: userFriendlyMessage, error: errorMessage, notes: [], }; if (tokenUsage) { errorResponse.tokenUsage = tokenUsage; } if (cost) { errorResponse.cost = cost; } return errorResponse; } private async initializeMCP(): Promise<void> { this.mcpManager = new MCPClientManager(this.logger); for (const serverConfig of this.config.mcp!.servers!) { if (serverConfig.autoConnect === false) { this.logger.info( `Skipping MCP server ${serverConfig.name} (autoConnect=false)` ); continue; } const status = await this.mcpManager.connectServer(serverConfig); if (status.connected) { this.logger.info( `Connected to MCP server ${status.serverName} with ${status.tools.length} tools` ); for (const mcpTool of status.tools) { const langchainTool = convertMCPToolToLangChain( mcpTool, this.mcpManager, serverConfig ); this.tools.push(langchainTool); } } else { this.logger.error( `Failed to connect to MCP server ${status.serverName}: ${status.error}` ); } } } /** * Connect to MCP servers asynchronously after agent boot with background timeout pattern */ async connectMCPServers(): Promise<void> { if (!this.config.mcp?.servers || this.config.mcp.servers.length === 0) { return; } if (!this.mcpManager) { this.mcpManager = new MCPClientManager(this.logger); } this.logger.info( `Starting background MCP server connections for ${this.config.mcp.servers.length} servers...` ); this.config.mcp.servers.forEach((serverConfig) => { this.connectServerInBackground(serverConfig); }); this.logger.info('MCP server connections initiated in background'); } /** * Connect to a single MCP server in background with timeout */ private connectServerInBackground(serverConfig: any): void { const serverName = serverConfig.name; setTimeout(async () => { try { this.logger.info(`Background connecting to MCP server: ${serverName}`); const status = await this.mcpManager!.connectServer(serverConfig); this.mcpConnectionStatus.set(serverName, status); if (status.connected) { this.logger.info( `Successfully connected to MCP server ${status.serverName} with ${status.tools.length} tools` ); for (const mcpTool of status.tools) { const langchainTool = convertMCPToolToLangChain( mcpTool, this.mcpManager!, serverConfig ); this.tools.push(langchainTool); } if (this.initialized && this.executor) { this.logger.info( `Recreating executor with ${this.tools.length} total tools` ); await this.createExecutor(); } } else { this.logger.error( `Failed to connect to MCP server ${status.serverName}: ${status.error}` ); } } catch (error) { this.logger.error( `Background connection failed for MCP server ${serverName}:`, error ); this.mcpConnectionStatus.set(serverName, { connected: false, serverName, tools: [], error: error instanceof Error ? error.message : 'Connection failed', }); } }, 1000); } /** * Check if a string is valid JSON */ private isJSON(str: string): boolean { if (typeof str !== 'string') return false; const trimmed = str.trim(); if (!trimmed) return false; if ( !(trimmed.startsWith('{') && trimmed.endsWith('}')) && !(trimmed.startsWith('[') && trimmed.endsWith(']')) ) { return false; } try { JSON.parse(trimmed); return true; } catch { return false; } } }