UNPKG

gaunt-sloth-assistant

Version:

[![Tests and Lint](https://github.com/Galvanized-Pukeko/gaunt-sloth-assistant/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/Galvanized-Pukeko/gaunt-sloth-assistant/actions/workflows/unit-tests.yml) [![Integration Tests](https://github.co

273 lines 11.8 kB
import { getDefaultTools } from '#src/builtInToolsConfig.js'; import { displayInfo } from '#src/consoleUtils.js'; import { debugLog, debugLogError, debugLogObject } from '#src/debugUtils.js'; import { createAuthProviderAndAuthenticate } from '#src/mcp/OAuthClientProviderImpl.js'; import { stopWaitingForEscape, waitForEscape } from '#src/systemUtils.js'; import { formatToolCalls, ProgressIndicator } from '#src/utils.js'; import { isAIMessage } from '@langchain/core/messages'; import { IterableReadableStream } from '@langchain/core/utils/stream'; import { createReactAgent } from '@langchain/langgraph/prebuilt'; import { MultiServerMCPClient } from '@langchain/mcp-adapters'; export class GthLangChainAgent { statusUpdate; mcpClient = null; agent = null; config = null; constructor(statusUpdate) { this.statusUpdate = (level, message) => { statusUpdate(level, message); }; } async init(command, configIn, checkpointSaver) { debugLog(`GthLangChainAgent.init called with command: ${command || 'default'}`); // Merge command-specific filesystem config if provided this.config = this.getEffectiveConfig(configIn, command); debugLogObject('Effective Config', { filesystem: this.config.filesystem, builtInTools: this.config.builtInTools, streamOutput: this.config.streamOutput, debugLog: this.config.debugLog, }); if (this.config.modelDisplayName) { this.statusUpdate('info', `Model: ${this.config.modelDisplayName}`); } this.mcpClient = await this.getMcpClient(this.config); // Get default filesystem tools (filtered based on config) debugLog('Loading default tools...'); const defaultTools = await getDefaultTools(this.config, command); debugLog(`Default tools loaded: ${defaultTools.length}`); // Get user config tools const flattenedConfigTools = this.extractAndFlattenTools(this.config.tools || []); debugLog(`User config tools loaded: ${flattenedConfigTools.length}`); // Get MCP tools const mcpTools = (await this.mcpClient?.getTools()) ?? []; debugLog(`MCP tools loaded: ${mcpTools.length}`); // Combine all tools const tools = [...defaultTools, ...flattenedConfigTools, ...mcpTools]; if (tools.length > 0) { const toolNames = tools .map((tool) => tool.name) .filter((name) => name) .join(', '); this.statusUpdate('info', `Loaded tools: ${toolNames}`); debugLog(`Total tools available: ${tools.length}`); debugLogObject('All Tools', toolNames.split(', ')); } // Create the React agent debugLog('Creating React agent...'); this.agent = createReactAgent({ llm: this.config.llm, tools, checkpointSaver, postModelHook: (state) => { debugLogObject('postModel state', state); const lastMessage = state.messages[state.messages.length - 1]; if (isAIMessage(lastMessage) && lastMessage.tool_calls && lastMessage.tool_calls?.length > 0) { this.statusUpdate('info', `\nRequested tools: ${formatToolCalls(lastMessage.tool_calls)}\n`); } if (configIn.hooks?.postModelHook) { return configIn.hooks.postModelHook(state); } return state; }, preModelHook: configIn.hooks?.preModelHook, }); debugLog('React agent created successfully'); } /** * Invoke LLM with a message and runnable config. * For streaming use {@link #stream} method, streaming is preferred if model API supports it. * Please note that this when tools are involved, this method will anyway do multiple LLM * calls within LangChain dependency. */ async invoke(messages, runConfig) { if (!this.agent || !this.config) { throw new Error('Agent not initialized. Call init() first.'); } debugLog('=== Starting non-streaming invoke ==='); debugLogObject('LLM Input Messages', messages); debugLogObject('Invoke RunConfig', runConfig); try { const progress = new ProgressIndicator('Thinking.'); try { debugLog('Calling agent.invoke...'); const response = await this.agent.invoke({ messages }, runConfig); const aiMessage = response.messages[response.messages.length - 1].content; this.statusUpdate('display', aiMessage); return aiMessage; } catch (e) { debugLogError('invoke inner', e); if (e instanceof Error && e?.name === 'ToolException') { throw e; // Re-throw ToolException to be handled by outer catch } this.statusUpdate('warning', `Something went wrong ${e.message}`); return ''; } finally { progress.stop(); } } catch (error) { debugLogError('invoke outer', error); if (error instanceof Error) { if (error?.name === 'ToolException') { this.statusUpdate('error', `Tool execution failed: ${error?.message}`); return `Tool execution failed: ${error?.message}`; } } throw error; } } /** * Induce LLM to stream AI messages with a user message and runnable config. * When stream is not appropriate use {@link invoke}. */ async stream(messages, runConfig) { if (!this.agent || !this.config) { throw new Error('Agent not initialized. Call init() first.'); } debugLog('=== Starting streaming invoke ==='); debugLogObject('LLM Input Messages', messages); debugLogObject('Stream RunConfig', runConfig); this.statusUpdate('info', '\nThinking...\n'); const stream = await this.agent.stream({ messages }, { ...runConfig, streamMode: 'messages' }); const statusUpdate = this.statusUpdate; const interruptState = { escape: false }; waitForEscape(() => (interruptState.escape = true), this.config.canInterruptInferenceWithEsc); return new IterableReadableStream({ async start(controller) { try { debugLog('Starting stream processing...'); let totalChunks = 0; for await (const [chunk, _metadata] of stream) { debugLogObject('Stream chunk', { chunk, _metadata }); if (isAIMessage(chunk)) { const text = chunk.text; totalChunks++; statusUpdate('stream', text); controller.enqueue(text); } if (interruptState.escape) { statusUpdate('warning', '\n\nInterrupted by user, exiting\n\n'); break; } } stopWaitingForEscape(); debugLog(`Stream completed. Total chunks: ${totalChunks}`); controller.close(); } catch (error) { stopWaitingForEscape(); debugLogError('stream processing', error); if (error instanceof Error) { if (error?.name === 'ToolException') { statusUpdate('error', `Tool execution failed: ${error?.message}`); } } controller.error(error); } }, async cancel() { stopWaitingForEscape(); // Clean up the underlying stream if it has a cancel method if (stream && typeof stream.cancel === 'function') { await stream.cancel(); } }, }); } // noinspection JSUnusedGlobalSymbols getMCPClient() { return this.mcpClient; } async cleanup() { debugLog('Cleaning up GthLangChainAgent...'); if (this.mcpClient) { debugLog('Closing MCP client...'); await this.mcpClient.close(); this.mcpClient = null; } this.agent = null; this.config = null; debugLog('GthLangChainAgent cleanup complete'); } getEffectiveConfig(config, command) { debugLog(`Getting effective config for command: ${command || 'default'}`); const supportsTools = !!config.llm.bindTools; if (!supportsTools) { this.statusUpdate('warning', 'Model does not seem to support tools.'); debugLog('Warning: Model does not support tools'); } return { ...config, filesystem: command && config.commands?.[command]?.filesystem !== undefined ? config.commands[command].filesystem : config.filesystem, builtInTools: command && config.commands?.[command]?.builtInTools !== undefined ? config.commands[command].builtInTools : config.builtInTools, }; } /** * Extract and flatten tools from toolkits */ extractAndFlattenTools(tools) { const flattenedTools = []; for (const toolOrToolkit of tools) { // eslint-disable-next-line if (toolOrToolkit['getTools'] instanceof Function) { // This is a toolkit flattenedTools.push(...toolOrToolkit.getTools()); } else { // This is a regular tool flattenedTools.push(toolOrToolkit); } } return flattenedTools; } getDefaultMcpServers() { return {}; } async getMcpClient(config) { debugLog('Setting up MCP client...'); const defaultServers = this.getDefaultMcpServers(); // Merge with user's mcpServers const rawMcpServers = { ...defaultServers, ...(config.mcpServers || {}) }; debugLog(`MCP servers count: ${Object.keys(rawMcpServers).length}`); const mcpServers = {}; for (const serverName of Object.keys(rawMcpServers)) { const server = rawMcpServers[serverName]; // eslint-disable-next-line @typescript-eslint/no-explicit-any if (server.url && server && server.authProvider === 'OAuth') { displayInfo(`Starting OAuth for for ${server.url}`); const authProvider = await createAuthProviderAndAuthenticate(server); mcpServers[serverName] = { ...server, authProvider, }; } else { // Add non-OAuth servers as-is mcpServers[serverName] = server; } } if (Object.keys(mcpServers).length > 0) { debugLog('Creating MultiServerMCPClient...'); return new MultiServerMCPClient({ throwOnLoadError: true, prefixToolNameWithServerName: true, additionalToolNamePrefix: 'mcp', mcpServers: mcpServers, }); } else { debugLog('No MCP servers configured'); return null; } } } //# sourceMappingURL=GthLangChainAgent.js.map