diff --git a/apps/server/src/services/llm/chat_service.spec.ts b/apps/server/src/services/llm/chat_service.spec.ts deleted file mode 100644 index 5e39f9d15..000000000 --- a/apps/server/src/services/llm/chat_service.spec.ts +++ /dev/null @@ -1,861 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ChatService } from './chat_service.js'; -import type { Message, ChatCompletionOptions } from './ai_interface.js'; - -// Mock dependencies -vi.mock('./chat_storage_service.js', () => ({ - default: { - createChat: vi.fn(), - getChat: vi.fn(), - updateChat: vi.fn(), - deleteChat: vi.fn(), - getAllChats: vi.fn(), - recordSources: vi.fn() - } -})); - -vi.mock('../log.js', () => ({ - default: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn() - } -})); - -vi.mock('./constants/llm_prompt_constants.js', () => ({ - CONTEXT_PROMPTS: { - NOTE_CONTEXT_PROMPT: 'Context: {context}', - SEMANTIC_NOTE_CONTEXT_PROMPT: 'Query: {query}\nContext: {context}' - }, - ERROR_PROMPTS: { - USER_ERRORS: { - GENERAL_ERROR: 'Sorry, I encountered an error processing your request.', - CONTEXT_ERROR: 'Sorry, I encountered an error processing the context.' - } - } -})); - -vi.mock('./pipeline/chat_pipeline.js', () => ({ - ChatPipeline: vi.fn().mockImplementation((config) => ({ - config, - execute: vi.fn(), - getMetrics: vi.fn(), - resetMetrics: vi.fn(), - stages: { - contextExtraction: { - execute: vi.fn() - }, - semanticContextExtraction: { - execute: vi.fn() - } - } - })) -})); - -vi.mock('./ai_service_manager.js', () => ({ - default: { - getService: vi.fn() - } -})); - -describe('ChatService', () => { - let chatService: ChatService; - let mockChatStorageService: any; - let mockAiServiceManager: any; - let mockChatPipeline: any; - let mockLog: any; - - beforeEach(async () => { - vi.clearAllMocks(); - - // Get mocked modules - mockChatStorageService = (await import('./chat_storage_service.js')).default; - mockAiServiceManager = (await import('./ai_service_manager.js')).default; - mockLog = (await import('../log.js')).default; - - // Setup pipeline mock - mockChatPipeline = { - execute: vi.fn(), - getMetrics: vi.fn(), - resetMetrics: vi.fn(), - stages: { - contextExtraction: { - execute: vi.fn() - }, - semanticContextExtraction: { - execute: vi.fn() - } - } - }; - - // Create a new ChatService instance - chatService = new ChatService(); - - // Replace the internal pipelines with our mock - (chatService as any).pipelines.set('default', mockChatPipeline); - (chatService as any).pipelines.set('agent', mockChatPipeline); - (chatService as any).pipelines.set('performance', mockChatPipeline); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should initialize with default pipelines', () => { - expect(chatService).toBeDefined(); - // Verify pipelines are created by checking internal state - expect((chatService as any).pipelines).toBeDefined(); - expect((chatService as any).sessionCache).toBeDefined(); - }); - }); - - describe('createSession', () => { - it('should create a new chat session with default title', async () => { - const mockChat = { - id: 'chat-123', - title: 'New Chat', - messages: [], - noteId: 'chat-123', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.createChat.mockResolvedValueOnce(mockChat); - - const session = await chatService.createSession(); - - expect(session).toEqual({ - id: 'chat-123', - title: 'New Chat', - messages: [], - isStreaming: false - }); - - expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []); - }); - - it('should create a new chat session with custom title and messages', async () => { - const initialMessages: Message[] = [ - { role: 'user', content: 'Hello' } - ]; - - const mockChat = { - id: 'chat-456', - title: 'Custom Chat', - messages: initialMessages, - noteId: 'chat-456', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.createChat.mockResolvedValueOnce(mockChat); - - const session = await chatService.createSession('Custom Chat', initialMessages); - - expect(session).toEqual({ - id: 'chat-456', - title: 'Custom Chat', - messages: initialMessages, - isStreaming: false - }); - - expect(mockChatStorageService.createChat).toHaveBeenCalledWith('Custom Chat', initialMessages); - }); - }); - - describe('getOrCreateSession', () => { - it('should return cached session if available', async () => { - const mockChat = { - id: 'chat-123', - title: 'Test Chat', - messages: [{ role: 'user', content: 'Hello' }], - noteId: 'chat-123', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - const cachedSession = { - id: 'chat-123', - title: 'Old Title', - messages: [], - isStreaming: false - }; - - // Pre-populate cache - (chatService as any).sessionCache.set('chat-123', cachedSession); - mockChatStorageService.getChat.mockResolvedValueOnce(mockChat); - - const session = await chatService.getOrCreateSession('chat-123'); - - expect(session).toEqual({ - id: 'chat-123', - title: 'Test Chat', // Should be updated from storage - messages: [{ role: 'user', content: 'Hello' }], // Should be updated from storage - isStreaming: false - }); - - expect(mockChatStorageService.getChat).toHaveBeenCalledWith('chat-123'); - }); - - it('should load session from storage if not cached', async () => { - const mockChat = { - id: 'chat-123', - title: 'Test Chat', - messages: [{ role: 'user', content: 'Hello' }], - noteId: 'chat-123', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.getChat.mockResolvedValueOnce(mockChat); - - const session = await chatService.getOrCreateSession('chat-123'); - - expect(session).toEqual({ - id: 'chat-123', - title: 'Test Chat', - messages: [{ role: 'user', content: 'Hello' }], - isStreaming: false - }); - - expect(mockChatStorageService.getChat).toHaveBeenCalledWith('chat-123'); - }); - - it('should create new session if not found', async () => { - mockChatStorageService.getChat.mockResolvedValueOnce(null); - - const mockNewChat = { - id: 'chat-new', - title: 'New Chat', - messages: [], - noteId: 'chat-new', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.createChat.mockResolvedValueOnce(mockNewChat); - - const session = await chatService.getOrCreateSession('nonexistent'); - - expect(session).toEqual({ - id: 'chat-new', - title: 'New Chat', - messages: [], - isStreaming: false - }); - - expect(mockChatStorageService.getChat).toHaveBeenCalledWith('nonexistent'); - expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []); - }); - - it('should create new session when no sessionId provided', async () => { - const mockNewChat = { - id: 'chat-new', - title: 'New Chat', - messages: [], - noteId: 'chat-new', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.createChat.mockResolvedValueOnce(mockNewChat); - - const session = await chatService.getOrCreateSession(); - - expect(session).toEqual({ - id: 'chat-new', - title: 'New Chat', - messages: [], - isStreaming: false - }); - - expect(mockChatStorageService.createChat).toHaveBeenCalledWith('New Chat', []); - }); - }); - - describe('sendMessage', () => { - beforeEach(() => { - const mockSession = { - id: 'chat-123', - title: 'Test Chat', - messages: [], - isStreaming: false - }; - - const mockChat = { - id: 'chat-123', - title: 'Test Chat', - messages: [], - noteId: 'chat-123', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.getChat.mockResolvedValue(mockChat); - mockChatStorageService.updateChat.mockResolvedValue(mockChat); - - mockChatPipeline.execute.mockResolvedValue({ - text: 'Hello! How can I help you?', - model: 'gpt-3.5-turbo', - provider: 'OpenAI', - usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 } - }); - }); - - it('should send message and get AI response', async () => { - const session = await chatService.sendMessage('chat-123', 'Hello'); - - expect(session.messages).toHaveLength(2); - expect(session.messages[0]).toEqual({ - role: 'user', - content: 'Hello' - }); - expect(session.messages[1]).toEqual({ - role: 'assistant', - content: 'Hello! How can I help you?', - tool_calls: undefined - }); - - expect(mockChatStorageService.updateChat).toHaveBeenCalledTimes(2); // Once for user message, once for complete conversation - expect(mockChatPipeline.execute).toHaveBeenCalled(); - }); - - it('should handle streaming callback', async () => { - const streamCallback = vi.fn(); - - await chatService.sendMessage('chat-123', 'Hello', {}, streamCallback); - - expect(mockChatPipeline.execute).toHaveBeenCalledWith( - expect.objectContaining({ - streamCallback - }) - ); - }); - - it('should update title for first message', async () => { - const mockChat = { - id: 'chat-123', - title: 'New Chat', - messages: [], - noteId: 'chat-123', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.getChat.mockResolvedValue(mockChat); - - await chatService.sendMessage('chat-123', 'What is the weather like?'); - - // Should update title based on first message - expect(mockChatStorageService.updateChat).toHaveBeenLastCalledWith( - 'chat-123', - expect.any(Array), - 'What is the weather like?' - ); - }); - - it('should handle errors gracefully', async () => { - mockChatPipeline.execute.mockRejectedValueOnce(new Error('AI service error')); - - const session = await chatService.sendMessage('chat-123', 'Hello'); - - expect(session.messages).toHaveLength(2); - expect(session.messages[1]).toEqual({ - role: 'assistant', - content: 'Sorry, I encountered an error processing your request.' - }); - - expect(session.isStreaming).toBe(false); - expect(mockChatStorageService.updateChat).toHaveBeenCalledWith( - 'chat-123', - expect.arrayContaining([ - expect.objectContaining({ - role: 'assistant', - content: 'Sorry, I encountered an error processing your request.' - }) - ]) - ); - }); - - it('should handle tool calls in response', async () => { - const toolCalls = [{ - id: 'call_123', - type: 'function' as const, - function: { - name: 'searchNotes', - arguments: '{"query": "test"}' - } - }]; - - mockChatPipeline.execute.mockResolvedValueOnce({ - text: 'I need to search for notes.', - model: 'gpt-4', - provider: 'OpenAI', - tool_calls: toolCalls, - usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 } - }); - - const session = await chatService.sendMessage('chat-123', 'Search for notes about AI'); - - expect(session.messages[1]).toEqual({ - role: 'assistant', - content: 'I need to search for notes.', - tool_calls: toolCalls - }); - }); - }); - - describe('sendContextAwareMessage', () => { - beforeEach(() => { - const mockSession = { - id: 'chat-123', - title: 'Test Chat', - messages: [], - isStreaming: false - }; - - const mockChat = { - id: 'chat-123', - title: 'Test Chat', - messages: [], - noteId: 'chat-123', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.getChat.mockResolvedValue(mockChat); - mockChatStorageService.updateChat.mockResolvedValue(mockChat); - - mockChatPipeline.execute.mockResolvedValue({ - text: 'Based on the context, here is my response.', - model: 'gpt-4', - provider: 'OpenAI', - usage: { promptTokens: 20, completionTokens: 15, totalTokens: 35 } - }); - }); - - it('should send context-aware message with note ID', async () => { - const session = await chatService.sendContextAwareMessage( - 'chat-123', - 'What is this note about?', - 'note-456' - ); - - expect(session.messages).toHaveLength(2); - expect(session.messages[0]).toEqual({ - role: 'user', - content: 'What is this note about?' - }); - - expect(mockChatPipeline.execute).toHaveBeenCalledWith( - expect.objectContaining({ - noteId: 'note-456', - query: 'What is this note about?', - showThinking: false - }) - ); - - expect(mockChatStorageService.updateChat).toHaveBeenLastCalledWith( - 'chat-123', - expect.any(Array), - undefined, - expect.objectContaining({ - contextNoteId: 'note-456' - }) - ); - }); - - it('should use agent pipeline when showThinking is enabled', async () => { - await chatService.sendContextAwareMessage( - 'chat-123', - 'Analyze this note', - 'note-456', - { showThinking: true } - ); - - expect(mockChatPipeline.execute).toHaveBeenCalledWith( - expect.objectContaining({ - showThinking: true - }) - ); - }); - - it('should handle errors in context-aware messages', async () => { - mockChatPipeline.execute.mockRejectedValueOnce(new Error('Context error')); - - const session = await chatService.sendContextAwareMessage( - 'chat-123', - 'What is this note about?', - 'note-456' - ); - - expect(session.messages[1]).toEqual({ - role: 'assistant', - content: 'Sorry, I encountered an error processing the context.' - }); - }); - }); - - describe('addNoteContext', () => { - it('should add note context to session', async () => { - const mockSession = { - id: 'chat-123', - title: 'Test Chat', - messages: [ - { role: 'user', content: 'Tell me about AI features' } - ], - isStreaming: false - }; - - const mockChat = { - id: 'chat-123', - title: 'Test Chat', - messages: mockSession.messages, - noteId: 'chat-123', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.getChat.mockResolvedValue(mockChat); - mockChatStorageService.updateChat.mockResolvedValue(mockChat); - - // Mock the pipeline's context extraction stage - mockChatPipeline.stages.contextExtraction.execute.mockResolvedValue({ - context: 'This note contains information about AI features...', - sources: [ - { - noteId: 'note-456', - title: 'AI Features', - similarity: 0.95, - content: 'AI features content' - } - ] - }); - - const session = await chatService.addNoteContext('chat-123', 'note-456'); - - expect(session.messages).toHaveLength(2); - expect(session.messages[1]).toEqual({ - role: 'user', - content: 'Context: This note contains information about AI features...' - }); - - expect(mockChatStorageService.recordSources).toHaveBeenCalledWith( - 'chat-123', - [expect.objectContaining({ - noteId: 'note-456', - title: 'AI Features', - similarity: 0.95, - content: 'AI features content' - })] - ); - }); - }); - - describe('addSemanticNoteContext', () => { - it('should add semantic note context to session', async () => { - const mockSession = { - id: 'chat-123', - title: 'Test Chat', - messages: [], - isStreaming: false - }; - - const mockChat = { - id: 'chat-123', - title: 'Test Chat', - messages: [], - noteId: 'chat-123', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }; - - mockChatStorageService.getChat.mockResolvedValue(mockChat); - mockChatStorageService.updateChat.mockResolvedValue(mockChat); - - mockChatPipeline.stages.semanticContextExtraction.execute.mockResolvedValue({ - context: 'Semantic context about machine learning...', - sources: [] - }); - - const session = await chatService.addSemanticNoteContext( - 'chat-123', - 'note-456', - 'machine learning algorithms' - ); - - expect(session.messages).toHaveLength(1); - expect(session.messages[0]).toEqual({ - role: 'user', - content: 'Query: machine learning algorithms\nContext: Semantic context about machine learning...' - }); - - expect(mockChatPipeline.stages.semanticContextExtraction.execute).toHaveBeenCalledWith({ - noteId: 'note-456', - query: 'machine learning algorithms' - }); - }); - }); - - describe('getAllSessions', () => { - it('should return all chat sessions', async () => { - const mockChats = [ - { - id: 'chat-1', - title: 'Chat 1', - messages: [{ role: 'user', content: 'Hello' }], - noteId: 'chat-1', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - }, - { - id: 'chat-2', - title: 'Chat 2', - messages: [{ role: 'user', content: 'Hi' }], - noteId: 'chat-2', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - } - ]; - - mockChatStorageService.getAllChats.mockResolvedValue(mockChats); - - const sessions = await chatService.getAllSessions(); - - expect(sessions).toHaveLength(2); - expect(sessions[0]).toEqual({ - id: 'chat-1', - title: 'Chat 1', - messages: [{ role: 'user', content: 'Hello' }], - isStreaming: false - }); - expect(sessions[1]).toEqual({ - id: 'chat-2', - title: 'Chat 2', - messages: [{ role: 'user', content: 'Hi' }], - isStreaming: false - }); - }); - - it('should update cached sessions with latest data', async () => { - const mockChats = [ - { - id: 'chat-1', - title: 'Updated Title', - messages: [{ role: 'user', content: 'Updated message' }], - noteId: 'chat-1', - createdAt: new Date(), - updatedAt: new Date(), - metadata: {} - } - ]; - - // Pre-populate cache with old data - (chatService as any).sessionCache.set('chat-1', { - id: 'chat-1', - title: 'Old Title', - messages: [{ role: 'user', content: 'Old message' }], - isStreaming: true - }); - - mockChatStorageService.getAllChats.mockResolvedValue(mockChats); - - const sessions = await chatService.getAllSessions(); - - expect(sessions[0]).toEqual({ - id: 'chat-1', - title: 'Updated Title', - messages: [{ role: 'user', content: 'Updated message' }], - isStreaming: true // Should preserve streaming state - }); - }); - }); - - describe('deleteSession', () => { - it('should delete session from cache and storage', async () => { - // Pre-populate cache - (chatService as any).sessionCache.set('chat-123', { - id: 'chat-123', - title: 'Test Chat', - messages: [], - isStreaming: false - }); - - mockChatStorageService.deleteChat.mockResolvedValue(true); - - const result = await chatService.deleteSession('chat-123'); - - expect(result).toBe(true); - expect((chatService as any).sessionCache.has('chat-123')).toBe(false); - expect(mockChatStorageService.deleteChat).toHaveBeenCalledWith('chat-123'); - }); - }); - - describe('generateChatCompletion', () => { - it('should use AI service directly for simple completion', async () => { - const messages: Message[] = [ - { role: 'user', content: 'Hello' } - ]; - - const mockService = { - getName: () => 'OpenAI', - generateChatCompletion: vi.fn().mockResolvedValue({ - text: 'Hello! How can I help?', - model: 'gpt-3.5-turbo', - provider: 'OpenAI' - }) - }; - - mockAiServiceManager.getService.mockResolvedValue(mockService); - - const result = await chatService.generateChatCompletion(messages); - - expect(result).toEqual({ - text: 'Hello! How can I help?', - model: 'gpt-3.5-turbo', - provider: 'OpenAI' - }); - - expect(mockService.generateChatCompletion).toHaveBeenCalledWith(messages, {}); - }); - - it('should use pipeline for advanced context', async () => { - const messages: Message[] = [ - { role: 'user', content: 'Hello' } - ]; - - const options = { - useAdvancedContext: true, - noteId: 'note-123' - }; - - // Mock AI service for this test - const mockService = { - getName: () => 'OpenAI', - generateChatCompletion: vi.fn() - }; - mockAiServiceManager.getService.mockResolvedValue(mockService); - - mockChatPipeline.execute.mockResolvedValue({ - text: 'Response with context', - model: 'gpt-4', - provider: 'OpenAI', - tool_calls: [] - }); - - const result = await chatService.generateChatCompletion(messages, options); - - expect(result).toEqual({ - text: 'Response with context', - model: 'gpt-4', - provider: 'OpenAI', - tool_calls: [] - }); - - expect(mockChatPipeline.execute).toHaveBeenCalledWith({ - messages, - options, - query: 'Hello', - noteId: 'note-123' - }); - }); - - it('should throw error when no AI service available', async () => { - const messages: Message[] = [ - { role: 'user', content: 'Hello' } - ]; - - mockAiServiceManager.getService.mockResolvedValue(null); - - await expect(chatService.generateChatCompletion(messages)).rejects.toThrow( - 'No AI service available' - ); - }); - }); - - describe('pipeline metrics', () => { - it('should get pipeline metrics', () => { - mockChatPipeline.getMetrics.mockReturnValue({ requestCount: 5 }); - - const metrics = chatService.getPipelineMetrics(); - - expect(metrics).toEqual({ requestCount: 5 }); - expect(mockChatPipeline.getMetrics).toHaveBeenCalled(); - }); - - it('should reset pipeline metrics', () => { - chatService.resetPipelineMetrics(); - - expect(mockChatPipeline.resetMetrics).toHaveBeenCalled(); - }); - - it('should handle different pipeline types', () => { - mockChatPipeline.getMetrics.mockReturnValue({ requestCount: 3 }); - - const metrics = chatService.getPipelineMetrics('agent'); - - expect(metrics).toEqual({ requestCount: 3 }); - }); - }); - - describe('generateTitleFromMessages', () => { - it('should generate title from first user message', () => { - const messages: Message[] = [ - { role: 'user', content: 'What is machine learning?' }, - { role: 'assistant', content: 'Machine learning is...' } - ]; - - // Access private method for testing - const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService); - const title = generateTitle(messages); - - expect(title).toBe('What is machine learning?'); - }); - - it('should truncate long titles', () => { - const messages: Message[] = [ - { role: 'user', content: 'This is a very long message that should be truncated because it exceeds the maximum length' }, - { role: 'assistant', content: 'Response' } - ]; - - const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService); - const title = generateTitle(messages); - - expect(title).toBe('This is a very long message...'); - expect(title.length).toBe(30); - }); - - it('should return default title for empty or invalid messages', () => { - const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService); - - expect(generateTitle([])).toBe('New Chat'); - expect(generateTitle([{ role: 'assistant', content: 'Hello' }])).toBe('New Chat'); - }); - - it('should use first line for multiline messages', () => { - const messages: Message[] = [ - { role: 'user', content: 'First line\nSecond line\nThird line' }, - { role: 'assistant', content: 'Response' } - ]; - - const generateTitle = (chatService as any).generateTitleFromMessages.bind(chatService); - const title = generateTitle(messages); - - expect(title).toBe('First line'); - }); - }); -}); \ No newline at end of file diff --git a/apps/server/src/services/llm/chat_service.ts b/apps/server/src/services/llm/chat_service.ts deleted file mode 100644 index 18bf01251..000000000 --- a/apps/server/src/services/llm/chat_service.ts +++ /dev/null @@ -1,595 +0,0 @@ -import type { Message, ChatCompletionOptions, ChatResponse } from './ai_interface.js'; -import chatStorageService from './chat_storage_service.js'; -import log from '../log.js'; -import { CONTEXT_PROMPTS, ERROR_PROMPTS } from './constants/llm_prompt_constants.js'; -import { ChatPipeline } from './pipeline/chat_pipeline.js'; -import type { ChatPipelineConfig, StreamCallback } from './pipeline/interfaces.js'; -import aiServiceManager from './ai_service_manager.js'; -import type { ChatPipelineInput } from './pipeline/interfaces.js'; -import type { NoteSearchResult } from './interfaces/context_interfaces.js'; - -// Update the ChatCompletionOptions interface to include the missing properties -declare module './ai_interface.js' { - interface ChatCompletionOptions { - pipeline?: string; - noteId?: string; - useAdvancedContext?: boolean; - showThinking?: boolean; - enableTools?: boolean; - } -} - -// Add a type for context extraction result -interface ContextExtractionResult { - context: string; - sources?: NoteSearchResult[]; - thinking?: string; -} - -export interface ChatSession { - id: string; - title: string; - messages: Message[]; - isStreaming?: boolean; - options?: ChatCompletionOptions; -} - -/** - * Chat pipeline configurations for different use cases - */ -const PIPELINE_CONFIGS: Record> = { - default: { - enableStreaming: true, - enableMetrics: true - }, - agent: { - enableStreaming: true, - enableMetrics: true, - maxToolCallIterations: 5 - }, - performance: { - enableStreaming: false, - enableMetrics: true - } -}; - -/** - * Service for managing chat interactions and history - */ -export class ChatService { - private sessionCache: Map = new Map(); - private pipelines: Map = new Map(); - - constructor() { - // Initialize pipelines - Object.entries(PIPELINE_CONFIGS).forEach(([name, config]) => { - this.pipelines.set(name, new ChatPipeline(config)); - }); - } - - /** - * Get a pipeline by name, or the default one - */ - private getPipeline(name: string = 'default'): ChatPipeline { - return this.pipelines.get(name) || this.pipelines.get('default')!; - } - - /** - * Create a new chat session - */ - async createSession(title?: string, initialMessages: Message[] = []): Promise { - // Create a new Chat Note as the source of truth - const chat = await chatStorageService.createChat(title || 'New Chat', initialMessages); - - const session: ChatSession = { - id: chat.id, - title: chat.title, - messages: chat.messages, - isStreaming: false - }; - - // Session is just a cache now - this.sessionCache.set(chat.id, session); - return session; - } - - /** - * Get an existing session or create a new one - */ - async getOrCreateSession(sessionId?: string): Promise { - if (sessionId) { - // First check the cache - const cachedSession = this.sessionCache.get(sessionId); - if (cachedSession) { - // Refresh the data from the source of truth - const chat = await chatStorageService.getChat(sessionId); - if (chat) { - // Update the cached session with latest data from the note - cachedSession.title = chat.title; - cachedSession.messages = chat.messages; - return cachedSession; - } - } else { - // Not in cache, load from the chat note - const chat = await chatStorageService.getChat(sessionId); - if (chat) { - const session: ChatSession = { - id: chat.id, - title: chat.title, - messages: chat.messages, - isStreaming: false - }; - - this.sessionCache.set(chat.id, session); - return session; - } - } - } - - return this.createSession(); - } - - /** - * Send a message in a chat session and get the AI response - */ - async sendMessage( - sessionId: string, - content: string, - options?: ChatCompletionOptions, - streamCallback?: StreamCallback - ): Promise { - const session = await this.getOrCreateSession(sessionId); - - // Add user message - const userMessage: Message = { - role: 'user', - content - }; - - session.messages.push(userMessage); - session.isStreaming = true; - - try { - // Immediately save the user message - await chatStorageService.updateChat(session.id, session.messages); - - // Log message processing - log.info(`Processing message: "${content.substring(0, 100)}..."`); - - // Select pipeline to use - const pipeline = this.getPipeline(); - - // Include sessionId in the options for tool execution tracking - const pipelineOptions = { - ...(options || session.options || {}), - sessionId: session.id - }; - - // Execute the pipeline - const response = await pipeline.execute({ - messages: session.messages, - options: pipelineOptions, - query: content, - streamCallback - }); - - // Add assistant message - const assistantMessage: Message = { - role: 'assistant', - content: response.text, - tool_calls: response.tool_calls - }; - - session.messages.push(assistantMessage); - session.isStreaming = false; - - // Save metadata about the response - const metadata = { - model: response.model, - provider: response.provider, - usage: response.usage - }; - - // If there are tool calls, make sure they're stored in metadata - if (response.tool_calls && response.tool_calls.length > 0) { - // Let the storage service extract and save tool executions - // The tool results are already in the messages - } - - // Save the complete conversation with metadata - await chatStorageService.updateChat(session.id, session.messages, undefined, metadata); - - // If first message, update the title based on content - if (session.messages.length <= 2 && (!session.title || session.title === 'New Chat')) { - const title = this.generateTitleFromMessages(session.messages); - session.title = title; - await chatStorageService.updateChat(session.id, session.messages, title); - } - - return session; - - } catch (error: unknown) { - session.isStreaming = false; - console.error('Error in AI chat:', this.handleError(error)); - - // Add error message - const errorMessage: Message = { - role: 'assistant', - content: ERROR_PROMPTS.USER_ERRORS.GENERAL_ERROR - }; - - session.messages.push(errorMessage); - - // Save the conversation with error - await chatStorageService.updateChat(session.id, session.messages); - - // Notify streaming error if callback provided - if (streamCallback) { - streamCallback(errorMessage.content, true); - } - - return session; - } - } - - /** - * Send a message with context from a specific note - */ - async sendContextAwareMessage( - sessionId: string, - content: string, - noteId: string, - options?: ChatCompletionOptions, - streamCallback?: StreamCallback - ): Promise { - const session = await this.getOrCreateSession(sessionId); - - // Add user message - const userMessage: Message = { - role: 'user', - content - }; - - session.messages.push(userMessage); - session.isStreaming = true; - - try { - // Immediately save the user message - await chatStorageService.updateChat(session.id, session.messages); - - // Log message processing - log.info(`Processing context-aware message: "${content.substring(0, 100)}..."`); - log.info(`Using context from note: ${noteId}`); - - // Get showThinking option if it exists - const showThinking = options?.showThinking === true; - - // Select appropriate pipeline based on whether agent tools are needed - const pipelineType = showThinking ? 'agent' : 'default'; - const pipeline = this.getPipeline(pipelineType); - - // Include sessionId in the options for tool execution tracking - const pipelineOptions = { - ...(options || session.options || {}), - sessionId: session.id - }; - - // Execute the pipeline with note context - const response = await pipeline.execute({ - messages: session.messages, - options: pipelineOptions, - noteId, - query: content, - showThinking, - streamCallback - }); - - // Add assistant message - const assistantMessage: Message = { - role: 'assistant', - content: response.text, - tool_calls: response.tool_calls - }; - - session.messages.push(assistantMessage); - session.isStreaming = false; - - // Save metadata about the response - const metadata = { - model: response.model, - provider: response.provider, - usage: response.usage, - contextNoteId: noteId // Store the note ID used for context - }; - - // If there are tool calls, make sure they're stored in metadata - if (response.tool_calls && response.tool_calls.length > 0) { - // Let the storage service extract and save tool executions - // The tool results are already in the messages - } - - // Save the complete conversation with metadata to the Chat Note (the single source of truth) - await chatStorageService.updateChat(session.id, session.messages, undefined, metadata); - - // If first message, update the title - if (session.messages.length <= 2 && (!session.title || session.title === 'New Chat')) { - const title = this.generateTitleFromMessages(session.messages); - session.title = title; - await chatStorageService.updateChat(session.id, session.messages, title); - } - - return session; - - } catch (error: unknown) { - session.isStreaming = false; - console.error('Error in context-aware chat:', this.handleError(error)); - - // Add error message - const errorMessage: Message = { - role: 'assistant', - content: ERROR_PROMPTS.USER_ERRORS.CONTEXT_ERROR - }; - - session.messages.push(errorMessage); - - // Save the conversation with error to the Chat Note - await chatStorageService.updateChat(session.id, session.messages); - - // Notify streaming error if callback provided - if (streamCallback) { - streamCallback(errorMessage.content, true); - } - - return session; - } - } - - /** - * Add context from the current note to the chat - * - * @param sessionId - The ID of the chat session - * @param noteId - The ID of the note to add context from - * @param useSmartContext - Whether to use smart context extraction (default: true) - * @returns The updated chat session - */ - async addNoteContext(sessionId: string, noteId: string, useSmartContext = true): Promise { - const session = await this.getOrCreateSession(sessionId); - - // Get the last user message to use as context for semantic search - const lastUserMessage = [...session.messages].reverse() - .find(msg => msg.role === 'user' && msg.content.length > 10)?.content || ''; - - // Use the context extraction stage from the pipeline - const pipeline = this.getPipeline(); - const contextResult = await pipeline.stages.contextExtraction.execute({ - noteId, - query: lastUserMessage, - useSmartContext - }) as ContextExtractionResult; - - const contextMessage: Message = { - role: 'user', - content: CONTEXT_PROMPTS.NOTE_CONTEXT_PROMPT.replace('{context}', contextResult.context) - }; - - session.messages.push(contextMessage); - - // Store the context note id in metadata - const metadata = { - contextNoteId: noteId - }; - - // Check if the context extraction result has sources - if (contextResult.sources && contextResult.sources.length > 0) { - // Convert the sources to match expected format (handling null vs undefined) - const sources = contextResult.sources.map(source => ({ - noteId: source.noteId, - title: source.title, - similarity: source.similarity, - // Replace null with undefined for content - content: source.content === null ? undefined : source.content - })); - - // Store these sources in metadata - await chatStorageService.recordSources(session.id, sources); - } - - await chatStorageService.updateChat(session.id, session.messages, undefined, metadata); - - return session; - } - - /** - * Add semantically relevant context from a note based on a specific query - */ - async addSemanticNoteContext(sessionId: string, noteId: string, query: string): Promise { - const session = await this.getOrCreateSession(sessionId); - - // Use the semantic context extraction stage from the pipeline - const pipeline = this.getPipeline(); - const contextResult = await pipeline.stages.semanticContextExtraction.execute({ - noteId, - query - }); - - const contextMessage: Message = { - role: 'user', - content: CONTEXT_PROMPTS.SEMANTIC_NOTE_CONTEXT_PROMPT - .replace('{query}', query) - .replace('{context}', contextResult.context) - }; - - session.messages.push(contextMessage); - - // Store the context note id and query in metadata - const metadata = { - contextNoteId: noteId - }; - - // Check if the semantic context extraction result has sources - const contextSources = (contextResult as ContextExtractionResult).sources || []; - if (contextSources && contextSources.length > 0) { - // Convert the sources to the format expected by recordSources - const sources = contextSources.map((source) => ({ - noteId: source.noteId, - title: source.title, - similarity: source.similarity, - content: source.content === null ? undefined : source.content - })); - - // Store these sources in metadata - await chatStorageService.recordSources(session.id, sources); - } - - await chatStorageService.updateChat(session.id, session.messages, undefined, metadata); - - return session; - } - - /** - * Get all user's chat sessions - */ - async getAllSessions(): Promise { - // Always fetch the latest data from notes - const chats = await chatStorageService.getAllChats(); - - // Update the cache with the latest data - return chats.map(chat => { - const cachedSession = this.sessionCache.get(chat.id); - - const session: ChatSession = { - id: chat.id, - title: chat.title, - messages: chat.messages, - isStreaming: cachedSession?.isStreaming || false - }; - - // Update the cache - if (cachedSession) { - cachedSession.title = chat.title; - cachedSession.messages = chat.messages; - } else { - this.sessionCache.set(chat.id, session); - } - - return session; - }); - } - - /** - * Delete a chat session - */ - async deleteSession(sessionId: string): Promise { - this.sessionCache.delete(sessionId); - return chatStorageService.deleteChat(sessionId); - } - - /** - * Get pipeline performance metrics - */ - getPipelineMetrics(pipelineType: string = 'default'): unknown { - const pipeline = this.getPipeline(pipelineType); - return pipeline.getMetrics(); - } - - /** - * Reset pipeline metrics - */ - resetPipelineMetrics(pipelineType: string = 'default'): void { - const pipeline = this.getPipeline(pipelineType); - pipeline.resetMetrics(); - } - - /** - * Generate a title from the first messages in a conversation - */ - private generateTitleFromMessages(messages: Message[]): string { - if (messages.length < 2) { - return 'New Chat'; - } - - // Get the first user message - const firstUserMessage = messages.find(m => m.role === 'user'); - if (!firstUserMessage) { - return 'New Chat'; - } - - // Extract first line or first few words - const firstLine = firstUserMessage.content.split('\n')[0].trim(); - - if (firstLine.length <= 30) { - return firstLine; - } - - // Take first 30 chars if too long - return firstLine.substring(0, 27) + '...'; - } - - /** - * Generate a chat completion with a sequence of messages - * @param messages Messages array to send to the AI provider - * @param options Chat completion options - */ - async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise { - log.info(`========== CHAT SERVICE FLOW CHECK ==========`); - log.info(`Entered generateChatCompletion in ChatService`); - log.info(`Using pipeline for chat completion: ${this.getPipeline(options.pipeline).constructor.name}`); - log.info(`Tool support enabled: ${options.enableTools !== false}`); - - try { - // Get AI service - const service = await aiServiceManager.getService(); - if (!service) { - throw new Error('No AI service available'); - } - - log.info(`Using AI service: ${service.getName()}`); - - // Prepare query extraction - const lastUserMessage = [...messages].reverse().find(m => m.role === 'user'); - const query = lastUserMessage ? lastUserMessage.content : undefined; - - // For advanced context processing, use the pipeline - if (options.useAdvancedContext && query) { - log.info(`Using chat pipeline for advanced context with query: ${query.substring(0, 50)}...`); - - // Create a pipeline input with the query and messages - const pipelineInput: ChatPipelineInput = { - messages, - options, - query, - noteId: options.noteId - }; - - // Execute the pipeline - const pipeline = this.getPipeline(options.pipeline); - const response = await pipeline.execute(pipelineInput); - log.info(`Pipeline execution complete, response contains tools: ${response.tool_calls ? 'yes' : 'no'}`); - if (response.tool_calls) { - log.info(`Tool calls in pipeline response: ${response.tool_calls.length}`); - } - return response; - } - - // If not using advanced context, use direct service call - return await service.generateChatCompletion(messages, options); - } catch (error: unknown) { - console.error('Error in generateChatCompletion:', error); - throw error; - } - } - - /** - * Error handler utility - */ - private handleError(error: unknown): string { - if (error instanceof Error) { - return error.message || String(error); - } - return String(error); - } -} - -// Singleton instance -const chatService = new ChatService(); -export default chatService; diff --git a/apps/server/src/services/llm/cleanup_obsolete_files.sh b/apps/server/src/services/llm/cleanup_obsolete_files.sh new file mode 100755 index 000000000..f60a5eff8 --- /dev/null +++ b/apps/server/src/services/llm/cleanup_obsolete_files.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +# Cleanup script for obsolete LLM files after Phase 1 and Phase 2 refactoring +# This script removes files that have been replaced by the simplified architecture + +echo "======================================" +echo "LLM Cleanup Script - Phase 1 & 2" +echo "======================================" +echo "" +echo "This script will remove obsolete files replaced by:" +echo "- Simplified 4-stage pipeline" +echo "- Centralized configuration service" +echo "- New tool format adapter" +echo "" + +# Safety check +read -p "Are you sure you want to remove obsolete LLM files? (y/N): " confirm +if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Cleanup cancelled." + exit 0 +fi + +# Counter for removed files +removed_count=0 + +# Function to safely remove a file +remove_file() { + local file=$1 + if [ -f "$file" ]; then + echo "Removing: $file" + rm "$file" + ((removed_count++)) + else + echo "Already removed or doesn't exist: $file" + fi +} + +echo "" +echo "Starting cleanup..." +echo "" + +# ============================================ +# PIPELINE STAGES - Replaced by simplified_pipeline.ts +# ============================================ +echo "Removing old pipeline stages (replaced by 4-stage simplified pipeline)..." + +# Old 9-stage pipeline implementation +remove_file "pipeline/stages/agent_tools_context_stage.ts" +remove_file "pipeline/stages/context_extraction_stage.ts" +remove_file "pipeline/stages/error_recovery_stage.ts" +remove_file "pipeline/stages/llm_completion_stage.ts" +remove_file "pipeline/stages/message_preparation_stage.ts" +remove_file "pipeline/stages/model_selection_stage.ts" +remove_file "pipeline/stages/response_processing_stage.ts" +remove_file "pipeline/stages/semantic_context_extraction_stage.ts" +remove_file "pipeline/stages/tool_calling_stage.ts" +remove_file "pipeline/stages/user_interaction_stage.ts" + +# Old pipeline base class +remove_file "pipeline/pipeline_stage.ts" + +# Old complex pipeline (replaced by simplified_pipeline.ts) +remove_file "pipeline/chat_pipeline.ts" +remove_file "pipeline/chat_pipeline.spec.ts" + +echo "" + +# ============================================ +# CONFIGURATION - Replaced by configuration_service.ts +# ============================================ +echo "Removing old configuration files (replaced by centralized configuration_service.ts)..." + +# Old configuration helpers are still used, but configuration_manager can be removed if it exists +remove_file "config/configuration_manager.ts" + +echo "" + +# ============================================ +# FORMATTERS - Consolidated into tool_format_adapter.ts +# ============================================ +echo "Removing old formatter files (replaced by tool_format_adapter.ts)..." + +# Old individual formatters if they exist +remove_file "formatters/base_formatter.ts" +remove_file "formatters/openai_formatter.ts" +remove_file "formatters/anthropic_formatter.ts" +remove_file "formatters/ollama_formatter.ts" + +echo "" + +# ============================================ +# DUPLICATE SERVICES - Consolidated +# ============================================ +echo "Removing duplicate service files..." + +# ChatService is replaced by RestChatService with simplified pipeline +remove_file "chat_service.ts" +remove_file "chat_service.spec.ts" + +echo "" + +# ============================================ +# OLD INTERFACES - Check which are still needed +# ============================================ +echo "Checking interfaces..." + +# Note: Some interfaces may still be needed, so we'll be careful here +# The pipeline/interfaces.ts is still used by pipeline_adapter.ts + +echo "" + +# ============================================ +# UNUSED CONTEXT EXTRACTORS +# ============================================ +echo "Checking context extractors..." + +# These might still be used, so let's check first +echo "Note: Context extractors in context_extractors/ may still be in use" +echo "Skipping context_extractors for safety" + +echo "" + +# ============================================ +# REMOVE EMPTY DIRECTORIES +# ============================================ +echo "Removing empty directories..." + +# Remove stages directory if empty +if [ -d "pipeline/stages" ]; then + if [ -z "$(ls -A pipeline/stages)" ]; then + echo "Removing empty directory: pipeline/stages" + rmdir "pipeline/stages" + ((removed_count++)) + fi +fi + +# Remove formatters directory if empty +if [ -d "formatters" ]; then + if [ -z "$(ls -A formatters)" ]; then + echo "Removing empty directory: formatters" + rmdir "formatters" + ((removed_count++)) + fi +fi + +echo "" +echo "======================================" +echo "Cleanup Complete!" +echo "======================================" +echo "Removed $removed_count files/directories" +echo "" +echo "Remaining structure:" +echo "- simplified_pipeline.ts (new 4-stage pipeline)" +echo "- pipeline_adapter.ts (backward compatibility)" +echo "- configuration_service.ts (centralized config)" +echo "- model_registry.ts (model capabilities)" +echo "- logging_service.ts (structured logging)" +echo "- tool_format_adapter.ts (unified tool conversion)" +echo "" +echo "Note: The pipeline_adapter.ts provides backward compatibility" +echo "until all references to the old pipeline are updated." \ No newline at end of file diff --git a/apps/server/src/services/llm/config/configuration_manager.ts b/apps/server/src/services/llm/config/configuration_manager.ts deleted file mode 100644 index 6b879eed6..000000000 --- a/apps/server/src/services/llm/config/configuration_manager.ts +++ /dev/null @@ -1,309 +0,0 @@ -import options from '../../options.js'; -import log from '../../log.js'; -import type { - AIConfig, - ProviderPrecedenceConfig, - ModelIdentifier, - ModelConfig, - ProviderType, - ConfigValidationResult, - ProviderSettings, - OpenAISettings, - AnthropicSettings, - OllamaSettings -} from '../interfaces/configuration_interfaces.js'; - -/** - * Configuration manager that handles conversion from string-based options - * to proper typed configuration objects. - * - * This is the ONLY place where string parsing should happen for LLM configurations. - */ -export class ConfigurationManager { - private static instance: ConfigurationManager | null = null; - - private constructor() {} - - public static getInstance(): ConfigurationManager { - if (!ConfigurationManager.instance) { - ConfigurationManager.instance = new ConfigurationManager(); - } - return ConfigurationManager.instance; - } - - /** - * Get the complete AI configuration - always fresh, no caching - */ - public async getAIConfig(): Promise { - try { - const config: AIConfig = { - enabled: await this.getAIEnabled(), - selectedProvider: await this.getSelectedProvider(), - defaultModels: await this.getDefaultModels(), - providerSettings: await this.getProviderSettings() - }; - - return config; - } catch (error) { - log.error(`Error loading AI configuration: ${error}`); - return this.getDefaultConfig(); - } - } - - /** - * Get the selected AI provider - */ - public async getSelectedProvider(): Promise { - try { - const selectedProvider = options.getOption('aiSelectedProvider'); - return selectedProvider as ProviderType || null; - } catch (error) { - log.error(`Error getting selected provider: ${error}`); - return null; - } - } - - - /** - * Parse model identifier with optional provider prefix - * Handles formats like "gpt-4", "openai:gpt-4", "ollama:llama2:7b" - */ - public parseModelIdentifier(modelString: string): ModelIdentifier { - if (!modelString) { - return { - modelId: '', - fullIdentifier: '' - }; - } - - const parts = modelString.split(':'); - - if (parts.length === 1) { - // No provider prefix, just model name - return { - modelId: modelString, - fullIdentifier: modelString - }; - } - - // Check if first part is a known provider - const potentialProvider = parts[0].toLowerCase(); - const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama']; - - if (knownProviders.includes(potentialProvider as ProviderType)) { - // Provider prefix format - const provider = potentialProvider as ProviderType; - const modelId = parts.slice(1).join(':'); // Rejoin in case model has colons - - return { - provider, - modelId, - fullIdentifier: modelString - }; - } - - // Not a provider prefix, treat whole string as model name - return { - modelId: modelString, - fullIdentifier: modelString - }; - } - - /** - * Create model configuration from string - */ - public createModelConfig(modelString: string, defaultProvider?: ProviderType): ModelConfig { - const identifier = this.parseModelIdentifier(modelString); - const provider = identifier.provider || defaultProvider || 'openai'; - - return { - provider, - modelId: identifier.modelId, - displayName: identifier.fullIdentifier - }; - } - - /** - * Get default models for each provider - ONLY from user configuration - */ - public async getDefaultModels(): Promise> { - try { - const openaiModel = options.getOption('openaiDefaultModel'); - const anthropicModel = options.getOption('anthropicDefaultModel'); - const ollamaModel = options.getOption('ollamaDefaultModel'); - - return { - openai: openaiModel || undefined, - anthropic: anthropicModel || undefined, - ollama: ollamaModel || undefined - }; - } catch (error) { - log.error(`Error loading default models: ${error}`); - // Return undefined for all providers if we can't load config - return { - openai: undefined, - anthropic: undefined, - ollama: undefined - }; - } - } - - /** - * Get provider-specific settings - */ - public async getProviderSettings(): Promise { - try { - const openaiApiKey = options.getOption('openaiApiKey'); - const openaiBaseUrl = options.getOption('openaiBaseUrl'); - const openaiDefaultModel = options.getOption('openaiDefaultModel'); - const anthropicApiKey = options.getOption('anthropicApiKey'); - const anthropicBaseUrl = options.getOption('anthropicBaseUrl'); - const anthropicDefaultModel = options.getOption('anthropicDefaultModel'); - const ollamaBaseUrl = options.getOption('ollamaBaseUrl'); - const ollamaDefaultModel = options.getOption('ollamaDefaultModel'); - - const settings: ProviderSettings = {}; - - if (openaiApiKey || openaiBaseUrl || openaiDefaultModel) { - settings.openai = { - apiKey: openaiApiKey, - baseUrl: openaiBaseUrl, - defaultModel: openaiDefaultModel - }; - } - - if (anthropicApiKey || anthropicBaseUrl || anthropicDefaultModel) { - settings.anthropic = { - apiKey: anthropicApiKey, - baseUrl: anthropicBaseUrl, - defaultModel: anthropicDefaultModel - }; - } - - if (ollamaBaseUrl || ollamaDefaultModel) { - settings.ollama = { - baseUrl: ollamaBaseUrl, - defaultModel: ollamaDefaultModel - }; - } - - return settings; - } catch (error) { - log.error(`Error loading provider settings: ${error}`); - return {}; - } - } - - /** - * Validate configuration - */ - public async validateConfig(): Promise { - const result: ConfigValidationResult = { - isValid: true, - errors: [], - warnings: [] - }; - - try { - const config = await this.getAIConfig(); - - if (!config.enabled) { - result.warnings.push('AI features are disabled'); - return result; - } - - // Validate selected provider - if (!config.selectedProvider) { - result.errors.push('No AI provider selected'); - result.isValid = false; - } else { - // Validate selected provider settings - const providerConfig = config.providerSettings[config.selectedProvider]; - - if (config.selectedProvider === 'openai') { - const openaiConfig = providerConfig as OpenAISettings | undefined; - if (!openaiConfig?.apiKey) { - result.warnings.push('OpenAI API key is not configured'); - } - } - - if (config.selectedProvider === 'anthropic') { - const anthropicConfig = providerConfig as AnthropicSettings | undefined; - if (!anthropicConfig?.apiKey) { - result.warnings.push('Anthropic API key is not configured'); - } - } - - if (config.selectedProvider === 'ollama') { - const ollamaConfig = providerConfig as OllamaSettings | undefined; - if (!ollamaConfig?.baseUrl) { - result.warnings.push('Ollama base URL is not configured'); - } - } - } - - - } catch (error) { - result.errors.push(`Configuration validation error: ${error}`); - result.isValid = false; - } - - return result; - } - - // Private helper methods - - private async getAIEnabled(): Promise { - try { - return options.getOptionBool('aiEnabled'); - } catch { - return false; - } - } - - private parseProviderList(precedenceOption: string | null): string[] { - if (!precedenceOption) { - // Don't assume any defaults - return empty array - return []; - } - - try { - // Handle JSON array format - if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) { - const parsed = JSON.parse(precedenceOption); - if (Array.isArray(parsed)) { - return parsed.map(p => String(p).trim()); - } - } - - // Handle comma-separated format - if (precedenceOption.includes(',')) { - return precedenceOption.split(',').map(p => p.trim()); - } - - // Handle single provider - return [precedenceOption.trim()]; - - } catch (error) { - log.error(`Error parsing provider list "${precedenceOption}": ${error}`); - // Don't assume defaults on parse error - return []; - } - } - - private getDefaultConfig(): AIConfig { - return { - enabled: false, - selectedProvider: null, - defaultModels: { - openai: undefined, - anthropic: undefined, - ollama: undefined - }, - providerSettings: {} - }; - } -} - -// Export singleton instance -export default ConfigurationManager.getInstance(); diff --git a/apps/server/src/services/llm/formatters/base_formatter.ts b/apps/server/src/services/llm/formatters/base_formatter.ts deleted file mode 100644 index fe4c97f42..000000000 --- a/apps/server/src/services/llm/formatters/base_formatter.ts +++ /dev/null @@ -1,131 +0,0 @@ -import sanitizeHtml from 'sanitize-html'; -import type { Message } from '../ai_interface.js'; -import type { MessageFormatter } from '../interfaces/message_formatter.js'; -import { DEFAULT_SYSTEM_PROMPT, PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; -import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - HTML_TRANSFORMS, - HTML_TO_MARKDOWN_PATTERNS, - HTML_ENTITY_REPLACEMENTS, - ENCODING_FIXES, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; - -/** - * Base formatter with common functionality for all providers - * Provider-specific formatters should extend this class - */ -export abstract class BaseMessageFormatter implements MessageFormatter { - /** - * Format messages for the LLM API - * Each provider should override this method with its specific formatting logic - */ - abstract formatMessages(messages: Message[], systemPrompt?: string, context?: string): Message[]; - - /** - * Get the maximum recommended context length for this provider - * Each provider should override this with appropriate value - */ - abstract getMaxContextLength(): number; - - /** - * Get the default system prompt - * Uses the default prompt from constants - */ - protected getDefaultSystemPrompt(systemPrompt?: string): string { - return systemPrompt || DEFAULT_SYSTEM_PROMPT || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO; - } - - /** - * Clean context content - common method with standard HTML cleaning - * Provider-specific formatters can override for custom behavior - */ - cleanContextContent(content: string): string { - if (!content) return ''; - - try { - // First fix any encoding issues - const fixedContent = this.fixEncodingIssues(content); - - // Convert HTML to markdown for better readability - const cleaned = sanitizeHtml(fixedContent, { - allowedTags: HTML_ALLOWED_TAGS.STANDARD, - allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD, - transformTags: HTML_TRANSFORMS.STANDARD - }); - - // Process inline elements to markdown - let markdown = cleaned; - - // Apply all HTML to Markdown patterns - const patterns = HTML_TO_MARKDOWN_PATTERNS; - for (const pattern of Object.values(patterns)) { - markdown = markdown.replace(pattern.pattern, pattern.replacement); - } - - // Process list items - markdown = this.processListItems(markdown); - - // Fix common HTML entities - const entityPatterns = HTML_ENTITY_REPLACEMENTS; - for (const pattern of Object.values(entityPatterns)) { - markdown = markdown.replace(pattern.pattern, pattern.replacement); - } - - return markdown.trim(); - } catch (error) { - console.error(FORMATTER_LOGS.ERROR.CONTEXT_CLEANING("Base"), error); - return content; // Return original if cleaning fails - } - } - - /** - * Process HTML list items in markdown conversion - * This is a helper method that safely processes HTML list items - */ - protected processListItems(content: string): string { - // Process unordered lists - let result = content.replace(/]*>([\s\S]*?)<\/ul>/gi, (match: string, listContent: string) => { - return listContent.replace(/]*>([\s\S]*?)<\/li>/gi, '- $1\n'); - }); - - // Process ordered lists - result = result.replace(/]*>([\s\S]*?)<\/ol>/gi, (match: string, listContent: string) => { - let index = 1; - return listContent.replace(/]*>([\s\S]*?)<\/li>/gi, (itemMatch: string, item: string) => { - return `${index++}. ${item}\n`; - }); - }); - - return result; - } - - /** - * Fix common encoding issues in content - * This fixes issues like broken quote characters and other encoding problems - * - * @param content The content to fix encoding issues in - * @returns Content with encoding issues fixed - */ - protected fixEncodingIssues(content: string): string { - if (!content) return ''; - - try { - // Fix common encoding issues - let fixed = content.replace(ENCODING_FIXES.BROKEN_QUOTES.pattern, ENCODING_FIXES.BROKEN_QUOTES.replacement); - - // Fix other common broken unicode - fixed = fixed.replace(/[\u{0080}-\u{FFFF}]/gu, (match) => { - // Use replacements from constants - const replacements = ENCODING_FIXES.UNICODE_REPLACEMENTS; - return replacements[match as keyof typeof replacements] || match; - }); - - return fixed; - } catch (error) { - console.error(FORMATTER_LOGS.ERROR.ENCODING, error); - return content; // Return original if fixing fails - } - } -} diff --git a/apps/server/src/services/llm/formatters/ollama_formatter.ts b/apps/server/src/services/llm/formatters/ollama_formatter.ts deleted file mode 100644 index eb780f760..000000000 --- a/apps/server/src/services/llm/formatters/ollama_formatter.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type { Message } from '../ai_interface.js'; -import { BaseMessageFormatter } from './base_formatter.js'; -import sanitizeHtml from 'sanitize-html'; -import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; -import { LLM_CONSTANTS } from '../constants/provider_constants.js'; -import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - OLLAMA_CLEANING, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; -import log from '../../log.js'; - -/** - * Ollama-specific message formatter - * Handles the unique requirements of the Ollama API - */ -export class OllamaMessageFormatter extends BaseMessageFormatter { - /** - * Maximum recommended context length for Ollama - * Smaller than other providers due to Ollama's handling of context - */ - private static MAX_CONTEXT_LENGTH = LLM_CONSTANTS.CONTEXT_WINDOW.OLLAMA; - - /** - * Format messages for the Ollama API - * @param messages Messages to format - * @param systemPrompt Optional system prompt to use - * @param context Optional context to include - * @param preserveSystemPrompt When true, preserves existing system messages rather than replacing them - */ - formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean, useTools?: boolean): Message[] { - const formattedMessages: Message[] = []; - - // Log the input messages with all their properties - log.info(`Ollama formatter received ${messages.length} messages`); - messages.forEach((msg, index) => { - const msgKeys = Object.keys(msg); - log.info(`Message ${index} - role: ${msg.role}, keys: ${msgKeys.join(', ')}, content length: ${msg.content.length}`); - - // Log special properties if present - if (msg.tool_calls) { - log.info(`Message ${index} has ${msg.tool_calls.length} tool_calls`); - } - if (msg.tool_call_id) { - log.info(`Message ${index} has tool_call_id: ${msg.tool_call_id}`); - } - if (msg.name) { - log.info(`Message ${index} has name: ${msg.name}`); - } - }); - - // First identify user, system, and tool messages - const systemMessages = messages.filter(msg => msg.role === 'system'); - const nonSystemMessages = messages.filter(msg => msg.role !== 'system'); - - // Determine if we should preserve the existing system message - if (preserveSystemPrompt && systemMessages.length > 0) { - // Preserve the existing system message - formattedMessages.push(systemMessages[0]); - log.info(`Preserving existing system message: ${systemMessages[0].content.substring(0, 50)}...`); - } else { - // Use provided systemPrompt or default - let basePrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO; - - // Check if any message has tool_calls or if useTools flag is set, indicating this is a tool-using conversation - const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0); - const hasToolResults = messages.some(msg => msg.role === 'tool'); - const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults; - - // Add tool instructions for Ollama when tools are being used - if (isToolUsingConversation && PROVIDER_PROMPTS.OLLAMA.TOOL_INSTRUCTIONS) { - log.info('Adding tool instructions to system prompt for Ollama'); - basePrompt = `${basePrompt}\n\n${PROVIDER_PROMPTS.OLLAMA.TOOL_INSTRUCTIONS}`; - } - - formattedMessages.push({ - role: 'system', - content: basePrompt - }); - log.info(`Using new system message: ${basePrompt.substring(0, 50)}...`); - } - - // If we have context, inject it into the first user message - if (context && nonSystemMessages.length > 0) { - let injectedContext = false; - - for (let i = 0; i < nonSystemMessages.length; i++) { - const msg = nonSystemMessages[i]; - - if (msg.role === 'user' && !injectedContext) { - // Simple context injection directly in the user's message - const cleanedContext = this.cleanContextContent(context); - log.info(`Injecting context (${cleanedContext.length} chars) into user message`); - - const formattedContext = PROVIDER_PROMPTS.OLLAMA.CONTEXT_INJECTION( - cleanedContext, - msg.content - ); - - // Log what properties we're preserving - const msgKeys = Object.keys(msg); - const preservedKeys = msgKeys.filter(key => key !== 'role' && key !== 'content'); - log.info(`Preserving additional properties in user message: ${preservedKeys.join(', ')}`); - - // Create a new message with all original properties, but updated content - const newMessage = { - ...msg, // Copy all properties - content: formattedContext // Override content with injected context - }; - - formattedMessages.push(newMessage); - log.info(`Created user message with context, final keys: ${Object.keys(newMessage).join(', ')}`); - - injectedContext = true; - } else { - // For other messages, preserve all properties including any tool-related ones - log.info(`Preserving message with role ${msg.role}, keys: ${Object.keys(msg).join(', ')}`); - - formattedMessages.push({ - ...msg // Copy all properties - }); - } - } - } else { - // No context, just add all messages as-is - // Make sure to preserve all properties including tool_calls, tool_call_id, etc. - for (const msg of nonSystemMessages) { - log.info(`Adding message with role ${msg.role} without context injection, keys: ${Object.keys(msg).join(', ')}`); - formattedMessages.push({ - ...msg // Copy all properties - }); - } - } - - // Log the final formatted messages - log.info(`Ollama formatter produced ${formattedMessages.length} formatted messages`); - formattedMessages.forEach((msg, index) => { - const msgKeys = Object.keys(msg); - log.info(`Formatted message ${index} - role: ${msg.role}, keys: ${msgKeys.join(', ')}, content length: ${msg.content.length}`); - - // Log special properties if present - if (msg.tool_calls) { - log.info(`Formatted message ${index} has ${msg.tool_calls.length} tool_calls`); - } - if (msg.tool_call_id) { - log.info(`Formatted message ${index} has tool_call_id: ${msg.tool_call_id}`); - } - if (msg.name) { - log.info(`Formatted message ${index} has name: ${msg.name}`); - } - }); - - return formattedMessages; - } - - /** - * Clean up HTML and other problematic content before sending to Ollama - * Ollama needs a more aggressive cleaning than other models, - * but we want to preserve our XML tags for context - */ - override cleanContextContent(content: string): string { - if (!content) return ''; - - try { - // Define regexes for identifying and preserving tagged content - const notesTagsRegex = /<\/?notes>/g; - // const queryTagsRegex = /<\/?query>/g; // Commenting out unused variable - - // Capture tags to restore later - const noteTagPositions: number[] = []; - let match; - const regex = /<\/?note>/g; - while ((match = regex.exec(content)) !== null) { - noteTagPositions.push(match.index); - } - - // Remember the notes tags - const notesTagPositions: number[] = []; - while ((match = notesTagsRegex.exec(content)) !== null) { - notesTagPositions.push(match.index); - } - - // Remember the query tag - - // Temporarily replace XML tags with markers that won't be affected by sanitization - const modified = content - .replace(//g, '[NOTE_START]') - .replace(/<\/note>/g, '[NOTE_END]') - .replace(//g, '[NOTES_START]') - .replace(/<\/notes>/g, '[NOTES_END]') - .replace(/(.*?)<\/query>/g, '[QUERY]$1[/QUERY]'); - - // First use the parent class to do standard cleaning - const sanitized = super.cleanContextContent(modified); - - // Then apply Ollama-specific aggressive cleaning - // Remove any remaining HTML using sanitizeHtml while keeping our markers - let plaintext = sanitizeHtml(sanitized, { - allowedTags: HTML_ALLOWED_TAGS.NONE, - allowedAttributes: HTML_ALLOWED_ATTRIBUTES.NONE, - textFilter: (text) => text - }); - - // Apply all Ollama-specific cleaning patterns - const ollamaPatterns = OLLAMA_CLEANING; - for (const pattern of Object.values(ollamaPatterns)) { - plaintext = plaintext.replace(pattern.pattern, pattern.replacement); - } - - // Restore our XML tags - plaintext = plaintext - .replace(/\[NOTE_START\]/g, '') - .replace(/\[NOTE_END\]/g, '') - .replace(/\[NOTES_START\]/g, '') - .replace(/\[NOTES_END\]/g, '') - .replace(/\[QUERY\](.*?)\[\/QUERY\]/g, '$1'); - - return plaintext.trim(); - } catch (error) { - console.error(FORMATTER_LOGS.ERROR.CONTEXT_CLEANING("Ollama"), error); - return content; // Return original if cleaning fails - } - } - - /** - * Get the maximum recommended context length for Ollama - */ - getMaxContextLength(): number { - return OllamaMessageFormatter.MAX_CONTEXT_LENGTH; - } -} diff --git a/apps/server/src/services/llm/formatters/openai_formatter.ts b/apps/server/src/services/llm/formatters/openai_formatter.ts deleted file mode 100644 index d09a3675a..000000000 --- a/apps/server/src/services/llm/formatters/openai_formatter.ts +++ /dev/null @@ -1,143 +0,0 @@ -import sanitizeHtml from 'sanitize-html'; -import type { Message } from '../ai_interface.js'; -import { BaseMessageFormatter } from './base_formatter.js'; -import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; -import { LLM_CONSTANTS } from '../constants/provider_constants.js'; -import { - HTML_ALLOWED_TAGS, - HTML_ALLOWED_ATTRIBUTES, - HTML_TO_MARKDOWN_PATTERNS, - HTML_ENTITY_REPLACEMENTS, - FORMATTER_LOGS -} from '../constants/formatter_constants.js'; -import log from '../../log.js'; - -/** - * OpenAI-specific message formatter - * Optimized for OpenAI's API requirements and preferences - */ -export class OpenAIMessageFormatter extends BaseMessageFormatter { - /** - * Maximum recommended context length for OpenAI - * Based on GPT-4 context window size - */ - private static MAX_CONTEXT_LENGTH = LLM_CONSTANTS.CONTEXT_WINDOW.OPENAI; - - /** - * Format messages for the OpenAI API - * @param messages The messages to format - * @param systemPrompt Optional system prompt to use - * @param context Optional context to include - * @param preserveSystemPrompt When true, preserves existing system messages - * @param useTools Flag indicating if tools will be used in this request - */ - formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean, useTools?: boolean): Message[] { - const formattedMessages: Message[] = []; - - // Check if we already have a system message - const hasSystemMessage = messages.some(msg => msg.role === 'system'); - const userAssistantMessages = messages.filter(msg => msg.role === 'user' || msg.role === 'assistant'); - - // If we have explicit context, format it properly - if (context) { - // For OpenAI, it's best to put context in the system message - const formattedContext = PROVIDER_PROMPTS.OPENAI.SYSTEM_WITH_CONTEXT( - this.cleanContextContent(context) - ); - - // Add as system message - formattedMessages.push({ - role: 'system', - content: formattedContext - }); - } - // If we don't have explicit context but have a system prompt - else if (!hasSystemMessage && systemPrompt) { - let baseSystemPrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO; - - // Check if this is a tool-using conversation - const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0); - const hasToolResults = messages.some(msg => msg.role === 'tool'); - const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults; - - // Add tool instructions for OpenAI when tools are being used - if (isToolUsingConversation && PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS) { - log.info('Adding tool instructions to system prompt for OpenAI'); - baseSystemPrompt = `${baseSystemPrompt}\n\n${PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS}`; - } - - formattedMessages.push({ - role: 'system', - content: baseSystemPrompt - }); - } - // If neither context nor system prompt is provided, use default system prompt - else if (!hasSystemMessage) { - formattedMessages.push({ - role: 'system', - content: this.getDefaultSystemPrompt(systemPrompt) - }); - } - // Otherwise if there are existing system messages, keep them - else if (hasSystemMessage) { - // Keep any existing system messages - const systemMessages = messages.filter(msg => msg.role === 'system'); - for (const msg of systemMessages) { - formattedMessages.push({ - role: 'system', - content: this.cleanContextContent(msg.content) - }); - } - } - - // Add all user and assistant messages - for (const msg of userAssistantMessages) { - formattedMessages.push({ - role: msg.role, - content: msg.content - }); - } - - console.log(FORMATTER_LOGS.OPENAI.PROCESSED(messages.length, formattedMessages.length)); - return formattedMessages; - } - - /** - * Clean context content for OpenAI - * OpenAI handles HTML better than Ollama but still benefits from some cleaning - */ - override cleanContextContent(content: string): string { - if (!content) return ''; - - try { - // Convert HTML to Markdown for better readability - const cleaned = sanitizeHtml(content, { - allowedTags: HTML_ALLOWED_TAGS.STANDARD, - allowedAttributes: HTML_ALLOWED_ATTRIBUTES.STANDARD - }); - - // Apply all HTML to Markdown patterns - let markdown = cleaned; - for (const pattern of Object.values(HTML_TO_MARKDOWN_PATTERNS)) { - markdown = markdown.replace(pattern.pattern, pattern.replacement); - } - - // Fix common HTML entities - for (const pattern of Object.values(HTML_ENTITY_REPLACEMENTS)) { - markdown = markdown.replace(pattern.pattern, pattern.replacement); - } - - return markdown.trim(); - } catch (error) { - console.error(FORMATTER_LOGS.ERROR.CONTEXT_CLEANING("OpenAI"), error); - return content; // Return original if cleaning fails - } - } - - /** - * Get the maximum recommended context length for OpenAI - */ - getMaxContextLength(): number { - return OpenAIMessageFormatter.MAX_CONTEXT_LENGTH; - } -} diff --git a/apps/server/src/services/llm/pipeline/chat_pipeline.spec.ts b/apps/server/src/services/llm/pipeline/chat_pipeline.spec.ts deleted file mode 100644 index 68eb814c1..000000000 --- a/apps/server/src/services/llm/pipeline/chat_pipeline.spec.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ChatPipeline } from './chat_pipeline.js'; -import type { ChatPipelineInput, ChatPipelineConfig } from './interfaces.js'; -import type { Message, ChatResponse } from '../ai_interface.js'; - -// Mock all pipeline stages as classes that can be instantiated -vi.mock('./stages/context_extraction_stage.js', () => { - class MockContextExtractionStage { - execute = vi.fn().mockResolvedValue({}); - } - return { ContextExtractionStage: MockContextExtractionStage }; -}); - -vi.mock('./stages/semantic_context_extraction_stage.js', () => { - class MockSemanticContextExtractionStage { - execute = vi.fn().mockResolvedValue({ - context: '' - }); - } - return { SemanticContextExtractionStage: MockSemanticContextExtractionStage }; -}); - -vi.mock('./stages/agent_tools_context_stage.js', () => { - class MockAgentToolsContextStage { - execute = vi.fn().mockResolvedValue({}); - } - return { AgentToolsContextStage: MockAgentToolsContextStage }; -}); - -vi.mock('./stages/message_preparation_stage.js', () => { - class MockMessagePreparationStage { - execute = vi.fn().mockResolvedValue({ - messages: [{ role: 'user', content: 'Hello' }] - }); - } - return { MessagePreparationStage: MockMessagePreparationStage }; -}); - -vi.mock('./stages/model_selection_stage.js', () => { - class MockModelSelectionStage { - execute = vi.fn().mockResolvedValue({ - options: { - provider: 'openai', - model: 'gpt-4', - enableTools: true, - stream: false - } - }); - } - return { ModelSelectionStage: MockModelSelectionStage }; -}); - -vi.mock('./stages/llm_completion_stage.js', () => { - class MockLLMCompletionStage { - execute = vi.fn().mockResolvedValue({ - response: { - text: 'Hello! How can I help you?', - role: 'assistant', - finish_reason: 'stop' - } - }); - } - return { LLMCompletionStage: MockLLMCompletionStage }; -}); - -vi.mock('./stages/response_processing_stage.js', () => { - class MockResponseProcessingStage { - execute = vi.fn().mockResolvedValue({ - text: 'Hello! How can I help you?' - }); - } - return { ResponseProcessingStage: MockResponseProcessingStage }; -}); - -vi.mock('./stages/tool_calling_stage.js', () => { - class MockToolCallingStage { - execute = vi.fn().mockResolvedValue({ - needsFollowUp: false, - messages: [] - }); - } - return { ToolCallingStage: MockToolCallingStage }; -}); - -vi.mock('../tools/tool_registry.js', () => ({ - default: { - getTools: vi.fn().mockReturnValue([]), - executeTool: vi.fn() - } -})); - -vi.mock('../tools/tool_initializer.js', () => ({ - default: { - initializeTools: vi.fn().mockResolvedValue(undefined) - } -})); - -vi.mock('../ai_service_manager.js', () => ({ - default: { - getService: vi.fn().mockReturnValue({ - decomposeQuery: vi.fn().mockResolvedValue({ - subQueries: [{ text: 'test query' }], - complexity: 3 - }) - }) - } -})); - -vi.mock('../context/services/query_processor.js', () => ({ - default: { - decomposeQuery: vi.fn().mockResolvedValue({ - subQueries: [{ text: 'test query' }], - complexity: 3 - }) - } -})); - -vi.mock('../constants/search_constants.js', () => ({ - SEARCH_CONSTANTS: { - TOOL_EXECUTION: { - MAX_TOOL_CALL_ITERATIONS: 5 - } - } -})); - -vi.mock('../../log.js', () => ({ - default: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn() - } -})); - -describe('ChatPipeline', () => { - let pipeline: ChatPipeline; - - beforeEach(() => { - vi.clearAllMocks(); - pipeline = new ChatPipeline(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should initialize with default configuration', () => { - expect(pipeline.config).toEqual({ - enableStreaming: true, - enableMetrics: true, - maxToolCallIterations: 5 - }); - }); - - it('should accept custom configuration', () => { - const customConfig: Partial = { - enableStreaming: false, - maxToolCallIterations: 5 - }; - - const customPipeline = new ChatPipeline(customConfig); - - expect(customPipeline.config).toEqual({ - enableStreaming: false, - enableMetrics: true, - maxToolCallIterations: 5 - }); - }); - - it('should initialize all pipeline stages', () => { - expect(pipeline.stages.contextExtraction).toBeDefined(); - expect(pipeline.stages.semanticContextExtraction).toBeDefined(); - expect(pipeline.stages.agentToolsContext).toBeDefined(); - expect(pipeline.stages.messagePreparation).toBeDefined(); - expect(pipeline.stages.modelSelection).toBeDefined(); - expect(pipeline.stages.llmCompletion).toBeDefined(); - expect(pipeline.stages.responseProcessing).toBeDefined(); - expect(pipeline.stages.toolCalling).toBeDefined(); - }); - - it('should initialize metrics', () => { - expect(pipeline.metrics).toEqual({ - totalExecutions: 0, - averageExecutionTime: 0, - stageMetrics: { - contextExtraction: { - totalExecutions: 0, - averageExecutionTime: 0 - }, - semanticContextExtraction: { - totalExecutions: 0, - averageExecutionTime: 0 - }, - agentToolsContext: { - totalExecutions: 0, - averageExecutionTime: 0 - }, - messagePreparation: { - totalExecutions: 0, - averageExecutionTime: 0 - }, - modelSelection: { - totalExecutions: 0, - averageExecutionTime: 0 - }, - llmCompletion: { - totalExecutions: 0, - averageExecutionTime: 0 - }, - responseProcessing: { - totalExecutions: 0, - averageExecutionTime: 0 - }, - toolCalling: { - totalExecutions: 0, - averageExecutionTime: 0 - } - } - }); - }); - }); - - describe('execute', () => { - const messages: Message[] = [ - { role: 'user', content: 'Hello' } - ]; - - const input: ChatPipelineInput = { - query: 'Hello', - messages, - options: { - useAdvancedContext: true // Enable advanced context to trigger full pipeline flow - }, - noteId: 'note-123' - }; - - it('should execute all pipeline stages in order', async () => { - const result = await pipeline.execute(input); - - // Get the mock instances from the pipeline stages - expect(pipeline.stages.modelSelection.execute).toHaveBeenCalled(); - expect(pipeline.stages.messagePreparation.execute).toHaveBeenCalled(); - expect(pipeline.stages.llmCompletion.execute).toHaveBeenCalled(); - expect(pipeline.stages.responseProcessing.execute).toHaveBeenCalled(); - - expect(result).toEqual({ - text: 'Hello! How can I help you?', - role: 'assistant', - finish_reason: 'stop' - }); - }); - - it('should increment total executions metric', async () => { - const initialExecutions = pipeline.metrics.totalExecutions; - - await pipeline.execute(input); - - expect(pipeline.metrics.totalExecutions).toBe(initialExecutions + 1); - }); - - it('should handle streaming callback', async () => { - const streamCallback = vi.fn(); - const inputWithStream = { ...input, streamCallback }; - - await pipeline.execute(inputWithStream); - - expect(pipeline.stages.llmCompletion.execute).toHaveBeenCalled(); - }); - - it('should handle tool calling iterations', async () => { - // Mock LLM response to include tool calls - (pipeline.stages.llmCompletion.execute as any).mockResolvedValue({ - response: { - text: 'Hello! How can I help you?', - role: 'assistant', - finish_reason: 'stop', - tool_calls: [{ id: 'tool1', function: { name: 'search', arguments: '{}' } }] - } - }); - - // Mock tool calling to require iteration then stop - (pipeline.stages.toolCalling.execute as any) - .mockResolvedValueOnce({ needsFollowUp: true, messages: [] }) - .mockResolvedValueOnce({ needsFollowUp: false, messages: [] }); - - await pipeline.execute(input); - - expect(pipeline.stages.toolCalling.execute).toHaveBeenCalledTimes(2); - }); - - it('should respect max tool call iterations', async () => { - // Mock LLM response to include tool calls - (pipeline.stages.llmCompletion.execute as any).mockResolvedValue({ - response: { - text: 'Hello! How can I help you?', - role: 'assistant', - finish_reason: 'stop', - tool_calls: [{ id: 'tool1', function: { name: 'search', arguments: '{}' } }] - } - }); - - // Mock tool calling to always require iteration - (pipeline.stages.toolCalling.execute as any).mockResolvedValue({ needsFollowUp: true, messages: [] }); - - await pipeline.execute(input); - - // Should be called maxToolCallIterations times (5 iterations as configured) - expect(pipeline.stages.toolCalling.execute).toHaveBeenCalledTimes(5); - }); - - it('should handle stage errors gracefully', async () => { - (pipeline.stages.modelSelection.execute as any).mockRejectedValueOnce(new Error('Model selection failed')); - - await expect(pipeline.execute(input)).rejects.toThrow('Model selection failed'); - }); - - it('should pass context between stages', async () => { - await pipeline.execute(input); - - // Check that stage was called (the actual context passing is tested in integration) - expect(pipeline.stages.messagePreparation.execute).toHaveBeenCalled(); - }); - - it('should handle empty messages', async () => { - const emptyInput = { ...input, messages: [] }; - - const result = await pipeline.execute(emptyInput); - - expect(result).toBeDefined(); - expect(pipeline.stages.modelSelection.execute).toHaveBeenCalled(); - }); - - it('should calculate content length for model selection', async () => { - await pipeline.execute(input); - - expect(pipeline.stages.modelSelection.execute).toHaveBeenCalledWith( - expect.objectContaining({ - contentLength: expect.any(Number) - }) - ); - }); - - it('should update average execution time', async () => { - const initialAverage = pipeline.metrics.averageExecutionTime; - - await pipeline.execute(input); - - expect(pipeline.metrics.averageExecutionTime).toBeGreaterThanOrEqual(0); - }); - - it('should disable streaming when config is false', async () => { - const noStreamPipeline = new ChatPipeline({ enableStreaming: false }); - - await noStreamPipeline.execute(input); - - expect(noStreamPipeline.stages.llmCompletion.execute).toHaveBeenCalled(); - }); - - it('should handle concurrent executions', async () => { - const promise1 = pipeline.execute(input); - const promise2 = pipeline.execute(input); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - expect(result1).toBeDefined(); - expect(result2).toBeDefined(); - expect(pipeline.metrics.totalExecutions).toBe(2); - }); - }); - - describe('metrics', () => { - const input: ChatPipelineInput = { - query: 'Hello', - messages: [{ role: 'user', content: 'Hello' }], - options: { - useAdvancedContext: true - }, - noteId: 'note-123' - }; - - it('should track stage execution times when metrics enabled', async () => { - await pipeline.execute(input); - - expect(pipeline.metrics.stageMetrics.modelSelection.totalExecutions).toBe(1); - expect(pipeline.metrics.stageMetrics.llmCompletion.totalExecutions).toBe(1); - }); - - it('should skip stage metrics when disabled', async () => { - const noMetricsPipeline = new ChatPipeline({ enableMetrics: false }); - - await noMetricsPipeline.execute(input); - - // Total executions is still tracked, but stage metrics are not updated - expect(noMetricsPipeline.metrics.totalExecutions).toBe(1); - expect(noMetricsPipeline.metrics.stageMetrics.modelSelection.totalExecutions).toBe(0); - expect(noMetricsPipeline.metrics.stageMetrics.llmCompletion.totalExecutions).toBe(0); - }); - }); - - describe('error handling', () => { - const input: ChatPipelineInput = { - query: 'Hello', - messages: [{ role: 'user', content: 'Hello' }], - options: { - useAdvancedContext: true - }, - noteId: 'note-123' - }; - - it('should propagate errors from stages', async () => { - (pipeline.stages.modelSelection.execute as any).mockRejectedValueOnce(new Error('Model selection failed')); - - await expect(pipeline.execute(input)).rejects.toThrow('Model selection failed'); - }); - - it('should handle invalid input gracefully', async () => { - const invalidInput = { - query: '', - messages: [], - options: {}, - noteId: '' - }; - - const result = await pipeline.execute(invalidInput); - - expect(result).toBeDefined(); - }); - }); -}); \ No newline at end of file diff --git a/apps/server/src/services/llm/pipeline/chat_pipeline.ts b/apps/server/src/services/llm/pipeline/chat_pipeline.ts deleted file mode 100644 index 06dc35274..000000000 --- a/apps/server/src/services/llm/pipeline/chat_pipeline.ts +++ /dev/null @@ -1,986 +0,0 @@ -import type { ChatPipelineInput, ChatPipelineConfig, PipelineMetrics, StreamCallback } from './interfaces.js'; -import type { ChatResponse, StreamChunk, Message } from '../ai_interface.js'; -import { ContextExtractionStage } from './stages/context_extraction_stage.js'; -import { SemanticContextExtractionStage } from './stages/semantic_context_extraction_stage.js'; -import { AgentToolsContextStage } from './stages/agent_tools_context_stage.js'; -import { MessagePreparationStage } from './stages/message_preparation_stage.js'; -import { ModelSelectionStage } from './stages/model_selection_stage.js'; -import { LLMCompletionStage } from './stages/llm_completion_stage.js'; -import { ResponseProcessingStage } from './stages/response_processing_stage.js'; -import { ToolCallingStage } from './stages/tool_calling_stage.js'; -import { ErrorRecoveryStage } from './stages/error_recovery_stage.js'; -// Traditional search is used instead of vector search -import toolRegistry from '../tools/tool_registry.js'; -import toolInitializer from '../tools/tool_initializer.js'; -import log from '../../log.js'; -import type { LLMServiceInterface } from '../interfaces/agent_tool_interfaces.js'; -import { SEARCH_CONSTANTS } from '../constants/search_constants.js'; - -/** - * Pipeline for managing the entire chat flow - * Implements a modular, composable architecture where each stage is a separate component - */ -export class ChatPipeline { - stages: { - contextExtraction: ContextExtractionStage; - semanticContextExtraction: SemanticContextExtractionStage; - agentToolsContext: AgentToolsContextStage; - messagePreparation: MessagePreparationStage; - modelSelection: ModelSelectionStage; - llmCompletion: LLMCompletionStage; - responseProcessing: ResponseProcessingStage; - toolCalling: ToolCallingStage; - errorRecovery: ErrorRecoveryStage; - // traditional search is used instead of vector search - }; - - config: ChatPipelineConfig; - metrics: PipelineMetrics; - - /** - * Create a new chat pipeline - * @param config Optional pipeline configuration - */ - constructor(config?: Partial) { - // Initialize all pipeline stages - this.stages = { - contextExtraction: new ContextExtractionStage(), - semanticContextExtraction: new SemanticContextExtractionStage(), - agentToolsContext: new AgentToolsContextStage(), - messagePreparation: new MessagePreparationStage(), - modelSelection: new ModelSelectionStage(), - llmCompletion: new LLMCompletionStage(), - responseProcessing: new ResponseProcessingStage(), - toolCalling: new ToolCallingStage(), - errorRecovery: new ErrorRecoveryStage(), - // traditional search is used instead of vector search - }; - - // Set default configuration values - this.config = { - enableStreaming: true, - enableMetrics: true, - maxToolCallIterations: SEARCH_CONSTANTS.TOOL_EXECUTION.MAX_TOOL_CALL_ITERATIONS, - ...config - }; - - // Initialize metrics - this.metrics = { - totalExecutions: 0, - averageExecutionTime: 0, - stageMetrics: {} - }; - - // Initialize stage metrics - Object.keys(this.stages).forEach(stageName => { - this.metrics.stageMetrics[stageName] = { - totalExecutions: 0, - averageExecutionTime: 0 - }; - }); - } - - /** - * Execute the chat pipeline - * This is the main entry point that orchestrates all pipeline stages - */ - async execute(input: ChatPipelineInput): Promise { - log.info(`========== STARTING CHAT PIPELINE ==========`); - log.info(`Executing chat pipeline with ${input.messages.length} messages`); - const startTime = Date.now(); - this.metrics.totalExecutions++; - - // Initialize streaming handler if requested - let streamCallback = input.streamCallback; - let accumulatedText = ''; - - try { - // Extract content length for model selection - let contentLength = 0; - for (const message of input.messages) { - contentLength += message.content.length; - } - - // Initialize tools if needed - try { - const toolCount = toolRegistry.getAllTools().length; - - // If there are no tools registered, initialize them - if (toolCount === 0) { - log.info('No tools found in registry, initializing tools...'); - // Tools are already initialized in the AIServiceManager constructor - // No need to initialize them again - log.info(`Tools initialized, now have ${toolRegistry.getAllTools().length} tools`); - } else { - log.info(`Found ${toolCount} tools already registered`); - } - } catch (error: any) { - log.error(`Error checking/initializing tools: ${error.message || String(error)}`); - } - - // First, select the appropriate model based on query complexity and content length - const modelSelectionStartTime = Date.now(); - log.info(`========== MODEL SELECTION ==========`); - const modelSelection = await this.stages.modelSelection.execute({ - options: input.options, - query: input.query, - contentLength - }); - this.updateStageMetrics('modelSelection', modelSelectionStartTime); - log.info(`Selected model: ${modelSelection.options.model || 'default'}, enableTools: ${modelSelection.options.enableTools}`); - - // Determine if we should use tools or semantic context - const useTools = modelSelection.options.enableTools === true; - const useEnhancedContext = input.options?.useAdvancedContext === true; - - // Log details about the advanced context parameter - log.info(`Enhanced context option check: input.options=${JSON.stringify(input.options || {})}`); - log.info(`Enhanced context decision: useEnhancedContext=${useEnhancedContext}, hasQuery=${!!input.query}`); - - // Early return if we don't have a query or enhanced context is disabled - if (!input.query || !useEnhancedContext) { - log.info(`========== SIMPLE QUERY MODE ==========`); - log.info('Enhanced context disabled or no query provided, skipping context enrichment'); - - // Prepare messages without additional context - const messagePreparationStartTime = Date.now(); - const preparedMessages = await this.stages.messagePreparation.execute({ - messages: input.messages, - systemPrompt: input.options?.systemPrompt, - options: modelSelection.options - }); - this.updateStageMetrics('messagePreparation', messagePreparationStartTime); - - // Generate completion using the LLM - const llmStartTime = Date.now(); - const completion = await this.stages.llmCompletion.execute({ - messages: preparedMessages.messages, - options: modelSelection.options - }); - this.updateStageMetrics('llmCompletion', llmStartTime); - - return completion.response; - } - - // STAGE 1: Start with the user's query - const userQuery = input.query || ''; - log.info(`========== STAGE 1: USER QUERY ==========`); - log.info(`Processing query with: question="${userQuery.substring(0, 50)}...", noteId=${input.noteId}, showThinking=${input.showThinking}`); - - // STAGE 2: Perform query decomposition using the LLM - log.info(`========== STAGE 2: QUERY DECOMPOSITION ==========`); - log.info('Performing query decomposition to generate effective search queries'); - const llmService = await this.getLLMService(); - let searchQueries = [userQuery]; - - if (llmService) { - try { - // Import the query processor and use its decomposeQuery method - const queryProcessor = (await import('../context/services/query_processor.js')).default; - - // Use the enhanced query processor with the LLM service - const decomposedQuery = await queryProcessor.decomposeQuery(userQuery, undefined, llmService); - - if (decomposedQuery && decomposedQuery.subQueries && decomposedQuery.subQueries.length > 0) { - // Extract search queries from the decomposed query - searchQueries = decomposedQuery.subQueries.map(sq => sq.text); - - // Always include the original query if it's not already included - if (!searchQueries.includes(userQuery)) { - searchQueries.unshift(userQuery); - } - - log.info(`Query decomposed with complexity ${decomposedQuery.complexity}/10 into ${searchQueries.length} search queries`); - } else { - log.info('Query decomposition returned no sub-queries, using original query'); - } - } catch (error: any) { - log.error(`Error in query decomposition: ${error.message || String(error)}`); - } - } else { - log.info('No LLM service available for query decomposition, using original query'); - } - - // STAGE 3: Vector search has been removed - skip semantic search - const vectorSearchStartTime = Date.now(); - log.info(`========== STAGE 3: VECTOR SEARCH (DISABLED) ==========`); - log.info('Vector search has been removed - LLM will rely on tool calls for context'); - - // Create empty vector search result since vector search is disabled - const vectorSearchResult = { - searchResults: [], - totalResults: 0, - executionTime: Date.now() - vectorSearchStartTime - }; - - // Skip metrics update for disabled vector search functionality - log.info(`Vector search disabled - using tool-based context extraction instead`); - - // Extract context from search results - log.info(`========== SEMANTIC CONTEXT EXTRACTION ==========`); - const semanticContextStartTime = Date.now(); - const semanticContext = await this.stages.semanticContextExtraction.execute({ - noteId: input.noteId || 'global', - query: userQuery, - messages: input.messages, - searchResults: vectorSearchResult.searchResults - }); - - const context = semanticContext.context; - this.updateStageMetrics('semanticContextExtraction', semanticContextStartTime); - log.info(`Extracted semantic context (${context.length} chars)`); - - // STAGE 4: Prepare messages with context and tool definitions for the LLM - log.info(`========== STAGE 4: MESSAGE PREPARATION ==========`); - const messagePreparationStartTime = Date.now(); - const preparedMessages = await this.stages.messagePreparation.execute({ - messages: input.messages, - context, - systemPrompt: input.options?.systemPrompt, - options: modelSelection.options - }); - this.updateStageMetrics('messagePreparation', messagePreparationStartTime); - log.info(`Prepared ${preparedMessages.messages.length} messages for LLM, tools enabled: ${useTools}`); - - // Setup streaming handler if streaming is enabled and callback provided - // Check if streaming should be enabled based on several conditions - const streamEnabledInConfig = this.config.enableStreaming; - const streamFormatRequested = input.format === 'stream'; - const streamRequestedInOptions = modelSelection.options.stream === true; - const streamCallbackAvailable = typeof streamCallback === 'function'; - - log.info(`[ChatPipeline] Request type info - Format: ${input.format || 'not specified'}, Options from pipelineInput: ${JSON.stringify({stream: input.options?.stream})}`); - log.info(`[ChatPipeline] Stream settings - config.enableStreaming: ${streamEnabledInConfig}, format parameter: ${input.format}, modelSelection.options.stream: ${modelSelection.options.stream}, streamCallback available: ${streamCallbackAvailable}`); - - // IMPORTANT: Respect the existing stream option but with special handling for callbacks: - // 1. If a stream callback is available, streaming MUST be enabled for it to work - // 2. Otherwise, preserve the original stream setting from input options - - // First, determine what the stream value should be based on various factors: - let shouldEnableStream = modelSelection.options.stream; - - if (streamCallbackAvailable) { - // If we have a stream callback, we NEED to enable streaming - // This is critical for GET requests with EventSource - shouldEnableStream = true; - log.info(`[ChatPipeline] Stream callback available, enabling streaming`); - } else if (streamRequestedInOptions) { - // Stream was explicitly requested in options, honor that setting - log.info(`[ChatPipeline] Stream explicitly requested in options: ${streamRequestedInOptions}`); - shouldEnableStream = streamRequestedInOptions; - } else if (streamFormatRequested) { - // Format=stream parameter indicates streaming was requested - log.info(`[ChatPipeline] Stream format requested in parameters`); - shouldEnableStream = true; - } else { - // No explicit streaming indicators, use config default - log.info(`[ChatPipeline] No explicit stream settings, using config default: ${streamEnabledInConfig}`); - shouldEnableStream = streamEnabledInConfig; - } - - // Set the final stream option - modelSelection.options.stream = shouldEnableStream; - - log.info(`[ChatPipeline] Final streaming decision: stream=${shouldEnableStream}, will stream to client=${streamCallbackAvailable && shouldEnableStream}`); - - - // STAGE 5 & 6: Handle LLM completion and tool execution loop - log.info(`========== STAGE 5: LLM COMPLETION ==========`); - const llmStartTime = Date.now(); - const completion = await this.stages.llmCompletion.execute({ - messages: preparedMessages.messages, - options: modelSelection.options - }); - this.updateStageMetrics('llmCompletion', llmStartTime); - log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`); - - // Track whether content has been streamed to prevent duplication - let hasStreamedContent = false; - - // Handle streaming if enabled and available - // Use shouldEnableStream variable which contains our streaming decision - if (shouldEnableStream && completion.response.stream && streamCallback) { - // Setup stream handler that passes chunks through response processing - await completion.response.stream(async (chunk: StreamChunk) => { - // Process the chunk text - const processedChunk = await this.processStreamChunk(chunk, input.options); - - // Accumulate text for final response - accumulatedText += processedChunk.text; - - // Forward to callback with original chunk data in case it contains additional information - streamCallback(processedChunk.text, processedChunk.done, chunk); - - // Mark that we have streamed content to prevent duplication - hasStreamedContent = true; - }); - } - - // Process any tool calls in the response - let currentMessages = preparedMessages.messages; - let currentResponse = completion.response; - let toolCallIterations = 0; - const maxToolCallIterations = this.config.maxToolCallIterations; - - // Check if tools were enabled in the options - const toolsEnabled = modelSelection.options.enableTools !== false; - - // Log decision points for tool execution - log.info(`========== TOOL EXECUTION DECISION ==========`); - log.info(`Tools enabled in options: ${toolsEnabled}`); - log.info(`Response provider: ${currentResponse.provider || 'unknown'}`); - log.info(`Response model: ${currentResponse.model || 'unknown'}`); - - // Enhanced tool_calls detection - check both direct property and getter - let hasToolCalls = false; - - log.info(`[TOOL CALL DEBUG] Starting tool call detection for provider: ${currentResponse.provider}`); - // Check response object structure - log.info(`[TOOL CALL DEBUG] Response properties: ${Object.keys(currentResponse).join(', ')}`); - - // Try to access tool_calls as a property - if ('tool_calls' in currentResponse) { - log.info(`[TOOL CALL DEBUG] tool_calls exists as a direct property`); - log.info(`[TOOL CALL DEBUG] tool_calls type: ${typeof currentResponse.tool_calls}`); - - if (currentResponse.tool_calls && Array.isArray(currentResponse.tool_calls)) { - log.info(`[TOOL CALL DEBUG] tool_calls is an array with length: ${currentResponse.tool_calls.length}`); - } else { - log.info(`[TOOL CALL DEBUG] tool_calls is not an array or is empty: ${JSON.stringify(currentResponse.tool_calls)}`); - } - } else { - log.info(`[TOOL CALL DEBUG] tool_calls does not exist as a direct property`); - } - - // First check the direct property - if (currentResponse.tool_calls && currentResponse.tool_calls.length > 0) { - hasToolCalls = true; - log.info(`Response has tool_calls property with ${currentResponse.tool_calls.length} tools`); - log.info(`Tool calls details: ${JSON.stringify(currentResponse.tool_calls)}`); - } - // Check if it might be a getter (for dynamic tool_calls collection) - else { - log.info(`[TOOL CALL DEBUG] Direct property check failed, trying getter approach`); - try { - const toolCallsDesc = Object.getOwnPropertyDescriptor(currentResponse, 'tool_calls'); - - if (toolCallsDesc) { - log.info(`[TOOL CALL DEBUG] Found property descriptor for tool_calls: ${JSON.stringify({ - configurable: toolCallsDesc.configurable, - enumerable: toolCallsDesc.enumerable, - hasGetter: !!toolCallsDesc.get, - hasSetter: !!toolCallsDesc.set - })}`); - } else { - log.info(`[TOOL CALL DEBUG] No property descriptor found for tool_calls`); - } - - if (toolCallsDesc && typeof toolCallsDesc.get === 'function') { - log.info(`[TOOL CALL DEBUG] Attempting to call the tool_calls getter`); - const dynamicToolCalls = toolCallsDesc.get.call(currentResponse); - - log.info(`[TOOL CALL DEBUG] Getter returned: ${JSON.stringify(dynamicToolCalls)}`); - - if (dynamicToolCalls && dynamicToolCalls.length > 0) { - hasToolCalls = true; - log.info(`Response has dynamic tool_calls with ${dynamicToolCalls.length} tools`); - log.info(`Dynamic tool calls details: ${JSON.stringify(dynamicToolCalls)}`); - // Ensure property is available for subsequent code - currentResponse.tool_calls = dynamicToolCalls; - log.info(`[TOOL CALL DEBUG] Updated currentResponse.tool_calls with dynamic values`); - } else { - log.info(`[TOOL CALL DEBUG] Getter returned no valid tool calls`); - } - } else { - log.info(`[TOOL CALL DEBUG] No getter function found for tool_calls`); - } - } catch (e: any) { - log.error(`Error checking dynamic tool_calls: ${e}`); - log.error(`[TOOL CALL DEBUG] Error details: ${e.stack || 'No stack trace'}`); - } - } - - log.info(`Response has tool_calls: ${hasToolCalls ? 'true' : 'false'}`); - if (hasToolCalls && currentResponse.tool_calls) { - log.info(`[TOOL CALL DEBUG] Final tool_calls that will be used: ${JSON.stringify(currentResponse.tool_calls)}`); - } - - // Tool execution loop - if (toolsEnabled && hasToolCalls && currentResponse.tool_calls) { - log.info(`========== STAGE 6: TOOL EXECUTION ==========`); - log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`); - - // Format tool calls for logging - log.info(`========== TOOL CALL DETAILS ==========`); - currentResponse.tool_calls.forEach((toolCall, idx) => { - log.info(`Tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`); - log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`); - }); - - // Keep track of whether we're in a streaming response - const isStreaming = shouldEnableStream && streamCallback; - let streamingPaused = false; - - // If streaming was enabled, send an update to the user - if (isStreaming && streamCallback) { - streamingPaused = true; - // Send a dedicated message with a specific type for tool execution - streamCallback('', false, { - text: '', - done: false, - toolExecution: { - type: 'start', - tool: { - name: 'tool_execution', - arguments: {} - } - } - }); - } - - while (toolCallIterations < maxToolCallIterations) { - toolCallIterations++; - log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`); - - // Create a copy of messages before tool execution - const previousMessages = [...currentMessages]; - - try { - const toolCallingStartTime = Date.now(); - log.info(`========== PIPELINE TOOL EXECUTION FLOW ==========`); - log.info(`About to call toolCalling.execute with ${currentResponse.tool_calls.length} tool calls`); - log.info(`Tool calls being passed to stage: ${JSON.stringify(currentResponse.tool_calls)}`); - - const toolCallingResult = await this.stages.toolCalling.execute({ - response: currentResponse, - messages: currentMessages, - options: modelSelection.options - }); - this.updateStageMetrics('toolCalling', toolCallingStartTime); - - log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`); - - // Update messages with tool results - currentMessages = toolCallingResult.messages; - - // Log the tool results for debugging - const toolResultMessages = currentMessages.filter( - msg => msg.role === 'tool' && !previousMessages.includes(msg) - ); - - log.info(`========== TOOL EXECUTION RESULTS ==========`); - log.info(`Received ${toolResultMessages.length} tool results`); - toolResultMessages.forEach((msg, idx) => { - log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content}`); - log.info(`Tool result status: ${msg.content.startsWith('Error:') ? 'ERROR' : 'SUCCESS'}`); - log.info(`Tool result for: ${this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '')}`); - - // If streaming, show tool executions to the user - if (isStreaming && streamCallback) { - // For each tool result, format a readable message for the user - const toolName = this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || ''); - - // Create a structured tool result message - // The client will receive this structured data and can display it properly - try { - // Parse the result content if it's JSON - let parsedContent = msg.content; - try { - // Check if the content is JSON - if (msg.content.trim().startsWith('{') || msg.content.trim().startsWith('[')) { - parsedContent = JSON.parse(msg.content); - } - } catch (e) { - // If parsing fails, keep the original content - log.info(`Could not parse tool result as JSON: ${e}`); - } - - // Send the structured tool result directly so the client has the raw data - streamCallback('', false, { - text: '', - done: false, - toolExecution: { - type: 'complete', - tool: { - name: toolName, - arguments: {} - }, - result: parsedContent - } - }); - - // No longer need to send formatted text version - // The client should use the structured data instead - } catch (err) { - log.error(`Error sending structured tool result: ${err}`); - // Use structured format here too instead of falling back to text format - streamCallback('', false, { - text: '', - done: false, - toolExecution: { - type: 'complete', - tool: { - name: toolName || 'unknown', - arguments: {} - }, - result: msg.content - } - }); - } - } - }); - - // Check if we need another LLM completion for tool results - if (toolCallingResult.needsFollowUp) { - log.info(`========== TOOL FOLLOW-UP REQUIRED ==========`); - log.info('Tool execution complete, sending results back to LLM'); - - // Ensure messages are properly formatted - this.validateToolMessages(currentMessages); - - // If streaming, show progress to the user - if (isStreaming && streamCallback) { - streamCallback('', false, { - text: '', - done: false, - toolExecution: { - type: 'update', - tool: { - name: 'tool_processing', - arguments: {} - } - } - }); - } - - // Extract tool execution status information for Ollama feedback - let toolExecutionStatus; - - if (currentResponse.provider === 'Ollama') { - // Collect tool execution status from the tool results - toolExecutionStatus = toolResultMessages.map(msg => { - // Determine if this was a successful tool call - const isError = msg.content.startsWith('Error:'); - return { - toolCallId: msg.tool_call_id || '', - name: msg.name || 'unknown', - success: !isError, - result: msg.content, - error: isError ? msg.content.substring(7) : undefined - }; - }); - - log.info(`Created tool execution status for Ollama: ${toolExecutionStatus.length} entries`); - toolExecutionStatus.forEach((status, idx) => { - log.info(`Tool status ${idx + 1}: ${status.name} - ${status.success ? 'success' : 'failed'}`); - }); - } - - // Generate a new completion with the updated messages - const followUpStartTime = Date.now(); - - // Log messages being sent to LLM for tool follow-up - log.info(`========== SENDING TOOL RESULTS TO LLM FOR FOLLOW-UP ==========`); - log.info(`Total messages being sent: ${currentMessages.length}`); - // Log the most recent messages (last 3) for clarity - const recentMessages = currentMessages.slice(-3); - recentMessages.forEach((msg, idx) => { - const position = currentMessages.length - recentMessages.length + idx; - log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`); - if (msg.tool_calls) { - log.info(` Has ${msg.tool_calls.length} tool calls`); - } - if (msg.tool_call_id) { - log.info(` Tool call ID: ${msg.tool_call_id}`); - } - }); - - log.info(`LLM follow-up request options: ${JSON.stringify({ - model: modelSelection.options.model, - enableTools: true, - stream: modelSelection.options.stream, - provider: currentResponse.provider - })}`); - - const followUpCompletion = await this.stages.llmCompletion.execute({ - messages: currentMessages, - options: { - ...modelSelection.options, - // Ensure tool support is still enabled for follow-up requests - enableTools: true, - // Preserve original streaming setting for tool execution follow-ups - stream: modelSelection.options.stream, - // Add tool execution status for Ollama provider - ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {}) - } - }); - this.updateStageMetrics('llmCompletion', followUpStartTime); - - // Log the follow-up response from the LLM - log.info(`========== LLM FOLLOW-UP RESPONSE RECEIVED ==========`); - log.info(`Follow-up response model: ${followUpCompletion.response.model}, provider: ${followUpCompletion.response.provider}`); - log.info(`Follow-up response text: ${followUpCompletion.response.text?.substring(0, 150)}${followUpCompletion.response.text?.length > 150 ? '...' : ''}`); - log.info(`Follow-up contains tool calls: ${!!followUpCompletion.response.tool_calls && followUpCompletion.response.tool_calls.length > 0}`); - if (followUpCompletion.response.tool_calls && followUpCompletion.response.tool_calls.length > 0) { - log.info(`Follow-up has ${followUpCompletion.response.tool_calls.length} new tool calls`); - } - - // Update current response for the next iteration - currentResponse = followUpCompletion.response; - - // Check if we need to continue the tool calling loop - if (!currentResponse.tool_calls || currentResponse.tool_calls.length === 0) { - log.info(`========== TOOL EXECUTION COMPLETE ==========`); - log.info('No more tool calls, breaking tool execution loop'); - break; - } else { - log.info(`========== ADDITIONAL TOOL CALLS DETECTED ==========`); - log.info(`Next iteration has ${currentResponse.tool_calls.length} more tool calls`); - // Log the next set of tool calls - currentResponse.tool_calls.forEach((toolCall, idx) => { - log.info(`Next tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`); - log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`); - }); - } - } else { - log.info(`========== TOOL EXECUTION COMPLETE ==========`); - log.info('No follow-up needed, breaking tool execution loop'); - break; - } - } catch (error: any) { - log.info(`========== TOOL EXECUTION ERROR ==========`); - log.error(`Error in tool execution: ${error.message || String(error)}`); - - // Add error message to the conversation if tool execution fails - currentMessages.push({ - role: 'system', - content: `Error executing tool: ${error.message || String(error)}. Please try a different approach.` - }); - - // If streaming, show error to the user - if (isStreaming && streamCallback) { - streamCallback('', false, { - text: '', - done: false, - toolExecution: { - type: 'error', - tool: { - name: 'unknown', - arguments: {} - }, - result: error.message || 'unknown error' - } - }); - } - - // For Ollama, create tool execution status with the error - let toolExecutionStatus; - if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) { - // We need to create error statuses for all tool calls that failed - toolExecutionStatus = currentResponse.tool_calls.map(toolCall => { - return { - toolCallId: toolCall.id || '', - name: toolCall.function?.name || 'unknown', - success: false, - result: `Error: ${error.message || 'unknown error'}`, - error: error.message || 'unknown error' - }; - }); - - log.info(`Created error tool execution status for Ollama: ${toolExecutionStatus.length} entries`); - } - - // Make a follow-up request to the LLM with the error information - const errorFollowUpCompletion = await this.stages.llmCompletion.execute({ - messages: currentMessages, - options: { - ...modelSelection.options, - // Preserve streaming for error follow-up - stream: modelSelection.options.stream, - // For Ollama, include tool execution status - ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {}) - } - }); - - // Log the error follow-up response from the LLM - log.info(`========== ERROR FOLLOW-UP RESPONSE RECEIVED ==========`); - log.info(`Error follow-up response model: ${errorFollowUpCompletion.response.model}, provider: ${errorFollowUpCompletion.response.provider}`); - log.info(`Error follow-up response text: ${errorFollowUpCompletion.response.text?.substring(0, 150)}${errorFollowUpCompletion.response.text?.length > 150 ? '...' : ''}`); - log.info(`Error follow-up contains tool calls: ${!!errorFollowUpCompletion.response.tool_calls && errorFollowUpCompletion.response.tool_calls.length > 0}`); - - // Update current response and break the tool loop - currentResponse = errorFollowUpCompletion.response; - break; - } - } - - if (toolCallIterations >= maxToolCallIterations) { - log.info(`========== MAXIMUM TOOL ITERATIONS REACHED ==========`); - log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), terminating loop`); - - // Add a message to inform the LLM that we've reached the limit - currentMessages.push({ - role: 'system', - content: `Maximum tool call iterations (${maxToolCallIterations}) reached. Please provide your best response with the information gathered so far.` - }); - - // If streaming, inform the user about iteration limit - if (isStreaming && streamCallback) { - streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false); - } - - // For Ollama, create a status about reaching max iterations - let toolExecutionStatus; - if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) { - // Create a special status message about max iterations - toolExecutionStatus = [ - { - toolCallId: 'max-iterations', - name: 'system', - success: false, - result: `Maximum tool call iterations (${maxToolCallIterations}) reached.`, - error: `Reached the maximum number of allowed tool calls (${maxToolCallIterations}). Please provide a final response with the information gathered so far.` - } - ]; - - log.info(`Created max iterations status for Ollama`); - } - - // Make a final request to get a summary response - const finalFollowUpCompletion = await this.stages.llmCompletion.execute({ - messages: currentMessages, - options: { - ...modelSelection.options, - enableTools: false, // Disable tools for the final response - // Preserve streaming setting for max iterations response - stream: modelSelection.options.stream, - // For Ollama, include tool execution status - ...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {}) - } - }); - - // Update the current response - currentResponse = finalFollowUpCompletion.response; - } - - // If streaming was paused for tool execution, resume it now with the final response - if (isStreaming && streamCallback && streamingPaused) { - // First log for debugging - const responseText = currentResponse.text || ""; - log.info(`Resuming streaming with final response: ${responseText.length} chars`); - - if (responseText.length > 0 && !hasStreamedContent) { - // Resume streaming with the final response text only if we haven't already streamed content - // This is where we send the definitive done:true signal with the complete content - streamCallback(responseText, true); - log.info(`Sent final response with done=true signal and text content`); - } else if (hasStreamedContent) { - log.info(`Content already streamed, sending done=true signal only after tool execution`); - // Just send the done signal without duplicating content - streamCallback('', true); - } else { - // For Anthropic, sometimes text is empty but response is in stream - if ((currentResponse.provider === 'Anthropic' || currentResponse.provider === 'OpenAI') && currentResponse.stream) { - log.info(`Detected empty response text for ${currentResponse.provider} provider with stream, sending stream content directly`); - // For Anthropic/OpenAI with stream mode, we need to stream the final response - if (currentResponse.stream) { - await currentResponse.stream(async (chunk: StreamChunk) => { - // Process the chunk - const processedChunk = await this.processStreamChunk(chunk, input.options); - - // Forward to callback - streamCallback( - processedChunk.text, - processedChunk.done || chunk.done || false, - chunk - ); - }); - log.info(`Completed streaming final ${currentResponse.provider} response after tool execution`); - } - } else { - // Empty response with done=true as fallback - streamCallback('', true); - log.info(`Sent empty final response with done=true signal`); - } - } - } - } else if (toolsEnabled) { - log.info(`========== NO TOOL CALLS DETECTED ==========`); - log.info(`LLM response did not contain any tool calls, skipping tool execution`); - - // Handle streaming for responses without tool calls - if (shouldEnableStream && streamCallback && !hasStreamedContent) { - log.info(`Sending final streaming response without tool calls: ${currentResponse.text.length} chars`); - - // Send the final response with done=true to complete the streaming - streamCallback(currentResponse.text, true); - - log.info(`Sent final non-tool response with done=true signal`); - } else if (shouldEnableStream && streamCallback && hasStreamedContent) { - log.info(`Content already streamed, sending done=true signal only`); - // Just send the done signal without duplicating content - streamCallback('', true); - } - } - - // Process the final response - log.info(`========== FINAL RESPONSE PROCESSING ==========`); - const responseProcessingStartTime = Date.now(); - const processedResponse = await this.stages.responseProcessing.execute({ - response: currentResponse, - options: modelSelection.options - }); - this.updateStageMetrics('responseProcessing', responseProcessingStartTime); - log.info(`Final response processed, returning to user (${processedResponse.text.length} chars)`); - - // Return the final response to the user - // The ResponseProcessingStage returns {text}, not {response} - // So we update our currentResponse with the processed text - currentResponse.text = processedResponse.text; - - log.info(`========== PIPELINE COMPLETE ==========`); - return currentResponse; - } catch (error: any) { - log.info(`========== PIPELINE ERROR ==========`); - log.error(`Error in chat pipeline: ${error.message || String(error)}`); - throw error; - } - } - - /** - * Helper method to get an LLM service for query processing - */ - private async getLLMService(): Promise { - try { - const aiServiceManager = await import('../ai_service_manager.js').then(module => module.default); - return aiServiceManager.getService(); - } catch (error: any) { - log.error(`Error getting LLM service: ${error.message || String(error)}`); - return null; - } - } - - /** - * Process a stream chunk through the response processing stage - */ - private async processStreamChunk(chunk: StreamChunk, options?: any): Promise { - try { - // Only process non-empty chunks - if (!chunk.text) return chunk; - - // Create a minimal response object for the processor - const miniResponse = { - text: chunk.text, - model: 'streaming', - provider: 'streaming' - }; - - // Process the chunk text - const processed = await this.stages.responseProcessing.execute({ - response: miniResponse, - options: options - }); - - // Return processed chunk - return { - ...chunk, - text: processed.text - }; - } catch (error) { - // On error, return original chunk - log.error(`Error processing stream chunk: ${error}`); - return chunk; - } - } - - /** - * Update metrics for a pipeline stage - */ - private updateStageMetrics(stageName: string, startTime: number) { - if (!this.config.enableMetrics) return; - - const executionTime = Date.now() - startTime; - const metrics = this.metrics.stageMetrics[stageName]; - - // Guard against undefined metrics (e.g., for removed stages) - if (!metrics) { - log.info(`WARNING: Attempted to update metrics for unknown stage: ${stageName}`); - return; - } - - metrics.totalExecutions++; - metrics.averageExecutionTime = - (metrics.averageExecutionTime * (metrics.totalExecutions - 1) + executionTime) / - metrics.totalExecutions; - } - - /** - * Get the current pipeline metrics - */ - getMetrics(): PipelineMetrics { - return this.metrics; - } - - /** - * Reset pipeline metrics - */ - resetMetrics(): void { - this.metrics.totalExecutions = 0; - this.metrics.averageExecutionTime = 0; - - Object.keys(this.metrics.stageMetrics).forEach(stageName => { - this.metrics.stageMetrics[stageName] = { - totalExecutions: 0, - averageExecutionTime: 0 - }; - }); - } - - /** - * Find tool name from tool call ID by looking at previous assistant messages - */ - private getToolNameFromToolCallId(messages: Message[], toolCallId: string): string { - if (!toolCallId) return 'unknown'; - - // Look for assistant messages with tool_calls - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message.role === 'assistant' && message.tool_calls) { - // Find the tool call with the matching ID - const toolCall = message.tool_calls.find(tc => tc.id === toolCallId); - if (toolCall && toolCall.function && toolCall.function.name) { - return toolCall.function.name; - } - } - } - - return 'unknown'; - } - - /** - * Validate tool messages to ensure they're properly formatted - */ - private validateToolMessages(messages: Message[]): void { - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; - - // Ensure tool messages have required fields - if (message.role === 'tool') { - if (!message.tool_call_id) { - log.info(`Tool message missing tool_call_id, adding placeholder`); - message.tool_call_id = `tool_${i}`; - } - - // Content should be a string - if (typeof message.content !== 'string') { - log.info(`Tool message content is not a string, converting`); - try { - message.content = JSON.stringify(message.content); - } catch (e) { - message.content = String(message.content); - } - } - } - } - } -} diff --git a/apps/server/src/services/llm/pipeline/pipeline_stage.ts b/apps/server/src/services/llm/pipeline/pipeline_stage.ts deleted file mode 100644 index 68b2daf89..000000000 --- a/apps/server/src/services/llm/pipeline/pipeline_stage.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { PipelineInput, PipelineOutput, PipelineStage } from './interfaces.js'; -import log from '../../log.js'; - -/** - * Abstract base class for pipeline stages - */ -export abstract class BasePipelineStage implements PipelineStage { - name: string; - - constructor(name: string) { - this.name = name; - } - - /** - * Execute the pipeline stage - */ - async execute(input: TInput): Promise { - try { - log.info(`Executing pipeline stage: ${this.name}`); - const startTime = Date.now(); - const result = await this.process(input); - const endTime = Date.now(); - log.info(`Pipeline stage ${this.name} completed in ${endTime - startTime}ms`); - return result; - } catch (error: any) { - log.error(`Error in pipeline stage ${this.name}: ${error.message}`); - throw error; - } - } - - /** - * Process the input and produce output - * This is the main method that each pipeline stage must implement - */ - protected abstract process(input: TInput): Promise; -} diff --git a/apps/server/src/services/llm/pipeline/stages/agent_tools_context_stage.ts b/apps/server/src/services/llm/pipeline/stages/agent_tools_context_stage.ts deleted file mode 100644 index 10f460c4e..000000000 --- a/apps/server/src/services/llm/pipeline/stages/agent_tools_context_stage.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { PipelineInput } from '../interfaces.js'; -import aiServiceManager from '../../ai_service_manager.js'; -import log from '../../../log.js'; - -export interface AgentToolsContextInput { - noteId?: string; - query?: string; - showThinking?: boolean; -} - -export interface AgentToolsContextOutput { - context: string; - noteId: string; - query: string; -} - -/** - * Pipeline stage for adding LLM agent tools context - */ -export class AgentToolsContextStage { - constructor() { - log.info('AgentToolsContextStage initialized'); - } - - /** - * Execute the agent tools context stage - */ - async execute(input: AgentToolsContextInput): Promise { - return this.process(input); - } - - /** - * Process the input and add agent tools context - */ - protected async process(input: AgentToolsContextInput): Promise { - const noteId = input.noteId || 'global'; - const query = input.query || ''; - const showThinking = !!input.showThinking; - - log.info(`AgentToolsContextStage: Getting agent tools context for noteId=${noteId}, query="${query.substring(0, 30)}...", showThinking=${showThinking}`); - - try { - // Use the AI service manager to get agent tools context - const context = await aiServiceManager.getAgentToolsContext(noteId, query, showThinking); - - log.info(`AgentToolsContextStage: Generated agent tools context (${context.length} chars)`); - - return { - context, - noteId, - query - }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`AgentToolsContextStage: Error getting agent tools context: ${errorMessage}`); - throw error; - } - } -} diff --git a/apps/server/src/services/llm/pipeline/stages/context_extraction_stage.ts b/apps/server/src/services/llm/pipeline/stages/context_extraction_stage.ts deleted file mode 100644 index b1eaa69f9..000000000 --- a/apps/server/src/services/llm/pipeline/stages/context_extraction_stage.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { ContextExtractionInput } from '../interfaces.js'; -import aiServiceManager from '../../ai_service_manager.js'; -import log from '../../../log.js'; - -/** - * Context Extraction Pipeline Stage - */ - -export interface ContextExtractionOutput { - context: string; - noteId: string; - query: string; -} - -/** - * Pipeline stage for extracting context from notes - */ -export class ContextExtractionStage { - constructor() { - log.info('ContextExtractionStage initialized'); - } - - /** - * Execute the context extraction stage - */ - async execute(input: ContextExtractionInput): Promise { - return this.process(input); - } - - /** - * Process the input and extract context - */ - protected async process(input: ContextExtractionInput): Promise { - const { useSmartContext = true } = input; - const noteId = input.noteId || 'global'; - const query = input.query || ''; - - log.info(`ContextExtractionStage: Extracting context for noteId=${noteId}, query="${query.substring(0, 30)}..."`); - - try { - let context = ''; - - // Get enhanced context from the context service - const contextService = aiServiceManager.getContextService(); - const llmService = await aiServiceManager.getService(); - - if (contextService) { - // Use unified context service to get smart context - context = await contextService.processQuery( - query, - llmService, - { contextNoteId: noteId } - ).then(result => result.context); - - log.info(`ContextExtractionStage: Generated enhanced context (${context.length} chars)`); - } else { - log.info('ContextExtractionStage: Context service not available, using default context'); - } - - return { - context, - noteId, - query - }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`ContextExtractionStage: Error extracting context: ${errorMessage}`); - throw error; - } - } -} diff --git a/apps/server/src/services/llm/pipeline/stages/error_recovery_stage.ts b/apps/server/src/services/llm/pipeline/stages/error_recovery_stage.ts deleted file mode 100644 index c753a57ad..000000000 --- a/apps/server/src/services/llm/pipeline/stages/error_recovery_stage.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { ToolExecutionInput, StreamCallback } from '../interfaces.js'; -import type { ChatResponse, Message } from '../../ai_interface.js'; -import toolRegistry from '../../tools/tool_registry.js'; -import log from '../../../log.js'; - -interface RetryStrategy { - maxRetries: number; - baseDelay: number; - maxDelay: number; - backoffMultiplier: number; - jitter: boolean; -} - -interface ToolRetryContext { - toolName: string; - attempt: number; - lastError: string; - alternativeApproaches: string[]; - usedApproaches: string[]; -} - -/** - * Advanced Error Recovery Pipeline Stage - * Implements sophisticated retry strategies with exponential backoff, - * alternative tool selection, and intelligent fallback mechanisms - */ -export class ErrorRecoveryStage extends BasePipelineStage { - - private retryStrategies: Map = new Map(); - private activeRetries: Map = new Map(); - - constructor() { - super('ErrorRecovery'); - this.initializeRetryStrategies(); - } - - /** - * Initialize retry strategies for different tool types - */ - private initializeRetryStrategies(): void { - // Search tools - more aggressive retries since they're critical - this.retryStrategies.set('search_notes', { - maxRetries: 3, - baseDelay: 1000, - maxDelay: 8000, - backoffMultiplier: 2, - jitter: true - }); - - this.retryStrategies.set('keyword_search', { - maxRetries: 3, - baseDelay: 800, - maxDelay: 6000, - backoffMultiplier: 2, - jitter: true - }); - - // Read operations - moderate retries - this.retryStrategies.set('read_note', { - maxRetries: 2, - baseDelay: 500, - maxDelay: 3000, - backoffMultiplier: 2, - jitter: false - }); - - // Attribute operations - conservative retries - this.retryStrategies.set('attribute_search', { - maxRetries: 2, - baseDelay: 1200, - maxDelay: 5000, - backoffMultiplier: 1.8, - jitter: true - }); - - // Default strategy for unknown tools - this.retryStrategies.set('default', { - maxRetries: 2, - baseDelay: 1000, - maxDelay: 4000, - backoffMultiplier: 2, - jitter: true - }); - } - - /** - * Process tool execution with advanced error recovery - */ - protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> { - const { response } = input; - - // If no tool calls, pass through - if (!response.tool_calls || response.tool_calls.length === 0) { - return { response, needsFollowUp: false, messages: input.messages }; - } - - log.info(`========== ERROR RECOVERY STAGE PROCESSING ==========`); - log.info(`Processing ${response.tool_calls.length} tool calls with advanced error recovery`); - - const recoveredToolCalls: Array<{ - toolCall: any; - message: Message; - attempts: number; - recovered: boolean; - }> = []; - const updatedMessages = [...input.messages]; - - // Process each tool call with recovery - for (let i = 0; i < response.tool_calls.length; i++) { - const toolCall = response.tool_calls[i]; - const recoveredResult = await this.executeToolWithRecovery(toolCall, input, i); - - if (recoveredResult) { - recoveredToolCalls.push(recoveredResult); - updatedMessages.push(recoveredResult.message); - } - } - - // Create enhanced response with recovery information - const enhancedResponse: ChatResponse = { - ...response, - tool_calls: recoveredToolCalls.map(r => r.toolCall), - recovery_metadata: { - total_attempts: recoveredToolCalls.reduce((sum, r) => sum + r.attempts, 0), - successful_recoveries: recoveredToolCalls.filter(r => r.recovered).length, - failed_permanently: recoveredToolCalls.filter(r => !r.recovered).length - } - }; - - const needsFollowUp = recoveredToolCalls.length > 0; - - log.info(`Recovery complete: ${recoveredToolCalls.filter(r => r.recovered).length}/${recoveredToolCalls.length} tools recovered`); - - return { - response: enhancedResponse, - needsFollowUp, - messages: updatedMessages - }; - } - - /** - * Execute a tool call with comprehensive error recovery - */ - private async executeToolWithRecovery( - toolCall: any, - input: ToolExecutionInput, - index: number - ): Promise<{ toolCall: any, message: Message, attempts: number, recovered: boolean } | null> { - - const toolName = toolCall.function.name; - const strategy = this.retryStrategies.get(toolName) || this.retryStrategies.get('default')!; - - let lastError = ''; - let attempts = 0; - let recovered = false; - - // Initialize retry context - const retryContext: ToolRetryContext = { - toolName, - attempt: 0, - lastError: '', - alternativeApproaches: this.getAlternativeApproaches(toolName), - usedApproaches: [] - }; - - log.info(`Starting error recovery for tool: ${toolName} (max retries: ${strategy.maxRetries})`); - - // Primary execution attempts - for (attempts = 1; attempts <= strategy.maxRetries + 1; attempts++) { - try { - retryContext.attempt = attempts; - - // Add delay for retry attempts (not first attempt) - if (attempts > 1) { - const delay = this.calculateDelay(strategy, attempts - 1); - log.info(`Retry attempt ${attempts - 1} for ${toolName} after ${delay}ms delay`); - await this.sleep(delay); - - // Send retry notification if streaming - if (input.streamCallback) { - this.sendRetryNotification(input.streamCallback, toolName, attempts - 1, strategy.maxRetries); - } - } - - // Execute the tool - const tool = toolRegistry.getTool(toolName); - if (!tool) { - throw new Error(`Tool not found: ${toolName}`); - } - - // Parse arguments - const args = this.parseToolArguments(toolCall.function.arguments); - - // Modify arguments for retry if needed - const modifiedArgs = this.modifyArgsForRetry(args, retryContext); - - log.info(`Executing ${toolName} (attempt ${attempts}) with args: ${JSON.stringify(modifiedArgs)}`); - - const result = await tool.execute(modifiedArgs); - - // Success! - recovered = true; - log.info(`✓ Tool ${toolName} succeeded on attempt ${attempts}`); - - return { - toolCall, - message: { - role: 'tool', - content: typeof result === 'string' ? result : JSON.stringify(result, null, 2), - name: toolName, - tool_call_id: toolCall.id - }, - attempts, - recovered: true - }; - - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - retryContext.lastError = lastError; - - log.info(`✗ Tool ${toolName} failed on attempt ${attempts}: ${lastError}`); - - // If this was the last allowed attempt, break - if (attempts > strategy.maxRetries) { - break; - } - } - } - - // Primary attempts failed, try alternative approaches - log.info(`Primary attempts failed for ${toolName}, trying alternative approaches`); - - for (const alternative of retryContext.alternativeApproaches) { - if (retryContext.usedApproaches.includes(alternative)) { - continue; // Skip already used approaches - } - - try { - log.info(`Trying alternative approach: ${alternative} for ${toolName}`); - retryContext.usedApproaches.push(alternative); - - const alternativeResult = await this.executeAlternativeApproach(alternative, toolCall, retryContext); - - if (alternativeResult) { - log.info(`✓ Alternative approach ${alternative} succeeded for ${toolName}`); - recovered = true; - - return { - toolCall, - message: { - role: 'tool', - content: `ALTERNATIVE_SUCCESS: ${alternative} succeeded where ${toolName} failed. Result: ${alternativeResult}`, - name: toolName, - tool_call_id: toolCall.id - }, - attempts: attempts + 1, - recovered: true - }; - } - } catch (error) { - const altError = error instanceof Error ? error.message : String(error); - log.info(`✗ Alternative approach ${alternative} failed: ${altError}`); - } - } - - // All attempts failed - log.error(`All recovery attempts failed for ${toolName} after ${attempts} attempts and ${retryContext.usedApproaches.length} alternatives`); - - // Return failure message with guidance - const failureGuidance = this.generateFailureGuidance(toolName, lastError, retryContext); - - return { - toolCall, - message: { - role: 'tool', - content: `RECOVERY_FAILED: Tool ${toolName} failed after ${attempts} attempts and ${retryContext.usedApproaches.length} alternative approaches. Last error: ${lastError}\n\n${failureGuidance}`, - name: toolName, - tool_call_id: toolCall.id - }, - attempts, - recovered: false - }; - } - - /** - * Calculate retry delay with exponential backoff and optional jitter - */ - private calculateDelay(strategy: RetryStrategy, retryNumber: number): number { - let delay = strategy.baseDelay * Math.pow(strategy.backoffMultiplier, retryNumber - 1); - - // Apply maximum delay limit - delay = Math.min(delay, strategy.maxDelay); - - // Add jitter if enabled (±25% random variation) - if (strategy.jitter) { - const jitterRange = delay * 0.25; - const jitter = (Math.random() - 0.5) * 2 * jitterRange; - delay += jitter; - } - - return Math.round(Math.max(delay, 100)); // Minimum 100ms delay - } - - /** - * Sleep for specified milliseconds - */ - private sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Get alternative approaches for a tool - */ - private getAlternativeApproaches(toolName: string): string[] { - const alternatives: Record = { - 'search_notes': ['keyword_search', 'broader_search_terms', 'attribute_search'], - 'keyword_search': ['search_notes', 'simplified_query', 'attribute_search'], - 'attribute_search': ['search_notes', 'keyword_search', 'different_attribute_type'], - 'read_note': ['note_by_path', 'search_and_read', 'template_search'], - 'note_by_path': ['read_note', 'search_notes', 'keyword_search'] - }; - - return alternatives[toolName] || ['search_notes', 'keyword_search']; - } - - /** - * Modify arguments for retry attempts - */ - private modifyArgsForRetry(args: Record, context: ToolRetryContext): Record { - const modified = { ...args }; - - // For search tools, broaden the query on retries - if (context.toolName.includes('search') && context.attempt > 1) { - if (modified.query && typeof modified.query === 'string') { - // Remove quotes and qualifiers to broaden the search - modified.query = (modified.query as string) - .replace(/['"]/g, '') // Remove quotes - .replace(/\b(exactly|specific|precise)\b/gi, '') // Remove limiting words - .trim(); - - log.info(`Modified query for retry: "${modified.query}"`); - } - } - - // For attribute search, try different attribute types - if (context.toolName === 'attribute_search' && context.attempt > 1) { - if (modified.attributeType === 'label') { - modified.attributeType = 'relation'; - } else if (modified.attributeType === 'relation') { - modified.attributeType = 'label'; - } - - log.info(`Modified attributeType for retry: ${modified.attributeType}`); - } - - return modified; - } - - /** - * Execute alternative approach - */ - private async executeAlternativeApproach( - approach: string, - originalToolCall: any, - context: ToolRetryContext - ): Promise { - - switch (approach) { - case 'broader_search_terms': - return await this.executeBroaderSearch(originalToolCall); - - case 'simplified_query': - return await this.executeSimplifiedSearch(originalToolCall); - - case 'different_attribute_type': - return await this.executeDifferentAttributeSearch(originalToolCall); - - case 'search_and_read': - return await this.executeSearchAndRead(originalToolCall); - - default: - // Try to execute the alternative tool directly - return await this.executeAlternativeTool(approach, originalToolCall); - } - } - - /** - * Execute broader search approach - */ - private async executeBroaderSearch(toolCall: any): Promise { - const args = this.parseToolArguments(toolCall.function.arguments); - - if (args.query && typeof args.query === 'string') { - // Extract the main keywords and search more broadly - const keywords = (args.query as string) - .split(' ') - .filter(word => word.length > 3) - .slice(0, 3) // Take only first 3 main keywords - .join(' '); - - const broadArgs = { ...args, query: keywords }; - - const tool = toolRegistry.getTool('search_notes'); - if (tool) { - const result = await tool.execute(broadArgs); - return typeof result === 'string' ? result : JSON.stringify(result); - } - } - - return null; - } - - /** - * Execute simplified search approach - */ - private async executeSimplifiedSearch(toolCall: any): Promise { - const args = this.parseToolArguments(toolCall.function.arguments); - - if (args.query && typeof args.query === 'string') { - // Use only the first word as a very simple search - const firstWord = (args.query as string).split(' ')[0]; - const simpleArgs = { ...args, query: firstWord }; - - const tool = toolRegistry.getTool('keyword_search'); - if (tool) { - const result = await tool.execute(simpleArgs); - return typeof result === 'string' ? result : JSON.stringify(result); - } - } - - return null; - } - - /** - * Execute different attribute search - */ - private async executeDifferentAttributeSearch(toolCall: any): Promise { - const args = this.parseToolArguments(toolCall.function.arguments); - - if (args.attributeType) { - const newType = args.attributeType === 'label' ? 'relation' : 'label'; - const newArgs = { ...args, attributeType: newType }; - - const tool = toolRegistry.getTool('attribute_search'); - if (tool) { - const result = await tool.execute(newArgs); - return typeof result === 'string' ? result : JSON.stringify(result); - } - } - - return null; - } - - /** - * Execute search and read approach - */ - private async executeSearchAndRead(toolCall: any): Promise { - const args = this.parseToolArguments(toolCall.function.arguments); - - // First search for notes - const searchTool = toolRegistry.getTool('search_notes'); - if (searchTool && args.query) { - try { - const searchResult = await searchTool.execute({ query: args.query }); - - // Try to extract note IDs and read the first one - const searchText = typeof searchResult === 'string' ? searchResult : JSON.stringify(searchResult); - const noteIdMatch = searchText.match(/note[:\s]+([a-zA-Z0-9]+)/i); - - if (noteIdMatch && noteIdMatch[1]) { - const readTool = toolRegistry.getTool('read_note'); - if (readTool) { - const readResult = await readTool.execute({ noteId: noteIdMatch[1] }); - return `SEARCH_AND_READ: Found and read note ${noteIdMatch[1]}. Content: ${readResult}`; - } - } - - return `SEARCH_ONLY: ${searchText}`; - } catch (error) { - return null; - } - } - - return null; - } - - /** - * Execute alternative tool - */ - private async executeAlternativeTool(toolName: string, originalToolCall: any): Promise { - const tool = toolRegistry.getTool(toolName); - if (!tool) { - return null; - } - - const args = this.parseToolArguments(originalToolCall.function.arguments); - - try { - const result = await tool.execute(args); - return typeof result === 'string' ? result : JSON.stringify(result); - } catch (error) { - return null; - } - } - - /** - * Parse tool arguments safely - */ - private parseToolArguments(args: string | Record): Record { - if (typeof args === 'string') { - try { - return JSON.parse(args); - } catch { - return { query: args }; - } - } - return args; - } - - /** - * Send retry notification via streaming - */ - private sendRetryNotification( - streamCallback: StreamCallback, - toolName: string, - retryNumber: number, - maxRetries: number - ): void { - streamCallback('', false, { - text: '', - done: false, - toolExecution: { - type: 'retry', - action: 'retry', - tool: { name: toolName, arguments: {} }, - progress: { - current: retryNumber, - total: maxRetries, - status: 'retrying', - message: `Retrying ${toolName} (attempt ${retryNumber}/${maxRetries})...` - } - } - }); - } - - /** - * Generate failure guidance - */ - private generateFailureGuidance(toolName: string, lastError: string, context: ToolRetryContext): string { - const guidance = [ - `RECOVERY ANALYSIS for ${toolName}:`, - `- Primary attempts: ${context.attempt}`, - `- Alternative approaches tried: ${context.usedApproaches.join(', ') || 'none'}`, - `- Last error: ${lastError}`, - '', - 'SUGGESTED NEXT STEPS:', - '- Try manual search with broader terms', - '- Check if the requested information exists', - '- Use discover_tools to find alternative tools', - '- Reformulate the query with different keywords' - ]; - - return guidance.join('\n'); - } -} \ No newline at end of file diff --git a/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts b/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts deleted file mode 100644 index 3fb5fd91e..000000000 --- a/apps/server/src/services/llm/pipeline/stages/llm_completion_stage.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { LLMCompletionInput } from '../interfaces.js'; -import type { ChatCompletionOptions, ChatResponse, StreamChunk } from '../../ai_interface.js'; -import aiServiceManager from '../../ai_service_manager.js'; -import toolRegistry from '../../tools/tool_registry.js'; -import log from '../../../log.js'; - -/** - * Pipeline stage for LLM completion with enhanced streaming support - */ -export class LLMCompletionStage extends BasePipelineStage { - constructor() { - super('LLMCompletion'); - } - - /** - * Generate LLM completion using the AI service - * - * This enhanced version supports better streaming by forwarding raw provider data - * and ensuring consistent handling of stream options. - */ - protected async process(input: LLMCompletionInput): Promise<{ response: ChatResponse }> { - const { messages, options } = input; - - // Add detailed logging about the input messages, particularly useful for tool follow-ups - log.info(`========== LLM COMPLETION STAGE - INPUT MESSAGES ==========`); - log.info(`Total input messages: ${messages.length}`); - - // Log if tool messages are present (used for follow-ups) - const toolMessages = messages.filter(m => m.role === 'tool'); - if (toolMessages.length > 0) { - log.info(`Contains ${toolMessages.length} tool result messages - likely a tool follow-up request`); - } - - // Log the last few messages to understand conversation context - const lastMessages = messages.slice(-3); - lastMessages.forEach((msg, idx) => { - const msgPosition = messages.length - lastMessages.length + idx; - log.info(`Message ${msgPosition} (${msg.role}): ${msg.content?.substring(0, 150)}${msg.content?.length > 150 ? '...' : ''}`); - if (msg.tool_calls) { - log.info(` Contains ${msg.tool_calls.length} tool calls`); - } - if (msg.tool_call_id) { - log.info(` Tool call ID: ${msg.tool_call_id}`); - } - }); - - // Log completion options - log.info(`LLM completion options: ${JSON.stringify({ - model: options.model || 'default', - temperature: options.temperature, - enableTools: options.enableTools, - stream: options.stream, - hasToolExecutionStatus: !!options.toolExecutionStatus - })}`); - - // Create a deep copy of options to avoid modifying the original - const updatedOptions: ChatCompletionOptions = JSON.parse(JSON.stringify(options)); - - // Handle stream option explicitly - if (options.stream !== undefined) { - updatedOptions.stream = options.stream === true; - log.info(`[LLMCompletionStage] Stream explicitly set to: ${updatedOptions.stream}`); - } - - // Add capture of raw provider data for streaming - if (updatedOptions.stream) { - // Add a function to capture raw provider data in stream chunks - const originalStreamCallback = updatedOptions.streamCallback; - updatedOptions.streamCallback = async (text, done, rawProviderData) => { - // Create an enhanced chunk with the raw provider data - const enhancedChunk = { - text, - done, - // Include raw provider data if available - raw: rawProviderData - }; - - // Call the original callback if provided - if (originalStreamCallback) { - return originalStreamCallback(text, done, enhancedChunk); - } - }; - } - - // Check if tools should be enabled - if (updatedOptions.enableTools !== false) { - const toolDefinitions = toolRegistry.getAllToolDefinitions(); - if (toolDefinitions.length > 0) { - updatedOptions.enableTools = true; - updatedOptions.tools = toolDefinitions; - log.info(`========== ADDING TOOLS TO LLM REQUEST ==========`); - log.info(`Tool count: ${toolDefinitions.length}`); - log.info(`Tool names: ${toolDefinitions.map(t => t.function.name).join(', ')}`); - log.info(`enableTools option: ${updatedOptions.enableTools}`); - log.info(`===============================================`); - } else { - log.error(`========== NO TOOLS AVAILABLE FOR LLM ==========`); - log.error(`Tool registry returned 0 definitions`); - log.error(`This means the LLM will NOT have access to tools`); - log.error(`Check tool initialization and registration`); - log.error(`==============================================`); - } - } else { - log.info(`Tools explicitly disabled (enableTools: ${updatedOptions.enableTools})`); - } - - // Determine which provider to use - let selectedProvider = ''; - if (updatedOptions.providerMetadata?.provider) { - selectedProvider = updatedOptions.providerMetadata.provider; - log.info(`Using provider ${selectedProvider} from metadata for model ${updatedOptions.model}`); - } - - log.info(`Generating LLM completion, provider: ${selectedProvider || 'auto'}, model: ${updatedOptions?.model || 'default'}`); - - // Use specific provider if available - if (selectedProvider && aiServiceManager.isProviderAvailable(selectedProvider)) { - const service = await aiServiceManager.getService(selectedProvider); - log.info(`[LLMCompletionStage] Using specific service for ${selectedProvider}`); - - // Generate completion and wrap with enhanced stream handling - const response = await service.generateChatCompletion(messages, updatedOptions); - - // If streaming is enabled, enhance the stream method - if (response.stream && typeof response.stream === 'function' && updatedOptions.stream) { - const originalStream = response.stream; - - // Replace the stream method with an enhanced version that captures and forwards raw data - response.stream = async (callback) => { - return originalStream(async (chunk) => { - // Forward the chunk with any additional provider-specific data - // Create an enhanced chunk with provider info - const enhancedChunk: StreamChunk = { - ...chunk, - // If the provider didn't include raw data, add minimal info - raw: chunk.raw || { - provider: selectedProvider, - model: response.model - } - }; - return callback(enhancedChunk); - }); - }; - } - - // Add enhanced logging for debugging tool execution follow-ups - if (toolMessages.length > 0) { - if (response.tool_calls && response.tool_calls.length > 0) { - log.info(`Response contains ${response.tool_calls.length} tool calls`); - response.tool_calls.forEach((toolCall: any, idx: number) => { - log.info(`Tool call ${idx + 1}: ${toolCall.function?.name || 'unnamed'}`); - const args = typeof toolCall.function?.arguments === 'string' - ? toolCall.function?.arguments - : JSON.stringify(toolCall.function?.arguments); - log.info(`Arguments: ${args?.substring(0, 100) || '{}'}`); - }); - } else { - log.info(`Response contains no tool calls - plain text response`); - } - - if (toolMessages.length > 0 && !response.tool_calls) { - log.info(`This appears to be a final response after tool execution (no new tool calls)`); - } else if (toolMessages.length > 0 && response.tool_calls && response.tool_calls.length > 0) { - log.info(`This appears to be a continued tool execution flow (tools followed by more tools)`); - } - } - - return { response }; - } - - // Use auto-selection if no specific provider - log.info(`[LLMCompletionStage] Using auto-selected service`); - const response = await aiServiceManager.generateChatCompletion(messages, updatedOptions); - - // Add similar stream enhancement for auto-selected provider - if (response.stream && typeof response.stream === 'function' && updatedOptions.stream) { - const originalStream = response.stream; - response.stream = async (callback) => { - return originalStream(async (chunk) => { - // Create an enhanced chunk with provider info - const enhancedChunk: StreamChunk = { - ...chunk, - raw: chunk.raw || { - provider: response.provider, - model: response.model - } - }; - return callback(enhancedChunk); - }); - }; - } - - // Add enhanced logging for debugging tool execution follow-ups - if (toolMessages.length > 0) { - if (response.tool_calls && response.tool_calls.length > 0) { - log.info(`Response contains ${response.tool_calls.length} tool calls`); - response.tool_calls.forEach((toolCall: any, idx: number) => { - log.info(`Tool call ${idx + 1}: ${toolCall.function?.name || 'unnamed'}`); - const args = typeof toolCall.function?.arguments === 'string' - ? toolCall.function?.arguments - : JSON.stringify(toolCall.function?.arguments); - log.info(`Arguments: ${args?.substring(0, 100) || '{}'}`); - }); - } else { - log.info(`Response contains no tool calls - plain text response`); - } - - if (toolMessages.length > 0 && !response.tool_calls) { - log.info(`This appears to be a final response after tool execution (no new tool calls)`); - } else if (toolMessages.length > 0 && response.tool_calls && response.tool_calls.length > 0) { - log.info(`This appears to be a continued tool execution flow (tools followed by more tools)`); - } - } - - return { response }; - } -} diff --git a/apps/server/src/services/llm/pipeline/stages/message_preparation_stage.ts b/apps/server/src/services/llm/pipeline/stages/message_preparation_stage.ts deleted file mode 100644 index cfbea37ae..000000000 --- a/apps/server/src/services/llm/pipeline/stages/message_preparation_stage.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { MessagePreparationInput } from '../interfaces.js'; -import type { Message } from '../../ai_interface.js'; -import { SYSTEM_PROMPTS } from '../../constants/llm_prompt_constants.js'; -import { MessageFormatterFactory } from '../interfaces/message_formatter.js'; -import toolRegistry from '../../tools/tool_registry.js'; -import log from '../../../log.js'; - -/** - * Pipeline stage for preparing messages for LLM completion - */ -export class MessagePreparationStage extends BasePipelineStage { - constructor() { - super('MessagePreparation'); - } - - /** - * Prepare messages for LLM completion, including system prompt and context - * This uses provider-specific formatters to optimize the message structure - */ - protected async process(input: MessagePreparationInput): Promise<{ messages: Message[] }> { - const { messages, context, systemPrompt, options } = input; - - // Determine provider from model string if available (format: "provider:model") - let provider = 'default'; - if (options?.model && options.model.includes(':')) { - const [providerName] = options.model.split(':'); - provider = providerName; - } - - // Check if tools are enabled - const toolsEnabled = options?.enableTools === true; - - log.info(`Preparing messages for provider: ${provider}, context: ${!!context}, system prompt: ${!!systemPrompt}, tools: ${toolsEnabled}`); - log.info(`Input message count: ${messages.length}`); - - // Apply intelligent context management for long conversations - const managedMessages = await this.applyContextManagement(messages, provider, options); - log.info(`After context management: ${managedMessages.length} messages (reduced by ${messages.length - managedMessages.length})`); - - // Get appropriate formatter for this provider - const formatter = MessageFormatterFactory.getFormatter(provider); - - // Determine the system prompt to use - let finalSystemPrompt = systemPrompt || SYSTEM_PROMPTS.DEFAULT_SYSTEM_PROMPT; - - // If tools are enabled, enhance system prompt with tools guidance - if (toolsEnabled) { - const toolCount = toolRegistry.getAllTools().length; - const toolsPrompt = `You have access to ${toolCount} tools to help you respond. - -CRITICAL: You are designed for CONTINUOUS TOOL USAGE and ITERATIVE INVESTIGATION. When you receive tool results, this is NOT the end of your analysis - it's the beginning of deeper investigation. - -MANDATORY APPROACH: -- After ANY tool execution, immediately analyze the results and plan follow-up actions -- Use multiple tools in sequence to build comprehensive responses -- Chain tools together systematically - use results from one tool to inform the next -- When you find partial information, immediately search for additional details -- Cross-reference findings with alternative search approaches -- Never stop after a single tool unless you have completely fulfilled the request - -TOOL CHAINING EXAMPLES: -- If search_notes finds relevant note IDs → immediately use read_note to get full content -- If initial search returns partial results → try broader terms or alternative keywords -- If one search tool fails → immediately try a different search tool -- Use the information from each tool to inform better parameters for subsequent tools - -Remember: Tool usage should be continuous and iterative until you have thoroughly investigated the user's request.`; - - // Add tools guidance to system prompt - finalSystemPrompt = finalSystemPrompt + '\n\n' + toolsPrompt; - log.info(`Enhanced system prompt with aggressive tool chaining guidance: ${toolCount} tools available`); - } - - // Format messages using provider-specific approach - const formattedMessages = formatter.formatMessages( - managedMessages, - finalSystemPrompt, - context - ); - - log.info(`Formatted ${managedMessages.length} messages into ${formattedMessages.length} messages for provider: ${provider}`); - - return { messages: formattedMessages }; - } - - /** - * Apply intelligent context management to handle long conversations - * Implements various strategies like sliding window, summarization, and importance-based pruning - */ - private async applyContextManagement(messages: Message[], provider: string, options?: any): Promise { - const maxMessages = this.getMaxMessagesForProvider(provider); - - // If we're under the limit, return as-is - if (messages.length <= maxMessages) { - log.info(`Message count (${messages.length}) within limit (${maxMessages}), no context management needed`); - return messages; - } - - log.info(`Message count (${messages.length}) exceeds limit (${maxMessages}), applying context management`); - - // Strategy 1: Preserve recent messages and important system/tool messages - const managedMessages = await this.applySlidingWindowWithImportanceFiltering(messages, maxMessages); - - // Strategy 2: If still too many, apply summarization to older messages - if (managedMessages.length > maxMessages) { - return await this.applySummarizationToOlderMessages(managedMessages, maxMessages); - } - - return managedMessages; - } - - /** - * Get maximum message count for different providers based on their context windows - */ - private getMaxMessagesForProvider(provider: string): number { - const limits = { - 'anthropic': 50, // Conservative for Claude's context window management - 'openai': 40, // Conservative for GPT models - 'ollama': 30, // More conservative for local models - 'default': 35 // Safe default - }; - - return limits[provider as keyof typeof limits] || limits.default; - } - - /** - * Apply sliding window with importance filtering - * Keeps recent messages and important system/tool messages - */ - private async applySlidingWindowWithImportanceFiltering(messages: Message[], maxMessages: number): Promise { - if (messages.length <= maxMessages) { - return messages; - } - - // Always preserve the first system message if it exists - const systemMessages = messages.filter(msg => msg.role === 'system').slice(0, 1); - - // Find tool-related messages that are important to preserve - const toolMessages = messages.filter(msg => - msg.role === 'tool' || - (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) - ); - - // Calculate how many recent messages we can keep - const preservedCount = systemMessages.length; - const recentMessageCount = Math.min(maxMessages - preservedCount, messages.length); - - // Get the most recent messages - const recentMessages = messages.slice(-recentMessageCount); - - // Combine system messages + recent messages, avoiding duplicates - const result: Message[] = []; - - // Add system messages first - systemMessages.forEach(msg => { - if (!result.some(existing => existing === msg)) { - result.push(msg); - } - }); - - // Add recent messages - recentMessages.forEach(msg => { - if (!result.some(existing => existing === msg)) { - result.push(msg); - } - }); - - log.info(`Sliding window filtering: preserved ${preservedCount} system messages, kept ${recentMessages.length} recent messages`); - - return result.slice(0, maxMessages); // Ensure we don't exceed the limit - } - - /** - * Apply summarization to older messages when needed - * Summarizes conversation segments to reduce token count while preserving context - */ - private async applySummarizationToOlderMessages(messages: Message[], maxMessages: number): Promise { - if (messages.length <= maxMessages) { - return messages; - } - - // Keep recent messages (last 60% of limit) - const recentCount = Math.floor(maxMessages * 0.6); - const recentMessages = messages.slice(-recentCount); - - // Get older messages to summarize - const olderMessages = messages.slice(0, messages.length - recentCount); - - // Create a summary of older messages - const summary = this.createConversationSummary(olderMessages); - - // Create a summary message - const summaryMessage: Message = { - role: 'system', - content: `CONVERSATION SUMMARY: Previous conversation included ${olderMessages.length} messages. Key points: ${summary}` - }; - - log.info(`Applied summarization: summarized ${olderMessages.length} older messages, kept ${recentMessages.length} recent messages`); - - return [summaryMessage, ...recentMessages]; - } - - /** - * Create a concise summary of conversation messages - */ - private createConversationSummary(messages: Message[]): string { - const userQueries: string[] = []; - const assistantActions: string[] = []; - const toolUsage: string[] = []; - - messages.forEach(msg => { - if (msg.role === 'user') { - // Extract key topics from user messages - const content = msg.content?.substring(0, 100) || ''; - if (content.trim()) { - userQueries.push(content.trim()); - } - } else if (msg.role === 'assistant') { - // Track tool usage - if (msg.tool_calls && msg.tool_calls.length > 0) { - msg.tool_calls.forEach(tool => { - if (tool.function?.name) { - toolUsage.push(tool.function.name); - } - }); - } - } - }); - - const summary: string[] = []; - - if (userQueries.length > 0) { - summary.push(`User asked about: ${userQueries.slice(0, 3).join(', ')}`); - } - - if (toolUsage.length > 0) { - const uniqueTools = [...new Set(toolUsage)]; - summary.push(`Tools used: ${uniqueTools.slice(0, 5).join(', ')}`); - } - - return summary.join('. ') || 'General conversation about notes and information retrieval'; - } -} diff --git a/apps/server/src/services/llm/pipeline/stages/model_selection_stage.ts b/apps/server/src/services/llm/pipeline/stages/model_selection_stage.ts deleted file mode 100644 index 7b1276b91..000000000 --- a/apps/server/src/services/llm/pipeline/stages/model_selection_stage.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { ModelSelectionInput } from '../interfaces.js'; -import type { ChatCompletionOptions } from '../../ai_interface.js'; -import type { ModelMetadata } from '../../providers/provider_options.js'; -import log from '../../../log.js'; -import aiServiceManager from '../../ai_service_manager.js'; -import { SEARCH_CONSTANTS, MODEL_CAPABILITIES } from "../../constants/search_constants.js"; - -// Import types -import type { ServiceProviders } from '../../interfaces/ai_service_interfaces.js'; - -// Import new configuration system -import { - getSelectedProvider, - parseModelIdentifier, - getDefaultModelForProvider, - createModelConfig -} from '../../config/configuration_helpers.js'; -import type { ProviderType } from '../../interfaces/configuration_interfaces.js'; - -/** - * Pipeline stage for selecting the appropriate LLM model - */ -export class ModelSelectionStage extends BasePipelineStage { - constructor() { - super('ModelSelection'); - } - /** - * Select the appropriate model based on input complexity - */ - protected async process(input: ModelSelectionInput): Promise<{ options: ChatCompletionOptions }> { - const { options: inputOptions, query, contentLength } = input; - - // Log input options - log.info(`[ModelSelectionStage] Input options: ${JSON.stringify({ - model: inputOptions?.model, - stream: inputOptions?.stream, - enableTools: inputOptions?.enableTools - })}`); - log.info(`[ModelSelectionStage] Stream option in input: ${inputOptions?.stream}, type: ${typeof inputOptions?.stream}`); - - // Start with provided options or create a new object - const updatedOptions: ChatCompletionOptions = { ...(inputOptions || {}) }; - - // Preserve the stream option exactly as it was provided, including undefined state - // This is critical for ensuring the stream option propagates correctly down the pipeline - log.info(`[ModelSelectionStage] After copy, stream: ${updatedOptions.stream}, type: ${typeof updatedOptions.stream}`); - - // If model already specified, don't override it - if (updatedOptions.model) { - // Use the new configuration system to parse model identifier - const modelIdentifier = parseModelIdentifier(updatedOptions.model); - - if (modelIdentifier.provider) { - // Add provider metadata for backward compatibility - this.addProviderMetadata(updatedOptions, modelIdentifier.provider as ServiceProviders, modelIdentifier.modelId); - // Update the model to be just the model name without provider prefix - updatedOptions.model = modelIdentifier.modelId; - log.info(`Using explicitly specified model: ${modelIdentifier.modelId} from provider: ${modelIdentifier.provider}`); - } else { - log.info(`Using explicitly specified model: ${updatedOptions.model}`); - } - - log.info(`[ModelSelectionStage] Returning early with stream: ${updatedOptions.stream}`); - return { options: updatedOptions }; - } - - // Enable tools by default unless explicitly disabled - updatedOptions.enableTools = updatedOptions.enableTools !== false; - - // Add tools if not already provided - if (updatedOptions.enableTools && (!updatedOptions.tools || updatedOptions.tools.length === 0)) { - try { - // Import tool registry and fetch tool definitions - const toolRegistry = (await import('../../tools/tool_registry.js')).default; - const toolDefinitions = toolRegistry.getAllToolDefinitions(); - - if (toolDefinitions.length > 0) { - updatedOptions.tools = toolDefinitions; - log.info(`Added ${toolDefinitions.length} tools to options`); - } else { - // Try to initialize tools - log.info('No tools found in registry, trying to initialize them'); - try { - // Tools are already initialized in the AIServiceManager constructor - // No need to initialize them again - - // Try again after initialization - const reinitToolDefinitions = toolRegistry.getAllToolDefinitions(); - updatedOptions.tools = reinitToolDefinitions; - log.info(`After initialization, added ${reinitToolDefinitions.length} tools to options`); - } catch (initError: any) { - log.error(`Failed to initialize tools: ${initError.message}`); - } - } - } catch (error: any) { - log.error(`Error loading tools: ${error.message}`); - } - } - - // Get selected provider and model using the new configuration system - try { - // Use the configuration helpers to get a validated model config - const selectedProvider = await getSelectedProvider(); - - if (!selectedProvider) { - throw new Error('No AI provider is selected. Please select a provider in your AI settings.'); - } - - // First try to get a valid model config (this checks both selection and configuration) - const { getValidModelConfig } = await import('../../config/configuration_helpers.js'); - const modelConfig = await getValidModelConfig(selectedProvider); - - if (!modelConfig) { - throw new Error(`No default model configured for provider ${selectedProvider}. Please set a default model in your AI settings.`); - } - - // Use the configured model - updatedOptions.model = modelConfig.model; - - log.info(`Selected provider: ${selectedProvider}, model: ${updatedOptions.model}`); - - // Determine query complexity - let queryComplexity = 'low'; - if (query) { - // Simple heuristic: longer queries or those with complex terms indicate higher complexity - const complexityIndicators = [ - 'explain', 'analyze', 'compare', 'evaluate', 'synthesize', - 'summarize', 'elaborate', 'investigate', 'research', 'debate' - ]; - - const hasComplexTerms = complexityIndicators.some(term => query.toLowerCase().includes(term)); - const isLongQuery = query.length > 100; - const hasMultipleQuestions = (query.match(/\?/g) || []).length > 1; - - if ((hasComplexTerms && isLongQuery) || hasMultipleQuestions) { - queryComplexity = 'high'; - } else if (hasComplexTerms || isLongQuery) { - queryComplexity = 'medium'; - } - } - - // Check content length if provided - if (contentLength && contentLength > SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.MEDIUM_THRESHOLD) { - // For large content, favor more powerful models - queryComplexity = contentLength > SEARCH_CONSTANTS.CONTEXT.CONTENT_LENGTH.HIGH_THRESHOLD ? 'high' : 'medium'; - } - - // Add provider metadata (model is already set above) - this.addProviderMetadata(updatedOptions, selectedProvider as ServiceProviders, updatedOptions.model); - - log.info(`Selected model: ${updatedOptions.model} from provider: ${selectedProvider} for query complexity: ${queryComplexity}`); - log.info(`[ModelSelectionStage] Final options: ${JSON.stringify({ - model: updatedOptions.model, - stream: updatedOptions.stream, - provider: selectedProvider, - enableTools: updatedOptions.enableTools - })}`); - - return { options: updatedOptions }; - } catch (error) { - log.error(`Error determining default model: ${error}`); - throw new Error(`Failed to determine AI model configuration: ${error}`); - } - } - - /** - * Add provider metadata to the options based on model name - */ - private addProviderMetadata(options: ChatCompletionOptions, provider: ServiceProviders, modelName: string): void { - // Check if we already have providerMetadata - if (options.providerMetadata) { - // If providerMetadata exists but not modelId, add the model name - if (!options.providerMetadata.modelId && modelName) { - options.providerMetadata.modelId = modelName; - } - return; - } - - // Use the explicitly provided provider - no automatic fallbacks - let selectedProvider = provider; - - // Set the provider metadata in the options - if (selectedProvider) { - // Ensure the provider is one of the valid types - const validProvider = selectedProvider as 'openai' | 'anthropic' | 'ollama' | 'local'; - - options.providerMetadata = { - provider: validProvider, - modelId: modelName - }; - - // For backward compatibility, ensure model name is set without prefix - if (options.model && options.model.includes(':')) { - const parsed = parseModelIdentifier(options.model); - options.model = modelName || parsed.modelId; - } - - log.info(`Set provider metadata: provider=${selectedProvider}, model=${modelName}`); - } - } - - - - /** - * Get estimated context window for Ollama models - */ - private getOllamaContextWindow(model: string): number { - // Try to find exact matches in MODEL_CAPABILITIES - if (model in MODEL_CAPABILITIES) { - return MODEL_CAPABILITIES[model as keyof typeof MODEL_CAPABILITIES].contextWindowTokens; - } - - // Estimate based on model family - if (model.includes('llama3')) { - return MODEL_CAPABILITIES['gpt-4'].contextWindowTokens; - } else if (model.includes('llama2')) { - return MODEL_CAPABILITIES['default'].contextWindowTokens; - } else if (model.includes('mistral') || model.includes('mixtral')) { - return MODEL_CAPABILITIES['gpt-4'].contextWindowTokens; - } else if (model.includes('gemma')) { - return MODEL_CAPABILITIES['gpt-4'].contextWindowTokens; - } else { - return MODEL_CAPABILITIES['default'].contextWindowTokens; - } - } - - -} diff --git a/apps/server/src/services/llm/pipeline/stages/response_processing_stage.ts b/apps/server/src/services/llm/pipeline/stages/response_processing_stage.ts deleted file mode 100644 index 94944815b..000000000 --- a/apps/server/src/services/llm/pipeline/stages/response_processing_stage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { ResponseProcessingInput } from '../interfaces.js'; -import type { ChatResponse } from '../../ai_interface.js'; -import log from '../../../log.js'; - -/** - * Pipeline stage for processing LLM responses - */ -export class ResponseProcessingStage extends BasePipelineStage { - constructor() { - super('ResponseProcessing'); - } - - /** - * Process the LLM response - */ - protected async process(input: ResponseProcessingInput): Promise<{ text: string }> { - const { response, options } = input; - log.info(`Processing LLM response from model: ${response.model}`); - - // Perform any necessary post-processing on the response text - let text = response.text; - - // For Markdown formatting, ensure code blocks are properly formatted - if (options?.showThinking && text.includes('thinking:')) { - // Extract and format thinking section - const thinkingMatch = text.match(/thinking:(.*?)(?=answer:|$)/s); - if (thinkingMatch) { - const thinking = thinkingMatch[1].trim(); - text = text.replace(/thinking:.*?(?=answer:|$)/s, `**Thinking:** \n\n\`\`\`\n${thinking}\n\`\`\`\n\n`); - } - } - - // Clean up response text - text = text.replace(/^\s*assistant:\s*/i, ''); // Remove leading "Assistant:" if present - - // Log tokens if available for monitoring - if (response.usage) { - log.info(`Token usage - prompt: ${response.usage.promptTokens}, completion: ${response.usage.completionTokens}, total: ${response.usage.totalTokens}`); - } - - return { text }; - } -} diff --git a/apps/server/src/services/llm/pipeline/stages/semantic_context_extraction_stage.ts b/apps/server/src/services/llm/pipeline/stages/semantic_context_extraction_stage.ts deleted file mode 100644 index 03a8f2b73..000000000 --- a/apps/server/src/services/llm/pipeline/stages/semantic_context_extraction_stage.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { SemanticContextExtractionInput } from '../interfaces.js'; -import log from '../../../log.js'; - -/** - * Pipeline stage for extracting semantic context from notes - * Since vector search has been removed, this now returns empty context - * and relies on other context extraction methods - */ -export class SemanticContextExtractionStage extends BasePipelineStage { - constructor() { - super('SemanticContextExtraction'); - } - - /** - * Extract semantic context based on a query - * Returns empty context since vector search has been removed - */ - protected async process(input: SemanticContextExtractionInput): Promise<{ context: string }> { - const { noteId, query } = input; - log.info(`Semantic context extraction disabled - vector search has been removed. Using tool-based context instead for note ${noteId}`); - - // Return empty context since we no longer use vector search - // The LLM will rely on tool calls for context gathering - return { context: "" }; - } -} \ No newline at end of file diff --git a/apps/server/src/services/llm/pipeline/stages/tool_calling_stage.ts b/apps/server/src/services/llm/pipeline/stages/tool_calling_stage.ts deleted file mode 100644 index 66d04613a..000000000 --- a/apps/server/src/services/llm/pipeline/stages/tool_calling_stage.ts +++ /dev/null @@ -1,771 +0,0 @@ -import type { ChatResponse, Message } from '../../ai_interface.js'; -import log from '../../../log.js'; -import type { StreamCallback, ToolExecutionInput } from '../interfaces.js'; -import { BasePipelineStage } from '../pipeline_stage.js'; -import toolRegistry from '../../tools/tool_registry.js'; -import chatStorageService from '../../chat_storage_service.js'; -import aiServiceManager from '../../ai_service_manager.js'; - -// Type definitions for tools and validation results -interface ToolInterface { - execute: (args: Record) => Promise; - [key: string]: unknown; -} - -interface ToolValidationResult { - toolCall: { - id?: string; - function: { - name: string; - arguments: string | Record; - }; - }; - valid: boolean; - tool: ToolInterface | null; - error: string | null; - guidance?: string; // Guidance to help the LLM select better tools/parameters -} - -/** - * Pipeline stage for handling LLM tool calling - * This stage is responsible for: - * 1. Detecting tool calls in LLM responses - * 2. Executing the appropriate tools - * 3. Adding tool results back to the conversation - * 4. Determining if we need to make another call to the LLM - */ -export class ToolCallingStage extends BasePipelineStage { - constructor() { - super('ToolCalling'); - // Vector search tool has been removed - no preloading needed - } - - /** - * Process the LLM response and execute any tool calls - */ - protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> { - const { response, messages } = input; - const streamCallback = input.streamCallback as StreamCallback; - - log.info(`========== TOOL CALLING STAGE ENTRY ==========`); - log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`); - - log.info(`LLM requested ${response.tool_calls?.length || 0} tool calls from provider: ${response.provider}`); - - // Check if the response has tool calls - if (!response.tool_calls || response.tool_calls.length === 0) { - // No tool calls, return original response and messages - log.info(`No tool calls detected in response from provider: ${response.provider}`); - log.info(`===== EXITING TOOL CALLING STAGE: No tool_calls =====`); - return { response, needsFollowUp: false, messages }; - } - - // Log response details for debugging - if (response.text) { - log.info(`Response text: "${response.text.substring(0, 200)}${response.text.length > 200 ? '...' : ''}"`); - } - - // Check if the registry has any tools - const registryTools = toolRegistry.getAllTools(); - - // Convert ToolHandler[] to ToolInterface[] with proper type safety - const availableTools: ToolInterface[] = registryTools.map(tool => { - // Create a proper ToolInterface from the ToolHandler - const toolInterface: ToolInterface = { - // Pass through the execute method - execute: (args: Record) => tool.execute(args), - // Include other properties from the tool definition - ...tool.definition - }; - return toolInterface; - }); - log.info(`Available tools in registry: ${availableTools.length}`); - - // Log available tools for debugging - if (availableTools.length > 0) { - const availableToolNames = availableTools.map(t => { - // Safely access the name property using type narrowing - if (t && typeof t === 'object' && 'definition' in t && - t.definition && typeof t.definition === 'object' && - 'function' in t.definition && t.definition.function && - typeof t.definition.function === 'object' && - 'name' in t.definition.function && - typeof t.definition.function.name === 'string') { - return t.definition.function.name; - } - return 'unknown'; - }).join(', '); - log.info(`Available tools: ${availableToolNames}`); - } - - if (availableTools.length === 0) { - log.error(`No tools available in registry, cannot execute tool calls`); - // Try to initialize tools as a recovery step - try { - log.info('Attempting to initialize tools as recovery step'); - // Tools are already initialized in the AIServiceManager constructor - // No need to initialize them again - const toolCount = toolRegistry.getAllTools().length; - log.info(`After recovery initialization: ${toolCount} tools available`); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`Failed to initialize tools in recovery step: ${errorMessage}`); - } - } - - // Create a copy of messages to add the assistant message with tool calls - const updatedMessages = [...messages]; - - // Add the assistant message with the tool calls - updatedMessages.push({ - role: 'assistant', - content: response.text || "", - tool_calls: response.tool_calls - }); - - // Execute each tool call and add results to messages - log.info(`========== STARTING TOOL EXECUTION ==========`); - log.info(`Executing ${response.tool_calls?.length || 0} tool calls in parallel`); - - const executionStartTime = Date.now(); - - // First validate all tools before execution - log.info(`Validating ${response.tool_calls?.length || 0} tools before execution`); - const validationResults: ToolValidationResult[] = await Promise.all((response.tool_calls || []).map(async (toolCall) => { - try { - // Get the tool from registry - const tool = toolRegistry.getTool(toolCall.function.name); - - if (!tool) { - log.error(`Tool not found in registry: ${toolCall.function.name}`); - // Generate guidance for the LLM when a tool is not found - const guidance = this.generateToolGuidance(toolCall.function.name, `Tool not found: ${toolCall.function.name}`); - return { - toolCall, - valid: false, - tool: null, - error: `Tool not found: ${toolCall.function.name}`, - guidance // Add guidance for the LLM - }; - } - - // Validate the tool before execution - // Use unknown as an intermediate step for type conversion - const isToolValid = await this.validateToolBeforeExecution(tool as unknown as ToolInterface, toolCall.function.name); - if (!isToolValid) { - throw new Error(`Tool '${toolCall.function.name}' failed validation before execution`); - } - - return { - toolCall, - valid: true, - tool: tool as unknown as ToolInterface, - error: null - }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { - toolCall, - valid: false, - tool: null, - error: errorMessage - }; - } - })); - - // Execute the validated tools - const toolResults = await Promise.all(validationResults.map(async (validation, index) => { - const { toolCall, valid, tool, error } = validation; - - try { - log.info(`========== TOOL CALL ${index + 1} OF ${response.tool_calls?.length || 0} ==========`); - log.info(`Tool call ${index + 1} received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`); - - // Log parameters - const argsStr = typeof toolCall.function.arguments === 'string' - ? toolCall.function.arguments - : JSON.stringify(toolCall.function.arguments); - log.info(`Tool parameters: ${argsStr}`); - - // If validation failed, generate guidance and throw the error - if (!valid || !tool) { - // If we already have guidance from validation, use it, otherwise generate it - const toolGuidance = validation.guidance || - this.generateToolGuidance(toolCall.function.name, - error || `Unknown validation error for tool '${toolCall.function.name}'`); - - // Include the guidance in the error message - throw new Error(`${error || `Unknown validation error for tool '${toolCall.function.name}'`}\n${toolGuidance}`); - } - - log.info(`Tool validated successfully: ${toolCall.function.name}`); - - // Parse arguments (handle both string and object formats) - let args: Record; - // At this stage, arguments should already be processed by the provider-specific service - // But we still need to handle different formats just in case - if (typeof toolCall.function.arguments === 'string') { - log.info(`Received string arguments in tool calling stage: ${toolCall.function.arguments.substring(0, 50)}...`); - - try { - // Try to parse as JSON first - args = JSON.parse(toolCall.function.arguments) as Record; - log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`); - } catch (e: unknown) { - // If it's not valid JSON, try to check if it's a stringified object with quotes - const errorMessage = e instanceof Error ? e.message : String(e); - log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${errorMessage}`); - - // Sometimes LLMs return stringified JSON with escaped quotes or incorrect quotes - // Try to clean it up - try { - const cleaned = toolCall.function.arguments - .replace(/^['"]/g, '') // Remove surrounding quotes - .replace(/['"]$/g, '') // Remove surrounding quotes - .replace(/\\"/g, '"') // Replace escaped quotes - .replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names - .replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names - - log.info(`Cleaned argument string: ${cleaned}`); - args = JSON.parse(cleaned) as Record; - log.info(`Successfully parsed cleaned arguments: ${Object.keys(args).join(', ')}`); - } catch (cleanError: unknown) { - // If all parsing fails, treat it as a text argument - const cleanErrorMessage = cleanError instanceof Error ? cleanError.message : String(cleanError); - log.info(`Failed to parse cleaned arguments: ${cleanErrorMessage}`); - args = { text: toolCall.function.arguments }; - log.info(`Using text argument: ${(args.text as string).substring(0, 50)}...`); - } - } - } else { - // Arguments are already an object - args = toolCall.function.arguments as Record; - log.info(`Using object arguments with keys: ${Object.keys(args).join(', ')}`); - } - - // Execute the tool - log.info(`================ EXECUTING TOOL: ${toolCall.function.name} ================`); - log.info(`Tool parameters: ${Object.keys(args).join(', ')}`); - log.info(`Parameters values: ${Object.entries(args).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`); - - // Emit tool start event if streaming is enabled - if (streamCallback) { - const toolExecutionData = { - action: 'start', - tool: { - name: toolCall.function.name, - arguments: args - }, - type: 'start' as const, - progress: { - current: index + 1, - total: response.tool_calls?.length || 1, - status: 'initializing', - message: `Starting ${toolCall.function.name} execution...`, - estimatedDuration: this.getEstimatedDuration(toolCall.function.name) - } - }; - - // Don't wait for this to complete, but log any errors - const callbackResult = streamCallback('', false, { - text: '', - done: false, - toolExecution: toolExecutionData - }); - if (callbackResult instanceof Promise) { - callbackResult.catch((e: Error) => log.error(`Error sending tool execution start event: ${e.message}`)); - } - } - - const executionStart = Date.now(); - let result; - try { - log.info(`Starting tool execution for ${toolCall.function.name}...`); - - // Send progress update during execution - if (streamCallback) { - const progressData = { - action: 'progress', - tool: { - name: toolCall.function.name, - arguments: args - }, - type: 'progress' as const, - progress: { - current: index + 1, - total: response.tool_calls?.length || 1, - status: 'executing', - message: `Executing ${toolCall.function.name}...`, - startTime: executionStart - } - }; - - const progressResult = streamCallback('', false, { - text: '', - done: false, - toolExecution: progressData - }); - if (progressResult instanceof Promise) { - progressResult.catch((e: Error) => log.error(`Error sending tool execution progress event: ${e.message}`)); - } - } - - result = await tool.execute(args); - const executionTime = Date.now() - executionStart; - log.info(`================ TOOL EXECUTION COMPLETED in ${executionTime}ms ================`); - - // Record this successful tool execution if there's a sessionId available - if (input.options?.sessionId) { - try { - await chatStorageService.recordToolExecution( - input.options.sessionId, - toolCall.function.name, - toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - args, - result, - undefined // No error for successful execution - ); - } catch (storageError) { - log.error(`Failed to record tool execution in chat storage: ${storageError}`); - } - } - - // Emit tool completion event if streaming is enabled - if (streamCallback) { - const resultSummary = typeof result === 'string' - ? result.substring(0, 200) + (result.length > 200 ? '...' : '') - : `Object with ${Object.keys(result).length} properties`; - - const toolExecutionData = { - action: 'complete', - tool: { - name: toolCall.function.name, - arguments: {} as Record - }, - result: typeof result === 'string' ? result : result as Record, - type: 'complete' as const, - progress: { - current: index + 1, - total: response.tool_calls?.length || 1, - status: 'completed', - message: `${toolCall.function.name} completed successfully`, - executionTime: executionTime, - resultSummary: resultSummary - } - }; - - // Don't wait for this to complete, but log any errors - const callbackResult = streamCallback('', false, { - text: '', - done: false, - toolExecution: toolExecutionData - }); - if (callbackResult instanceof Promise) { - callbackResult.catch((e: Error) => log.error(`Error sending tool execution complete event: ${e.message}`)); - } - } - } catch (execError: unknown) { - const executionTime = Date.now() - executionStart; - const errorMessage = execError instanceof Error ? execError.message : String(execError); - log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${errorMessage} ================`); - - // Generate guidance for the failed tool execution - const toolGuidance = this.generateToolGuidance(toolCall.function.name, errorMessage); - - // Add the guidance to the error message for the LLM - const enhancedErrorMessage = `${errorMessage}\n${toolGuidance}`; - - // Record this failed tool execution if there's a sessionId available - if (input.options?.sessionId) { - try { - await chatStorageService.recordToolExecution( - input.options.sessionId, - toolCall.function.name, - toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - args, - "", // No result for failed execution - enhancedErrorMessage // Use enhanced error message with guidance - ); - } catch (storageError) { - log.error(`Failed to record tool execution error in chat storage: ${storageError}`); - } - } - - // Emit tool error event if streaming is enabled - if (streamCallback) { - const toolExecutionData = { - action: 'error', - tool: { - name: toolCall.function.name, - arguments: {} as Record - }, - error: enhancedErrorMessage, // Include guidance in the error message - type: 'error' as const, - progress: { - current: index + 1, - total: response.tool_calls?.length || 1, - status: 'failed', - message: `${toolCall.function.name} failed: ${errorMessage.substring(0, 100)}...`, - executionTime: executionTime, - errorType: execError instanceof Error ? execError.constructor.name : 'UnknownError' - } - }; - - // Don't wait for this to complete, but log any errors - const callbackResult = streamCallback('', false, { - text: '', - done: false, - toolExecution: toolExecutionData - }); - if (callbackResult instanceof Promise) { - callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`)); - } - } - - // Modify the error to include our guidance - if (execError instanceof Error) { - execError.message = enhancedErrorMessage; - } - throw execError; - } - - // Log execution result - const resultSummary = typeof result === 'string' - ? `${result.substring(0, 100)}...` - : `Object with keys: ${Object.keys(result).join(', ')}`; - const executionTime = Date.now() - executionStart; - log.info(`Tool execution completed in ${executionTime}ms - Result: ${resultSummary}`); - - // Return result with tool call ID - return { - toolCallId: toolCall.id, - name: toolCall.function.name, - result - }; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`Error executing tool ${toolCall.function.name}: ${errorMessage}`); - - // Emit tool error event if not already handled in the try/catch above - // and if streaming is enabled - // Need to check if error is an object with a name property of type string - const isExecutionError = typeof error === 'object' && error !== null && - 'name' in error && (error as { name: unknown }).name === "ExecutionError"; - - if (streamCallback && !isExecutionError) { - const toolExecutionData = { - action: 'error', - tool: { - name: toolCall.function.name, - arguments: {} as Record - }, - error: errorMessage, - type: 'error' as const - }; - - // Don't wait for this to complete, but log any errors - const callbackResult = streamCallback('', false, { - text: '', - done: false, - toolExecution: toolExecutionData - }); - if (callbackResult instanceof Promise) { - callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`)); - } - } - - // Return error message as result - return { - toolCallId: toolCall.id, - name: toolCall.function.name, - result: `Error: ${errorMessage}` - }; - } - })); - - const totalExecutionTime = Date.now() - executionStartTime; - log.info(`========== TOOL EXECUTION COMPLETE ==========`); - log.info(`Completed execution of ${toolResults.length} tools in ${totalExecutionTime}ms`); - - // Add each tool result to the messages array - const toolResultMessages: Message[] = []; - let hasEmptyResults = false; - - for (const result of toolResults) { - const { toolCallId, name, result: toolResult } = result; - - // Format result for message - const resultContent = typeof toolResult === 'string' - ? toolResult - : JSON.stringify(toolResult, null, 2); - - // Check if result is empty or unhelpful - const isEmptyResult = this.isEmptyToolResult(toolResult, name); - if (isEmptyResult && !resultContent.startsWith('Error:')) { - hasEmptyResults = true; - log.info(`Empty result detected for tool ${name}. Will add suggestion to try different parameters.`); - } - - // Add enhancement for empty results and continuation signals - let enhancedContent = resultContent; - if (isEmptyResult && !resultContent.startsWith('Error:')) { - enhancedContent = `${resultContent}\n\nCONTINUATION REQUIRED: This tool returned no useful results with the provided parameters. You MUST immediately try additional searches with broader terms, alternative keywords, or different tools. Do not stop here - continue your investigation.`; - } else if (!resultContent.startsWith('Error:')) { - // Add continuation signal for successful results too - enhancedContent = `${resultContent}\n\nCONTINUATION: Analyze these results and determine if additional tools are needed to provide a comprehensive response. Consider follow-up searches, reading specific notes, or cross-referencing with other tools.`; - } - - // Add a new message for the tool result - const toolMessage: Message = { - role: 'tool', - content: enhancedContent, - name: name, - tool_call_id: toolCallId - }; - - // Log detailed info about each tool result - log.info(`-------- Tool Result for ${name} (ID: ${toolCallId}) --------`); - log.info(`Result type: ${typeof toolResult}`); - log.info(`Result preview: ${resultContent.substring(0, 150)}${resultContent.length > 150 ? '...' : ''}`); - log.info(`Tool result status: ${resultContent.startsWith('Error:') ? 'ERROR' : isEmptyResult ? 'EMPTY' : 'SUCCESS'}`); - - updatedMessages.push(toolMessage); - toolResultMessages.push(toolMessage); - } - - // Log the decision about follow-up - log.info(`========== FOLLOW-UP DECISION ==========`); - const hasToolResults = toolResultMessages.length > 0; - const hasErrors = toolResultMessages.some(msg => msg.content.startsWith('Error:')); - const needsFollowUp = hasToolResults; - - log.info(`Follow-up needed: ${needsFollowUp}`); - log.info(`Reasoning: ${hasToolResults ? 'Has tool results to process' : 'No tool results'} ${hasErrors ? ', contains errors' : ''} ${hasEmptyResults ? ', contains empty results' : ''}`); - - // Add system message to ensure continuation - always add this for any tool results - if (needsFollowUp) { - log.info('Adding system message to ensure LLM continues with additional tools'); - - let directiveMessage = ''; - - // Handle empty results with aggressive re-search instructions - if (hasEmptyResults) { - directiveMessage = `CRITICAL: Empty results detected. YOU MUST NOT GIVE UP. IMMEDIATELY try alternative approaches: -- Use broader search terms (remove specific qualifiers) -- Try synonyms and related concepts -- Use different search tools (if keyword_search failed, try search_notes) -- Remove date/time constraints and try general terms -DO NOT respond to the user until you've tried at least 2-3 additional search variations.`; - } else { - // For successful results, provide comprehensive follow-up instructions - directiveMessage = `TOOL ANALYSIS CONTINUATION: You have received tool results above. This is NOT the end of your investigation. You must: - -1. Analyze the results thoroughly -2. Determine if additional information is needed -3. Use follow-up tools to gather comprehensive information -4. If search results included note IDs, use read_note to get full content -5. Cross-reference findings with alternative search approaches -6. Continue investigation until you can provide a complete, well-researched response - -REMEMBER: Execute multiple tools in sequence to build comprehensive answers. The user expects thorough investigation with systematic tool usage. - -Continue your systematic investigation now.`; - } - - updatedMessages.push({ - role: 'system', - content: directiveMessage - }); - } - - log.info(`Total messages to return to pipeline: ${updatedMessages.length}`); - log.info(`Last 3 messages in conversation:`); - const lastMessages = updatedMessages.slice(-3); - lastMessages.forEach((msg, idx) => { - const position = updatedMessages.length - lastMessages.length + idx; - log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`); - }); - - return { - response, - messages: updatedMessages, - needsFollowUp - }; - } - - - /** - * Validate a tool before execution - * @param tool The tool to validate - * @param toolName The name of the tool - */ - private async validateToolBeforeExecution(tool: ToolInterface, toolName: string): Promise { - try { - if (!tool) { - log.error(`Tool '${toolName}' not found or failed validation`); - return false; - } - - // Validate execute method - if (!tool.execute || typeof tool.execute !== 'function') { - log.error(`Tool '${toolName}' is missing execute method`); - return false; - } - - // search_notes tool now uses context handler instead of vector search - if (toolName === 'search_notes') { - log.info(`Tool '${toolName}' validated - uses context handler instead of vector search`); - } - - // Add additional tool-specific validations here - return true; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`Error validating tool before execution: ${errorMessage}`); - return false; - } - } - - /** - * Generate guidance for the LLM when a tool fails or is not found - * @param toolName The name of the tool that failed - * @param errorMessage The error message from the failed tool - * @returns A guidance message for the LLM with suggestions of what to try next - */ - private generateToolGuidance(toolName: string, errorMessage: string): string { - // Get all available tool names for recommendations - const availableTools = toolRegistry.getAllTools(); - const availableToolNames = availableTools - .map(t => { - if (t && typeof t === 'object' && 'definition' in t && - t.definition && typeof t.definition === 'object' && - 'function' in t.definition && t.definition.function && - typeof t.definition.function === 'object' && - 'name' in t.definition.function && - typeof t.definition.function.name === 'string') { - return t.definition.function.name; - } - return ''; - }) - .filter(name => name !== ''); - - // Create specific guidance based on the error and tool - let guidance = `TOOL GUIDANCE: The tool '${toolName}' failed with error: ${errorMessage}.\n`; - - // Add suggestions based on the specific tool and error - if (toolName === 'attribute_search' && errorMessage.includes('Invalid attribute type')) { - guidance += "CRITICAL REQUIREMENT: The 'attribute_search' tool requires 'attributeType' parameter that must be EXACTLY 'label' or 'relation' (lowercase, no other values).\n"; - guidance += "CORRECT EXAMPLE: { \"attributeType\": \"label\", \"attributeName\": \"important\", \"attributeValue\": \"yes\" }\n"; - guidance += "INCORRECT EXAMPLE: { \"attributeType\": \"Label\", ... } - Case matters! Must be lowercase.\n"; - } - else if (errorMessage.includes('Tool not found')) { - // Provide guidance on available search tools if a tool wasn't found - const searchTools = availableToolNames.filter(name => name.includes('search')); - guidance += `AVAILABLE SEARCH TOOLS: ${searchTools.join(', ')}\n`; - guidance += "TRY SEARCH NOTES: For semantic matches, use 'search_notes' with a query parameter.\n"; - guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n"; - } - else if (errorMessage.includes('missing required parameter')) { - // Provide parameter guidance based on the tool name - if (toolName === 'search_notes') { - guidance += "REQUIRED PARAMETERS: The 'search_notes' tool requires a 'query' parameter.\n"; - guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n"; - } else if (toolName === 'keyword_search') { - guidance += "REQUIRED PARAMETERS: The 'keyword_search' tool requires a 'query' parameter.\n"; - guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n"; - } - } - - // Add general recommendations including new helper tools - if (!toolName.includes('search_notes')) { - guidance += "RECOMMENDATION: If specific searches fail, try the 'search_notes' tool which performs semantic searches.\n"; - } - - // Encourage continued tool usage - guidance += "\nTry alternative tools immediately. Use discover_tools if unsure which tool to use next."; - - return guidance; - } - - /** - * Get estimated duration for a tool execution (in milliseconds) - * @param toolName The name of the tool - * @returns Estimated duration in milliseconds - */ - private getEstimatedDuration(toolName: string): number { - // Tool-specific duration estimates based on typical execution times - const estimations = { - 'search_notes': 2000, - 'read_note': 1000, - 'keyword_search': 1500, - 'attribute_search': 1200, - 'discover_tools': 500, - 'note_by_path': 800, - 'template_search': 1000 - }; - - return estimations[toolName as keyof typeof estimations] || 1500; // Default 1.5 seconds - } - - /** - * Determines if a tool result is effectively empty or unhelpful - * @param result The result from the tool execution - * @param toolName The name of the tool that was executed - * @returns true if the result is considered empty or unhelpful - */ - private isEmptyToolResult(result: unknown, toolName: string): boolean { - // Handle string results - if (typeof result === 'string') { - const trimmed = result.trim(); - if (trimmed === '' || trimmed === '[]' || trimmed === '{}') { - return true; - } - - // Tool-specific empty results (for string responses) - if (toolName === 'search_notes' && - (trimmed === 'No matching notes found.' || - trimmed.includes('No results found') || - trimmed.includes('No matches found') || - trimmed.includes('No notes found'))) { - // This is a valid result (empty, but valid), don't mark as empty so LLM can see feedback - return false; - } - - - if (toolName === 'keyword_search' && - (trimmed.includes('No matches found') || - trimmed.includes('No results for'))) { - return true; - } - } - // Handle object/array results - else if (result !== null && typeof result === 'object') { - // Check if it's an empty array - if (Array.isArray(result) && result.length === 0) { - return true; - } - - // Check if it's an object with no meaningful properties - // or with properties indicating empty results - if (!Array.isArray(result)) { - if (Object.keys(result).length === 0) { - return true; - } - - // Tool-specific object empty checks - const resultObj = result as Record; - - if (toolName === 'search_notes' && - 'results' in resultObj && - Array.isArray(resultObj.results) && - resultObj.results.length === 0) { - return true; - } - - } - } - - return false; - } - -} diff --git a/apps/server/src/services/llm/pipeline/stages/user_interaction_stage.ts b/apps/server/src/services/llm/pipeline/stages/user_interaction_stage.ts deleted file mode 100644 index 80124e3a7..000000000 --- a/apps/server/src/services/llm/pipeline/stages/user_interaction_stage.ts +++ /dev/null @@ -1,486 +0,0 @@ -import { BasePipelineStage } from '../pipeline_stage.js'; -import type { ToolExecutionInput, StreamCallback } from '../interfaces.js'; -import type { ChatResponse, Message } from '../../ai_interface.js'; -import log from '../../../log.js'; - -interface UserInteractionConfig { - enableConfirmation: boolean; - enableCancellation: boolean; - confirmationTimeout: number; // milliseconds - autoConfirmLowRisk: boolean; - requiredConfirmationTools: string[]; -} - -interface PendingInteraction { - id: string; - toolCall: any; - timestamp: number; - timeoutHandle?: NodeJS.Timeout; - resolved: boolean; -} - -type InteractionResponse = 'confirm' | 'cancel' | 'timeout'; - -/** - * Enhanced User Interaction Pipeline Stage - * Provides real-time confirmation/cancellation capabilities for tool execution - */ -export class UserInteractionStage extends BasePipelineStage { - - private config: UserInteractionConfig; - private pendingInteractions: Map = new Map(); - private interactionCallbacks: Map void> = new Map(); - - constructor(config?: Partial) { - super('UserInteraction'); - - this.config = { - enableConfirmation: true, - enableCancellation: true, - confirmationTimeout: 15000, // 15 seconds - autoConfirmLowRisk: true, - requiredConfirmationTools: ['attribute_search', 'read_note'], - ...config - }; - } - - /** - * Process tool execution with user interaction capabilities - */ - protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[], userInteractions?: any[] }> { - const { response } = input; - - // If no tool calls or interactions disabled, pass through - if (!response.tool_calls || response.tool_calls.length === 0 || !this.config.enableConfirmation) { - return { - response, - needsFollowUp: false, - messages: input.messages, - userInteractions: [] - }; - } - - log.info(`========== USER INTERACTION STAGE PROCESSING ==========`); - log.info(`Processing ${response.tool_calls.length} tool calls with user interaction controls`); - - const processedToolCalls: any[] = []; - const userInteractions: any[] = []; - const updatedMessages = [...input.messages]; - - // Process each tool call with interaction controls - for (let i = 0; i < response.tool_calls.length; i++) { - const toolCall = response.tool_calls[i]; - - const interactionResult = await this.processToolCallWithInteraction(toolCall, input, i); - - if (interactionResult) { - processedToolCalls.push(interactionResult.toolCall); - updatedMessages.push(interactionResult.message); - - if (interactionResult.interaction) { - userInteractions.push(interactionResult.interaction); - } - } - } - - // Create enhanced response with interaction metadata - const enhancedResponse: ChatResponse = { - ...response, - tool_calls: processedToolCalls, - interaction_metadata: { - total_interactions: userInteractions.length, - confirmed: userInteractions.filter((i: any) => i.response === 'confirm').length, - cancelled: userInteractions.filter((i: any) => i.response === 'cancel').length, - timedout: userInteractions.filter((i: any) => i.response === 'timeout').length - } - }; - - const needsFollowUp = processedToolCalls.length > 0; - - log.info(`User interaction complete: ${userInteractions.length} interactions processed`); - - return { - response: enhancedResponse, - needsFollowUp, - messages: updatedMessages, - userInteractions - }; - } - - /** - * Process a tool call with user interaction controls - */ - private async processToolCallWithInteraction( - toolCall: any, - input: ToolExecutionInput, - index: number - ): Promise<{ toolCall: any, message: Message, interaction?: any } | null> { - - const toolName = toolCall.function.name; - const riskLevel = this.assessToolRiskLevel(toolName); - - // Determine if confirmation is required - const requiresConfirmation = this.shouldRequireConfirmation(toolName, riskLevel); - - if (!requiresConfirmation) { - // Execute immediately for low-risk tools - log.info(`Tool ${toolName} is low-risk, executing immediately`); - return await this.executeToolDirectly(toolCall, input); - } - - // Request user confirmation - log.info(`Tool ${toolName} requires user confirmation (risk level: ${riskLevel})`); - - const interactionId = this.generateInteractionId(); - const interaction = await this.requestUserConfirmation(toolCall, interactionId, input.streamCallback); - - if (interaction.response === 'confirm') { - log.info(`User confirmed execution of ${toolName}`); - const result = await this.executeToolDirectly(toolCall, input); - return { - ...result!, - interaction - }; - } else if (interaction.response === 'cancel') { - log.info(`User cancelled execution of ${toolName}`); - return { - toolCall, - message: { - role: 'tool', - content: `USER_CANCELLED: Execution of ${toolName} was cancelled by user request.`, - name: toolName, - tool_call_id: toolCall.id - }, - interaction - }; - } else { - // Timeout - log.info(`User confirmation timeout for ${toolName}, executing with default action`); - const result = await this.executeToolDirectly(toolCall, input); - return { - ...result!, - interaction: { ...interaction, response: 'timeout_executed' } - }; - } - } - - /** - * Assess the risk level of a tool - */ - private assessToolRiskLevel(toolName: string): 'low' | 'medium' | 'high' { - const riskLevels = { - // Low risk - read-only operations - 'search_notes': 'low', - 'keyword_search': 'low', - 'discover_tools': 'low', - 'template_search': 'low', - - // Medium risk - specific data access - 'read_note': 'medium', - 'note_by_path': 'medium', - - // High risk - complex queries or potential data modification - 'attribute_search': 'high' - }; - - return (riskLevels as any)[toolName] || 'medium'; - } - - /** - * Determine if a tool requires user confirmation - */ - private shouldRequireConfirmation(toolName: string, riskLevel: string): boolean { - // Always require confirmation for high-risk tools - if (riskLevel === 'high') { - return true; - } - - // Check if tool is in the required confirmation list - if (this.config.requiredConfirmationTools.includes(toolName)) { - return true; - } - - // Auto-confirm low-risk tools if enabled - if (riskLevel === 'low' && this.config.autoConfirmLowRisk) { - return false; - } - - // Default to requiring confirmation for medium-risk tools - return riskLevel === 'medium'; - } - - /** - * Request user confirmation for tool execution - */ - private async requestUserConfirmation( - toolCall: any, - interactionId: string, - streamCallback?: StreamCallback - ): Promise { - - const toolName = toolCall.function.name; - const args = this.parseToolArguments(toolCall.function.arguments); - - // Create pending interaction - const pendingInteraction: PendingInteraction = { - id: interactionId, - toolCall, - timestamp: Date.now(), - resolved: false - }; - - this.pendingInteractions.set(interactionId, pendingInteraction); - - // Send confirmation request via streaming - if (streamCallback) { - this.sendConfirmationRequest(streamCallback, toolCall, interactionId, args); - } - - // Wait for user response or timeout - return new Promise((resolve) => { - // Set up timeout - const timeoutHandle = setTimeout(() => { - if (!pendingInteraction.resolved) { - pendingInteraction.resolved = true; - this.pendingInteractions.delete(interactionId); - this.interactionCallbacks.delete(interactionId); - - resolve({ - id: interactionId, - toolName, - response: 'timeout', - timestamp: Date.now(), - duration: Date.now() - pendingInteraction.timestamp - }); - } - }, this.config.confirmationTimeout); - - pendingInteraction.timeoutHandle = timeoutHandle; - - // Set up response callback - this.interactionCallbacks.set(interactionId, (response: InteractionResponse) => { - if (!pendingInteraction.resolved) { - pendingInteraction.resolved = true; - - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - - this.pendingInteractions.delete(interactionId); - this.interactionCallbacks.delete(interactionId); - - resolve({ - id: interactionId, - toolName, - response, - timestamp: Date.now(), - duration: Date.now() - pendingInteraction.timestamp - }); - } - }); - }); - } - - /** - * Send confirmation request via streaming - */ - private sendConfirmationRequest( - streamCallback: StreamCallback, - toolCall: any, - interactionId: string, - args: Record - ): void { - - const toolName = toolCall.function.name; - const riskLevel = this.assessToolRiskLevel(toolName); - - // Create user-friendly description of the tool action - const actionDescription = this.createActionDescription(toolName, args); - - const confirmationData = { - type: 'user_confirmation', - action: 'request', - interactionId, - tool: { - name: toolName, - description: actionDescription, - arguments: args, - riskLevel - }, - options: { - confirm: { - label: 'Execute', - description: `Proceed with ${toolName}`, - style: riskLevel === 'high' ? 'warning' : 'primary' - }, - cancel: { - label: 'Cancel', - description: 'Skip this tool execution', - style: 'secondary' - } - }, - timeout: this.config.confirmationTimeout, - message: `Do you want to execute ${toolName}? ${actionDescription}` - }; - - streamCallback('', false, { - text: '', - done: false, - userInteraction: confirmationData - }); - } - - /** - * Create user-friendly action description - */ - private createActionDescription(toolName: string, args: Record): string { - switch (toolName) { - case 'search_notes': - return `Search your notes for: "${args.query || 'unknown query'}"`; - - case 'read_note': - return `Read note with ID: ${args.noteId || 'unknown'}`; - - case 'keyword_search': - return `Search for keyword: "${args.query || 'unknown query'}"`; - - case 'attribute_search': - return `Search for ${args.attributeType || 'attribute'}: "${args.attributeName || 'unknown'}"`; - - case 'note_by_path': - return `Find note at path: "${args.path || 'unknown path'}"`; - - case 'discover_tools': - return `Discover available tools`; - - default: - return `Execute ${toolName} with provided parameters`; - } - } - - /** - * Execute tool directly without confirmation - */ - private async executeToolDirectly( - toolCall: any, - input: ToolExecutionInput - ): Promise<{ toolCall: any, message: Message }> { - - const toolName = toolCall.function.name; - - try { - // Import and use tool registry - const toolRegistry = (await import('../../tools/tool_registry.js')).default; - const tool = toolRegistry.getTool(toolName); - - if (!tool) { - throw new Error(`Tool not found: ${toolName}`); - } - - const args = this.parseToolArguments(toolCall.function.arguments); - const result = await tool.execute(args); - - return { - toolCall, - message: { - role: 'tool', - content: typeof result === 'string' ? result : JSON.stringify(result, null, 2), - name: toolName, - tool_call_id: toolCall.id - } - }; - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error(`Error executing tool ${toolName}: ${errorMessage}`); - - return { - toolCall, - message: { - role: 'tool', - content: `Error: ${errorMessage}`, - name: toolName, - tool_call_id: toolCall.id - } - }; - } - } - - /** - * Parse tool arguments safely - */ - private parseToolArguments(args: string | Record): Record { - if (typeof args === 'string') { - try { - return JSON.parse(args); - } catch { - return { query: args }; - } - } - return args; - } - - /** - * Generate unique interaction ID - */ - private generateInteractionId(): string { - return `interaction_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; - } - - /** - * Handle user response to confirmation request - * This method would be called by the frontend/WebSocket handler - */ - public handleUserResponse(interactionId: string, response: 'confirm' | 'cancel'): boolean { - const callback = this.interactionCallbacks.get(interactionId); - - if (callback) { - log.info(`Received user response for interaction ${interactionId}: ${response}`); - callback(response); - return true; - } - - log.error(`No callback found for interaction ${interactionId}`); - return false; - } - - /** - * Cancel all pending interactions - */ - public cancelAllPendingInteractions(): void { - log.info(`Cancelling ${this.pendingInteractions.size} pending interactions`); - - for (const [id, interaction] of this.pendingInteractions.entries()) { - if (interaction.timeoutHandle) { - clearTimeout(interaction.timeoutHandle); - } - - const callback = this.interactionCallbacks.get(id); - if (callback && !interaction.resolved) { - callback('cancel'); - } - } - - this.pendingInteractions.clear(); - this.interactionCallbacks.clear(); - } - - /** - * Get pending interactions (for status monitoring) - */ - public getPendingInteractions(): Array<{ id: string, toolName: string, timestamp: number }> { - return Array.from(this.pendingInteractions.values()).map(interaction => ({ - id: interaction.id, - toolName: interaction.toolCall.function.name, - timestamp: interaction.timestamp - })); - } - - /** - * Update configuration - */ - public updateConfig(newConfig: Partial): void { - this.config = { ...this.config, ...newConfig }; - log.info(`User interaction configuration updated: ${JSON.stringify(newConfig)}`); - } -} \ No newline at end of file diff --git a/apps/server/src/services/llm/providers/anthropic_service.ts b/apps/server/src/services/llm/providers/anthropic_service.ts index f374e8358..3e65aa376 100644 --- a/apps/server/src/services/llm/providers/anthropic_service.ts +++ b/apps/server/src/services/llm/providers/anthropic_service.ts @@ -97,10 +97,12 @@ export class AnthropicService extends BaseAIService { providerOptions.betaVersion ); - // Log API key format (without revealing the actual key) - const apiKeyPrefix = providerOptions.apiKey?.substring(0, 7) || 'undefined'; - const apiKeyLength = providerOptions.apiKey?.length || 0; - log.info(`[DEBUG] Using Anthropic API key with prefix '${apiKeyPrefix}...' and length ${apiKeyLength}`); + // Log API key format (without revealing the actual key) - only in debug mode + if (process.env.LLM_DEBUG === 'true') { + const apiKeyPrefix = providerOptions.apiKey?.substring(0, 7) || 'undefined'; + const apiKeyLength = providerOptions.apiKey?.length || 0; + log.info(`Using Anthropic API key with prefix '${apiKeyPrefix}...' and length ${apiKeyLength}`); + } log.info(`Using Anthropic API with model: ${providerOptions.model}`); @@ -162,8 +164,10 @@ export class AnthropicService extends BaseAIService { // Non-streaming request const response = await client.messages.create(requestParams); - // Log the complete response for debugging - log.info(`[DEBUG] Complete Anthropic API response: ${JSON.stringify(response, null, 2)}`); + // Log the complete response only in debug mode + if (process.env.LLM_DEBUG === 'true') { + log.info(`Complete Anthropic API response: ${JSON.stringify(response, null, 2)}`); + } // Get the assistant's response text from the content blocks const textContent = response.content @@ -180,7 +184,9 @@ export class AnthropicService extends BaseAIService { ); if (toolBlocks.length > 0) { - log.info(`[DEBUG] Found ${toolBlocks.length} tool-related blocks in response`); + if (process.env.LLM_DEBUG === 'true') { + log.info(`Found ${toolBlocks.length} tool-related blocks in response`); + } // Use ToolFormatAdapter to convert from Anthropic format toolCalls = ToolFormatAdapter.convertToolCallsFromProvider( diff --git a/apps/server/src/services/llm/tools/tool_registry.ts b/apps/server/src/services/llm/tools/tool_registry.ts index e4dc9d245..d98ad6fee 100644 --- a/apps/server/src/services/llm/tools/tool_registry.ts +++ b/apps/server/src/services/llm/tools/tool_registry.ts @@ -154,18 +154,22 @@ export class ToolRegistry { const validTools = this.getAllTools(); const toolDefs = validTools.map(handler => handler.definition); - // Enhanced debugging for tool recognition issues - log.info(`========== TOOL REGISTRY DEBUG INFO ==========`); - log.info(`Total tools in registry: ${this.tools.size}`); - log.info(`Valid tools after validation: ${validTools.length}`); - log.info(`Tool definitions being sent to LLM: ${toolDefs.length}`); + // Enhanced debugging for tool recognition issues (only in debug mode) + if (process.env.LLM_DEBUG === 'true') { + log.info(`========== TOOL REGISTRY INFO ==========`); + log.info(`Total tools in registry: ${this.tools.size}`); + log.info(`Valid tools after validation: ${validTools.length}`); + log.info(`Tool definitions being sent to LLM: ${toolDefs.length}`); + } - // Log each tool for debugging - toolDefs.forEach((def, idx) => { - log.info(`Tool ${idx + 1}: ${def.function.name} - ${def.function.description?.substring(0, 100) || 'No description'}...`); - log.info(` Parameters: ${Object.keys(def.function.parameters?.properties || {}).join(', ') || 'none'}`); - log.info(` Required: ${def.function.parameters?.required?.join(', ') || 'none'}`); - }); + // Log each tool for debugging (only in debug mode) + if (process.env.LLM_DEBUG === 'true') { + toolDefs.forEach((def, idx) => { + log.info(`Tool ${idx + 1}: ${def.function.name} - ${def.function.description?.substring(0, 100) || 'No description'}...`); + log.info(` Parameters: ${Object.keys(def.function.parameters?.properties || {}).join(', ') || 'none'}`); + log.info(` Required: ${def.function.parameters?.required?.join(', ') || 'none'}`); + }); + } if (toolDefs.length === 0) { log.error(`CRITICAL: No tool definitions available for LLM! This will prevent tool calling.`);