diff --git a/src/services/llm/ai_interface.ts b/src/services/llm/ai_interface.ts index 5fdc8b235..f08977c8a 100644 --- a/src/services/llm/ai_interface.ts +++ b/src/services/llm/ai_interface.ts @@ -34,6 +34,7 @@ export interface ChatCompletionOptions { stream?: boolean; // Whether to stream the response enableTools?: boolean; // Whether to enable tool calling tools?: any[]; // Tools to provide to the LLM + useAdvancedContext?: boolean; // Whether to use advanced context enrichment } export interface ChatResponse { diff --git a/src/services/llm/chat_service.ts b/src/services/llm/chat_service.ts index 17364319e..5c54613ca 100644 --- a/src/services/llm/chat_service.ts +++ b/src/services/llm/chat_service.ts @@ -1,9 +1,21 @@ -import type { Message, ChatCompletionOptions } from './ai_interface.js'; +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'; + +// Update the ChatCompletionOptions interface to include the missing properties +// TODO fix +declare module './ai_interface.js' { + interface ChatCompletionOptions { + pipeline?: string; + noteId?: string; + useAdvancedContext?: boolean; + } +} export interface ChatSession { id: string; @@ -365,7 +377,7 @@ export class ChatService { const pipeline = this.getPipeline(pipelineType); return pipeline.getMetrics(); } - + /** * Reset pipeline metrics */ @@ -398,8 +410,62 @@ export class ChatService { // 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: any) { + console.error('Error in generateChatCompletion:', error); + throw error; + } + } } // Singleton instance const chatService = new ChatService(); -export default chatService; \ No newline at end of file +export default chatService; diff --git a/src/services/llm/interfaces/agent_tool_interfaces.ts b/src/services/llm/interfaces/agent_tool_interfaces.ts index 97ebccd69..a9b7a20f8 100644 --- a/src/services/llm/interfaces/agent_tool_interfaces.ts +++ b/src/services/llm/interfaces/agent_tool_interfaces.ts @@ -19,6 +19,13 @@ export interface LLMServiceInterface { stream?: boolean; systemPrompt?: string; }): Promise; + + /** + * Generate search queries by decomposing a complex query into simpler ones + * @param query The original user query to decompose + * @returns An array of decomposed search queries + */ + generateSearchQueries?(query: string): Promise; } /** diff --git a/src/services/llm/pipeline/chat_pipeline.ts b/src/services/llm/pipeline/chat_pipeline.ts index 25296bdd5..41c1b9e2a 100644 --- a/src/services/llm/pipeline/chat_pipeline.ts +++ b/src/services/llm/pipeline/chat_pipeline.ts @@ -1,5 +1,5 @@ import type { ChatPipelineInput, ChatPipelineConfig, PipelineMetrics, StreamCallback } from './interfaces.js'; -import type { ChatResponse, StreamChunk } from '../ai_interface.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'; @@ -12,6 +12,7 @@ import { VectorSearchStage } from './stages/vector_search_stage.js'; 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'; /** * Pipeline for managing the entire chat flow @@ -80,6 +81,7 @@ export class ChatPipeline { * 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++; @@ -113,89 +115,107 @@ export class ChatPipeline { // 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; - // Determine which pipeline flow to use - let context: string | undefined; + // 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'); - // For context-aware chats, get the appropriate context - if (input.noteId && input.query) { - const contextStartTime = Date.now(); - if (input.showThinking) { - // Get enhanced context with agent tools if thinking is enabled - const agentContext = await this.stages.agentToolsContext.execute({ - noteId: input.noteId, - query: input.query, - showThinking: input.showThinking - }); - context = agentContext.context; - this.updateStageMetrics('agentToolsContext', contextStartTime); - } else if (!useTools) { - // Only get semantic context if tools are NOT enabled - // When tools are enabled, we'll let the LLM request context via tools instead - log.info('Getting semantic context for note using pipeline stages'); - - // First use the vector search stage to find relevant notes - const vectorSearchStartTime = Date.now(); - log.info(`Executing vector search stage for query: "${input.query?.substring(0, 50)}..."`); - - const vectorSearchResult = await this.stages.vectorSearch.execute({ - query: input.query || '', - noteId: input.noteId, - options: { - maxResults: 10, - useEnhancedQueries: true, - threshold: 0.6 - } - }); - - this.updateStageMetrics('vectorSearch', vectorSearchStartTime); - - log.info(`Vector search found ${vectorSearchResult.searchResults.length} relevant notes`); - - // Then pass to the semantic context stage to build the formatted context - const semanticContext = await this.stages.semanticContextExtraction.execute({ - noteId: input.noteId, - query: input.query, - messages: input.messages - }); - - context = semanticContext.context; - this.updateStageMetrics('semanticContextExtraction', contextStartTime); - } else { - log.info('Tools are enabled - using minimal direct context to avoid race conditions'); - // Get context from current note directly without semantic search - if (input.noteId) { - try { - const contextExtractor = new (await import('../../llm/context/index.js')).ContextExtractor(); - // Just get the direct content of the current note - context = await contextExtractor.extractContext(input.noteId, { - includeContent: true, - includeParents: true, - includeChildren: true, - includeLinks: true, - includeSimilar: false // Skip semantic search to avoid race conditions - }); - log.info(`Direct context extracted (${context.length} chars) without semantic search`); - } catch (error: any) { - log.error(`Error extracting direct context: ${error.message}`); - context = ""; // Fallback to empty context if extraction fails - } - } else { - context = ""; // No note ID, so no context - } - } + // 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; } - // Prepare messages with context and system prompt + // 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]; // Default to original query + + if (llmService && llmService.generateSearchQueries) { + try { + const decompositionResult = await llmService.generateSearchQueries(userQuery); + if (decompositionResult && decompositionResult.length > 0) { + searchQueries = decompositionResult; + log.info(`Generated ${searchQueries.length} search queries: ${JSON.stringify(searchQueries)}`); + } else { + log.info('Query decomposition returned no results, 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: Execute vector similarity search with decomposed queries + const vectorSearchStartTime = Date.now(); + log.info(`========== STAGE 3: VECTOR SEARCH ==========`); + log.info('Using VectorSearchStage pipeline component to find relevant notes'); + + const vectorSearchResult = await this.stages.vectorSearch.execute({ + query: userQuery, + noteId: input.noteId || 'global', + options: { + maxResults: 5, // Can be adjusted + useEnhancedQueries: true, + threshold: 0.6, + llmService: llmService || undefined + } + }); + + this.updateStageMetrics('vectorSearch', vectorSearchStartTime); + + log.info(`Vector search found ${vectorSearchResult.searchResults.length} relevant notes`); + + // 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, @@ -204,9 +224,7 @@ export class ChatPipeline { options: modelSelection.options }); this.updateStageMetrics('messagePreparation', messagePreparationStartTime); - - // Generate completion using the LLM - const llmStartTime = Date.now(); + log.info(`Prepared ${preparedMessages.messages.length} messages for LLM, tools enabled: ${useTools}`); // Setup streaming handler if streaming is enabled and callback provided const enableStreaming = this.config.enableStreaming && @@ -218,11 +236,15 @@ export class ChatPipeline { modelSelection.options.stream = true; } + // 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}`); // Handle streaming if enabled and available if (enableStreaming && completion.response.stream && streamCallback) { @@ -242,123 +264,247 @@ export class ChatPipeline { // Process any tool calls in the response let currentMessages = preparedMessages.messages; let currentResponse = completion.response; - let needsFollowUp = false; let toolCallIterations = 0; const maxToolCallIterations = this.config.maxToolCallIterations; // Check if tools were enabled in the options const toolsEnabled = modelSelection.options.enableTools !== false; - - log.info(`========== TOOL CALL PROCESSING ==========`); - log.info(`Tools enabled: ${toolsEnabled}`); - log.info(`Tool calls in response: ${currentResponse.tool_calls ? currentResponse.tool_calls.length : 0}`); - log.info(`Current response format: ${typeof currentResponse}`); - log.info(`Response keys: ${Object.keys(currentResponse).join(', ')}`); - - // Detailed tool call inspection + + // 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'}`); + log.info(`Response has tool_calls: ${currentResponse.tool_calls ? 'true' : 'false'}`); if (currentResponse.tool_calls) { - currentResponse.tool_calls.forEach((tool, idx) => { - log.info(`Tool call ${idx+1}: ${JSON.stringify(tool)}`); - }); + log.info(`Number of tool calls: ${currentResponse.tool_calls.length}`); + log.info(`Tool calls details: ${JSON.stringify(currentResponse.tool_calls)}`); + + // Check if we have a response from Ollama, which might be handled differently + if (currentResponse.provider === 'Ollama') { + log.info(`ATTENTION: Response is from Ollama - checking if tool execution path is correct`); + log.info(`Tool calls type: ${typeof currentResponse.tool_calls}`); + log.info(`First tool call name: ${currentResponse.tool_calls[0]?.function?.name || 'unknown'}`); + } } - // Process tool calls if present and tools are enabled + // Tool execution loop if (toolsEnabled && currentResponse.tool_calls && currentResponse.tool_calls.length > 0) { + log.info(`========== STAGE 6: TOOL EXECUTION ==========`); log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`); - // Start tool calling loop - log.info(`Starting tool calling loop with max ${maxToolCallIterations} iterations`); + // 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 || '{}'}`); + }); - do { - log.info(`Tool calling iteration ${toolCallIterations + 1}`); + // Keep track of whether we're in a streaming response + const isStreaming = enableStreaming && streamCallback; + let streamingPaused = false; - // Execute tool calling stage - const toolCallingStartTime = Date.now(); - const toolCallingResult = await this.stages.toolCalling.execute({ - response: currentResponse, - messages: currentMessages, - options: modelSelection.options - }); - this.updateStageMetrics('toolCalling', toolCallingStartTime); + // If streaming was enabled, send an update to the user + if (isStreaming && streamCallback) { + streamingPaused = true; + await streamCallback('', true); // Signal pause in streaming + await streamCallback('\n\n[Executing tools...]\n\n', false); + } - // Update state for next iteration - currentMessages = toolCallingResult.messages; - needsFollowUp = toolCallingResult.needsFollowUp; + while (toolCallIterations < maxToolCallIterations) { + toolCallIterations++; + log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`); - // Make another call to the LLM if needed - if (needsFollowUp) { - log.info(`Tool execution completed, making follow-up LLM call (iteration ${toolCallIterations + 1})...`); + // Create a copy of messages before tool execution + const previousMessages = [...currentMessages]; - // Generate a new LLM response with the updated messages - const followUpStartTime = Date.now(); - log.info(`Sending follow-up request to LLM with ${currentMessages.length} messages (including tool results)`); + 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 followUpCompletion = await this.stages.llmCompletion.execute({ + const toolCallingResult = await this.stages.toolCalling.execute({ + response: currentResponse, messages: currentMessages, options: modelSelection.options }); - this.updateStageMetrics('llmCompletion', followUpStartTime); + this.updateStageMetrics('toolCalling', toolCallingStartTime); - // Update current response for next iteration - currentResponse = followUpCompletion.response; + log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`); - // Check for more tool calls - const hasMoreToolCalls = !!(currentResponse.tool_calls && currentResponse.tool_calls.length > 0); + // Update messages with tool results + currentMessages = toolCallingResult.messages; - if (hasMoreToolCalls) { - log.info(`Follow-up response contains ${currentResponse.tool_calls?.length || 0} more tool calls`); + // Log the tool results for debugging + const toolResultMessages = currentMessages.filter( + msg => msg.role === 'tool' && !previousMessages.includes(msg) + ); + + log.info(`========== TOOL EXECUTION RESULTS ==========`); + toolResultMessages.forEach((msg, idx) => { + log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content.substring(0, 50)}...`); + + // 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 || ''); + const formattedToolResult = `[Tool: ${toolName || 'unknown'}]\n${msg.content}\n\n`; + streamCallback(formattedToolResult, false); + } + }); + + // 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) { + await streamCallback('[Generating response with tool results...]\n\n', false); + } + + // Generate a new completion with the updated messages + const followUpStartTime = Date.now(); + const followUpCompletion = await this.stages.llmCompletion.execute({ + messages: currentMessages, + options: { + ...modelSelection.options, + // Ensure tool support is still enabled for follow-up requests + enableTools: true, + // Disable streaming during tool execution follow-ups + stream: false + } + }); + this.updateStageMetrics('llmCompletion', followUpStartTime); + + // 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(`Follow-up response contains no more tool calls - completing tool loop`); + 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) { + await streamCallback(`[Tool execution error: ${error.message || 'unknown error'}]\n\n`, false); } - // Continue loop if there are more tool calls - needsFollowUp = hasMoreToolCalls; + // Make a follow-up request to the LLM with the error information + const errorFollowUpCompletion = await this.stages.llmCompletion.execute({ + messages: currentMessages, + options: modelSelection.options + }); + + // Update current response and break the tool loop + currentResponse = errorFollowUpCompletion.response; + break; } - - // Increment iteration counter - toolCallIterations++; - - } while (needsFollowUp && toolCallIterations < maxToolCallIterations); - - // If we hit max iterations but still have tool calls, log a warning - if (toolCallIterations >= maxToolCallIterations && needsFollowUp) { - log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), stopping`); } - log.info(`Completed ${toolCallIterations} tool call iterations`); + 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) { + await streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false); + } + + // 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 + } + }); + + // 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) { + // Resume streaming with the final response text + await streamCallback(currentResponse.text, true); + } + } else if (toolsEnabled) { + log.info(`========== NO TOOL CALLS DETECTED ==========`); + log.info(`LLM response did not contain any tool calls, skipping tool execution`); } - // For non-streaming responses, process the final response - const processStartTime = Date.now(); - const processed = await this.stages.responseProcessing.execute({ + // Process the final response + log.info(`========== FINAL RESPONSE PROCESSING ==========`); + const responseProcessingStartTime = Date.now(); + const processedResponse = await this.stages.responseProcessing.execute({ response: currentResponse, - options: input.options + options: modelSelection.options }); - this.updateStageMetrics('responseProcessing', processStartTime); + this.updateStageMetrics('responseProcessing', responseProcessingStartTime); + log.info(`Final response processed, returning to user (${processedResponse.text.length} chars)`); - // Combine response with processed text, using accumulated text if streamed - const finalResponse: ChatResponse = { - ...currentResponse, - text: accumulatedText || processed.text - }; + // 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; - const endTime = Date.now(); - const executionTime = endTime - startTime; - - // Update overall average execution time - this.metrics.averageExecutionTime = - (this.metrics.averageExecutionTime * (this.metrics.totalExecutions - 1) + executionTime) / - this.metrics.totalExecutions; - - log.info(`Chat pipeline completed in ${executionTime}ms`); - - return finalResponse; + log.info(`========== PIPELINE COMPLETE ==========`); + return currentResponse; } catch (error: any) { - log.error(`Error in chat pipeline: ${error.message}`); + 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 */ @@ -428,4 +574,52 @@ export class ChatPipeline { }; }); } + + /** + * 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/src/services/llm/pipeline/stages/tool_calling_stage.ts b/src/services/llm/pipeline/stages/tool_calling_stage.ts index e7365e983..e66bf0009 100644 --- a/src/services/llm/pipeline/stages/tool_calling_stage.ts +++ b/src/services/llm/pipeline/stages/tool_calling_stage.ts @@ -22,25 +22,29 @@ export class ToolCallingStage extends BasePipelineStage { const { response, messages, options } = input; - + + log.info(`========== TOOL CALLING STAGE ENTRY ==========`); + log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`); + // 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.info(`LLM requested ${response.tool_calls.length} tool calls from provider: ${response.provider}`); - + // 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 availableTools = toolRegistry.getAllTools(); log.info(`Available tools in registry: ${availableTools.length}`); - + if (availableTools.length === 0) { log.error(`No tools available in registry, cannot execute tool calls`); // Try to initialize tools as a recovery step @@ -53,10 +57,10 @@ export class ToolCallingStage extends BasePipelineStage { + log.info(`========== STARTING TOOL EXECUTION ==========`); + const toolResults = await Promise.all(response.tool_calls.map(async (toolCall, index) => { try { - log.info(`Tool call received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`); - + 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 + const argsStr = typeof toolCall.function.arguments === 'string' + ? toolCall.function.arguments : JSON.stringify(toolCall.function.arguments); log.info(`Tool parameters: ${argsStr}`); - + // Get the tool from registry const tool = toolRegistry.getTool(toolCall.function.name); - + if (!tool) { + log.error(`Tool not found in registry: ${toolCall.function.name}`); + log.info(`Available tools: ${availableTools.map(t => t.definition.function.name).join(', ')}`); throw new Error(`Tool not found: ${toolCall.function.name}`); } - + + log.info(`Tool found in registry: ${toolCall.function.name}`); + // Parse arguments (handle both string and object formats) let args; // 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); log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`); - } catch (e) { + } catch (e: unknown) { // If it's not valid JSON, try to check if it's a stringified object with quotes - log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${e.message}`); - + 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 { @@ -105,13 +115,14 @@ export class ToolCallingStage extends BasePipelineStage `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`); - + const executionStart = Date.now(); let result; try { @@ -139,13 +150,14 @@ export class ToolCallingStage extends BasePipelineStage { // Format the result content based on type let content: string; - + if (typeof result.result === 'string') { content = result.result; log.info(`Tool returned string result (${content.length} chars)`); @@ -182,9 +194,9 @@ export class ToolCallingStage extends BasePipelineStage 100 ? '...' : ''); log.info(`Tool result preview: ${contentPreview}`); }); - + log.info(`Added ${toolResults.length} tool results to conversation`); - + // If we have tool results, we need a follow-up call to the LLM const needsFollowUp = toolResults.length > 0; - + if (needsFollowUp) { log.info(`Tool execution complete, LLM follow-up required with ${updatedMessages.length} messages`); } - + return { response, needsFollowUp, diff --git a/src/services/llm/providers/ollama_service.ts b/src/services/llm/providers/ollama_service.ts index 61a7366be..6f5dc1758 100644 --- a/src/services/llm/providers/ollama_service.ts +++ b/src/services/llm/providers/ollama_service.ts @@ -107,14 +107,14 @@ export class OllamaService extends BaseAIService { const tools = toolRegistry.getAllToolDefinitions(); requestBody.tools = tools; log.info(`Adding ${tools.length} tools to request`); - + // If no tools found, reinitialize if (tools.length === 0) { log.info('No tools found in registry, re-initializing...'); try { const toolInitializer = await import('../tools/tool_initializer.js'); await toolInitializer.default.initializeTools(); - + // Try again requestBody.tools = toolRegistry.getAllToolDefinitions(); log.info(`After re-initialization: ${requestBody.tools.length} tools available`); @@ -139,43 +139,43 @@ export class OllamaService extends BaseAIService { log.info(`========== OLLAMA API REQUEST ==========`); log.info(`Model: ${requestBody.model}, Messages: ${requestBody.messages.length}, Tools: ${requestBody.tools ? requestBody.tools.length : 0}`); log.info(`Temperature: ${temperature}, Stream: ${requestBody.stream}, JSON response expected: ${expectsJsonResponse}`); - + // Check message structure and log detailed information about each message requestBody.messages.forEach((msg: any, index: number) => { const keys = Object.keys(msg); log.info(`Message ${index}, Role: ${msg.role}, Keys: ${keys.join(', ')}`); - + // Log message content preview if (msg.content && typeof msg.content === 'string') { - const contentPreview = msg.content.length > 200 - ? `${msg.content.substring(0, 200)}...` + const contentPreview = msg.content.length > 200 + ? `${msg.content.substring(0, 200)}...` : msg.content; log.info(`Message ${index} content: ${contentPreview}`); } - + // Log tool-related details if (keys.includes('tool_calls')) { log.info(`Message ${index} has ${msg.tool_calls.length} tool calls:`); msg.tool_calls.forEach((call: any, callIdx: number) => { log.info(` Tool call ${callIdx}: ${call.function?.name || 'unknown'}, ID: ${call.id || 'unspecified'}`); if (call.function?.arguments) { - const argsPreview = typeof call.function.arguments === 'string' - ? call.function.arguments.substring(0, 100) + const argsPreview = typeof call.function.arguments === 'string' + ? call.function.arguments.substring(0, 100) : JSON.stringify(call.function.arguments).substring(0, 100); log.info(` Arguments: ${argsPreview}...`); } }); } - + if (keys.includes('tool_call_id')) { log.info(`Message ${index} is a tool response for tool call ID: ${msg.tool_call_id}`); } - + if (keys.includes('name') && msg.role === 'tool') { log.info(`Message ${index} is from tool: ${msg.name}`); } }); - + // Log tool definitions if (requestBody.tools && requestBody.tools.length > 0) { log.info(`Sending ${requestBody.tools.length} tool definitions:`); @@ -185,19 +185,19 @@ export class OllamaService extends BaseAIService { log.info(` Description: ${tool.function.description.substring(0, 100)}...`); } if (tool.function?.parameters) { - const paramNames = tool.function.parameters.properties - ? Object.keys(tool.function.parameters.properties) + const paramNames = tool.function.parameters.properties + ? Object.keys(tool.function.parameters.properties) : []; log.info(` Parameters: ${paramNames.join(', ')}`); } }); } - + // Log full request body (this will create large logs but is helpful for debugging) const requestStr = JSON.stringify(requestBody); log.info(`Full Ollama request (truncated): ${requestStr.substring(0, 1000)}...`); log.info(`========== END OLLAMA REQUEST ==========`); - + // Make API request const response = await fetch(`${apiBase}/api/chat`, { method: 'POST', @@ -214,20 +214,20 @@ export class OllamaService extends BaseAIService { } const data: OllamaResponse = await response.json(); - + // Log response details log.info(`========== OLLAMA API RESPONSE ==========`); log.info(`Model: ${data.model}, Content length: ${data.message.content.length} chars`); log.info(`Tokens: ${data.prompt_eval_count} prompt, ${data.eval_count} completion, ${data.prompt_eval_count + data.eval_count} total`); log.info(`Duration: ${data.total_duration}ns total, ${data.prompt_eval_duration}ns prompt, ${data.eval_duration}ns completion`); log.info(`Done: ${data.done}, Reason: ${data.done_reason || 'not specified'}`); - + // Log content preview - const contentPreview = data.message.content.length > 300 - ? `${data.message.content.substring(0, 300)}...` + const contentPreview = data.message.content.length > 300 + ? `${data.message.content.substring(0, 300)}...` : data.message.content; log.info(`Response content: ${contentPreview}`); - + // Handle the response and extract tool calls if present const chatResponse: ChatResponse = { text: data.message.content, @@ -242,45 +242,47 @@ export class OllamaService extends BaseAIService { // Add tool calls if present if (data.message.tool_calls && data.message.tool_calls.length > 0) { + log.info(`========== OLLAMA TOOL CALLS DETECTED ==========`); log.info(`Ollama response includes ${data.message.tool_calls.length} tool calls`); - + // Log detailed information about each tool call const transformedToolCalls: ToolCall[] = []; - + // Log detailed information about the tool calls in the response log.info(`========== OLLAMA TOOL CALLS IN RESPONSE ==========`); data.message.tool_calls.forEach((toolCall, index) => { log.info(`Tool call ${index + 1}:`); log.info(` Name: ${toolCall.function?.name || 'unknown'}`); log.info(` ID: ${toolCall.id || `auto-${index + 1}`}`); - + // Generate a unique ID if none is provided const id = toolCall.id || `tool-call-${Date.now()}-${index}`; - + // Handle arguments based on their type let processedArguments: Record | string; - + if (typeof toolCall.function.arguments === 'string') { // Log raw string arguments in full for debugging log.info(` Raw string arguments: ${toolCall.function.arguments}`); - + // Try to parse JSON string arguments try { processedArguments = JSON.parse(toolCall.function.arguments); log.info(` Successfully parsed arguments to object with keys: ${Object.keys(processedArguments).join(', ')}`); log.info(` Parsed argument values:`); Object.entries(processedArguments).forEach(([key, value]) => { - const valuePreview = typeof value === 'string' + const valuePreview = typeof value === 'string' ? (value.length > 100 ? `${value.substring(0, 100)}...` : value) : JSON.stringify(value); log.info(` ${key}: ${valuePreview}`); }); - } catch (e) { + } catch (e: unknown) { // If parsing fails, keep as string and log the error processedArguments = toolCall.function.arguments; - log.info(` Could not parse arguments as JSON: ${e.message}`); + const errorMessage = e instanceof Error ? e.message : String(e); + log.info(` Could not parse arguments as JSON: ${errorMessage}`); log.info(` Keeping as string: ${processedArguments.substring(0, 200)}${processedArguments.length > 200 ? '...' : ''}`); - + // Try to clean and parse again with more aggressive methods try { const cleaned = toolCall.function.arguments @@ -288,12 +290,13 @@ export class OllamaService extends BaseAIService { .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(` Attempting to parse cleaned argument: ${cleaned}`); const reparseArg = JSON.parse(cleaned); log.info(` Successfully parsed cleaned argument with keys: ${Object.keys(reparseArg).join(', ')}`); - } catch (cleanErr) { - log.info(` Failed to parse cleaned arguments: ${cleanErr.message}`); + } catch (cleanErr: unknown) { + const cleanErrMessage = cleanErr instanceof Error ? cleanErr.message : String(cleanErr); + log.info(` Failed to parse cleaned arguments: ${cleanErrMessage}`); } } } else { @@ -302,13 +305,13 @@ export class OllamaService extends BaseAIService { log.info(` Object arguments with keys: ${Object.keys(processedArguments).join(', ')}`); log.info(` Argument values:`); Object.entries(processedArguments).forEach(([key, value]) => { - const valuePreview = typeof value === 'string' + const valuePreview = typeof value === 'string' ? (value.length > 100 ? `${value.substring(0, 100)}...` : value) : JSON.stringify(value); log.info(` ${key}: ${valuePreview}`); }); } - + // Convert to our standard ToolCall format transformedToolCalls.push({ id, @@ -319,11 +322,39 @@ export class OllamaService extends BaseAIService { } }); }); - + // Add transformed tool calls to response chatResponse.tool_calls = transformedToolCalls; log.info(`Transformed ${transformedToolCalls.length} tool calls for execution`); + log.info(`Tool calls after transformation: ${JSON.stringify(chatResponse.tool_calls)}`); + + // CRITICAL: Explicitly mark response for tool execution + log.info(`CRITICAL: Explicitly marking response for tool execution`); + + // Ensure tool_calls is properly exposed and formatted + // This is to make sure the pipeline can detect and execute the tools + if (transformedToolCalls.length > 0) { + // Make sure the tool_calls are exposed in the exact format expected by pipeline + chatResponse.tool_calls = transformedToolCalls.map(tc => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + })); + + // If the content is empty, use a placeholder to avoid issues + if (!chatResponse.text) { + chatResponse.text = "Processing your request..."; + } + + log.info(`Final tool_calls format for pipeline: ${JSON.stringify(chatResponse.tool_calls)}`); + } log.info(`========== END OLLAMA TOOL CALLS ==========`); + } else { + log.info(`========== NO OLLAMA TOOL CALLS DETECTED ==========`); + log.info(`Checking raw message response format: ${JSON.stringify(data.message)}`); } log.info(`========== END OLLAMA RESPONSE ==========`); diff --git a/src/services/llm/rest_chat_service.ts b/src/services/llm/rest_chat_service.ts index 6ee548487..af2ea88a8 100644 --- a/src/services/llm/rest_chat_service.ts +++ b/src/services/llm/rest_chat_service.ts @@ -4,7 +4,7 @@ import type { Message, ChatCompletionOptions } from "./ai_interface.js"; import contextService from "./context_service.js"; import { LLM_CONSTANTS } from './constants/provider_constants.js'; import { ERROR_PROMPTS } from './constants/llm_prompt_constants.js'; -import * as aiServiceManagerModule from "./ai_service_manager.js"; +import aiServiceManagerImport from "./ai_service_manager.js"; import becca from "../../becca/becca.js"; import vectorStore from "./embeddings/index.js"; import providerManager from "./providers/providers.js"; @@ -94,7 +94,7 @@ class RestChatService { // Try to access the manager - will create instance only if needed try { - const aiManager = aiServiceManagerModule.default; + const aiManager = aiServiceManagerImport.getInstance(); if (!aiManager) { log.info("AI check failed: AI manager module is not available"); @@ -315,7 +315,7 @@ class RestChatService { log.info("AI services are not available - checking for specific issues"); try { - const aiManager = aiServiceManagerModule.default; + const aiManager = aiServiceManagerImport.getInstance(); if (!aiManager) { log.error("AI service manager is not initialized"); @@ -341,7 +341,7 @@ class RestChatService { } // Get the AI service manager - const aiServiceManager = aiServiceManagerModule.default.getInstance(); + const aiServiceManager = aiServiceManagerImport.getInstance(); // Get the default service - just use the first available one const availableProviders = aiServiceManager.getAvailableProviders(); @@ -468,6 +468,9 @@ class RestChatService { // Use the Trilium-specific approach const contextNoteId = session.noteContext || null; + // Ensure tools are initialized to prevent tool execution issues + await this.ensureToolsInitialized(); + // Log that we're calling contextService with the parameters log.info(`Using enhanced context with: noteId=${contextNoteId}, showThinking=${showThinking}`); @@ -506,23 +509,67 @@ class RestChatService { temperature: session.metadata.temperature || 0.7, maxTokens: session.metadata.maxTokens, model: session.metadata.model, - stream: isStreamingRequest ? true : undefined + stream: isStreamingRequest ? true : undefined, + enableTools: true // Explicitly enable tools }; - // Process based on whether this is a streaming request + // Add a note indicating we're explicitly enabling tools + log.info(`Advanced context flow: explicitly enabling tools in chat options`); + + // Process streaming responses differently if (isStreamingRequest) { + // Handle streaming using the existing method await this.handleStreamingResponse(res, aiMessages, chatOptions, service, session); } else { - // Non-streaming approach for POST requests + // For non-streaming requests, generate a completion synchronously const response = await service.generateChatCompletion(aiMessages, chatOptions); - const aiResponse = response.text; // Extract the text from the response - // Store the assistant's response in the session - session.messages.push({ - role: 'assistant', - content: aiResponse, - timestamp: new Date() - }); + // Check if the response contains tool calls + if (response.tool_calls && response.tool_calls.length > 0) { + log.info(`Advanced context non-streaming: detected ${response.tool_calls.length} tool calls in response`); + log.info(`Tool calls details: ${JSON.stringify(response.tool_calls)}`); + + try { + // Execute the tools + const toolResults = await this.executeToolCalls(response); + log.info(`Successfully executed ${toolResults.length} tool calls in advanced context flow`); + + // Build updated messages with tool results + const toolMessages = [...aiMessages, { + role: 'assistant', + content: response.text || '', + tool_calls: response.tool_calls + }, ...toolResults]; + + // Make a follow-up request with the tool results + log.info(`Making follow-up request with ${toolResults.length} tool results`); + const followUpOptions = {...chatOptions, enableTools: false}; // Disable tools for follow-up + const followUpResponse = await service.generateChatCompletion(toolMessages, followUpOptions); + + // Update the session with the final response + session.messages.push({ + role: 'assistant', + content: followUpResponse.text || '', + timestamp: new Date() + }); + } catch (toolError: any) { + log.error(`Error executing tools in advanced context: ${toolError.message}`); + + // Add error response to session + session.messages.push({ + role: 'assistant', + content: `Error executing tools: ${toolError.message}`, + timestamp: new Date() + }); + } + } else { + // No tool calls, just add the response to the session + session.messages.push({ + role: 'assistant', + content: response.text || '', + timestamp: new Date() + }); + } } return sourceNotes; @@ -608,50 +655,57 @@ class RestChatService { try { // Use the correct method name: generateChatCompletion const response = await service.generateChatCompletion(aiMessages, chatOptions); - + // Check for tool calls in the response if (response.tool_calls && response.tool_calls.length > 0) { log.info(`========== STREAMING TOOL CALLS DETECTED ==========`); log.info(`Response contains ${response.tool_calls.length} tool calls, executing them...`); - + log.info(`CRITICAL CHECK: Tool execution is supposed to happen in the pipeline, not directly here.`); + log.info(`If tools are being executed here instead of in the pipeline, this may be a flow issue.`); + log.info(`Response came from provider: ${response.provider || 'unknown'}, model: ${response.model || 'unknown'}`); + try { + log.info(`========== STREAMING TOOL EXECUTION PATH ==========`); + log.info(`About to execute tools in streaming path (this is separate from pipeline tool execution)`); + // Execute the tools const toolResults = await this.executeToolCalls(response); - + log.info(`Successfully executed ${toolResults.length} tool calls in streaming path`); + // Make a follow-up request with the tool results const toolMessages = [...aiMessages, { role: 'assistant', content: response.text || '', tool_calls: response.tool_calls }, ...toolResults]; - + log.info(`Making follow-up request with ${toolResults.length} tool results`); - + // Send partial response to let the client know tools are being processed if (!res.writableEnded) { res.write(`data: ${JSON.stringify({ content: "Processing tools... " })}\n\n`); } - + // Use non-streaming for the follow-up to get a complete response const followUpOptions = {...chatOptions, stream: false, enableTools: false}; // Prevent infinite loops const followUpResponse = await service.generateChatCompletion(toolMessages, followUpOptions); - + messageContent = followUpResponse.text || ""; - + // Send the complete response as a single chunk if (!res.writableEnded) { res.write(`data: ${JSON.stringify({ content: messageContent })}\n\n`); res.write('data: [DONE]\n\n'); res.end(); } - + // Store the full response for the session session.messages.push({ role: 'assistant', content: messageContent, timestamp: new Date() }); - + return; // Skip the rest of the processing } catch (toolError) { log.error(`Error executing tools: ${toolError}`); @@ -716,48 +770,55 @@ class RestChatService { } } } - + /** * Execute tool calls from the LLM response * @param response The LLM response containing tool calls */ private async executeToolCalls(response: any): Promise { + log.info(`========== REST SERVICE TOOL EXECUTION FLOW ==========`); + log.info(`Entered executeToolCalls method in REST chat service`); + if (!response.tool_calls || response.tool_calls.length === 0) { + log.info(`No tool calls to execute, returning early`); return []; } - + log.info(`Executing ${response.tool_calls.length} tool calls from REST chat service`); - + // Import tool registry directly to avoid circular dependencies const toolRegistry = (await import('./tools/tool_registry.js')).default; - + // Check if tools are available const availableTools = toolRegistry.getAllTools(); + log.info(`Available tools in registry: ${availableTools.length}`); + if (availableTools.length === 0) { log.error('No tools available in registry for execution'); - + // Try to initialize tools try { const toolInitializer = await import('./tools/tool_initializer.js'); await toolInitializer.default.initializeTools(); log.info(`Initialized ${toolRegistry.getAllTools().length} tools`); - } catch (error) { - log.error(`Failed to initialize tools: ${error}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error(`Failed to initialize tools: ${errorMessage}`); throw new Error('Tool execution failed: No tools available'); } } - + // Execute each tool call and collect results const toolResults = await Promise.all(response.tool_calls.map(async (toolCall: any) => { try { log.info(`Executing tool: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`); - + // Get the tool from registry const tool = toolRegistry.getTool(toolCall.function.name); if (!tool) { throw new Error(`Tool not found: ${toolCall.function.name}`); } - + // Parse arguments let args; if (typeof toolCall.function.arguments === 'string') { @@ -765,7 +826,7 @@ class RestChatService { args = JSON.parse(toolCall.function.arguments); } catch (e) { log.error(`Failed to parse tool arguments: ${e.message}`); - + // Try cleanup and retry try { const cleaned = toolCall.function.arguments @@ -773,7 +834,7 @@ class RestChatService { .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 - + args = JSON.parse(cleaned); } catch (cleanErr) { // If all parsing fails, use as-is @@ -783,23 +844,23 @@ class RestChatService { } else { args = toolCall.function.arguments; } - + // Log what we're about to execute log.info(`Executing tool with arguments: ${JSON.stringify(args)}`); - + // Execute the tool and get result const startTime = Date.now(); const result = await tool.execute(args); const executionTime = Date.now() - startTime; - + log.info(`Tool execution completed in ${executionTime}ms`); - + // Log the result - const resultPreview = typeof result === 'string' + const resultPreview = typeof result === 'string' ? result.substring(0, 100) + (result.length > 100 ? '...' : '') : JSON.stringify(result).substring(0, 100) + '...'; log.info(`Tool result: ${resultPreview}`); - + // Format result as a proper message return { role: 'tool', @@ -809,7 +870,7 @@ class RestChatService { }; } catch (error: any) { log.error(`Error executing tool ${toolCall.function.name}: ${error.message}`); - + // Return error as tool result return { role: 'tool', @@ -819,7 +880,7 @@ class RestChatService { }; } })); - + log.info(`Completed execution of ${toolResults.length} tools`); return toolResults; } @@ -1042,6 +1103,33 @@ class RestChatService { throw new Error(`Failed to delete session: ${error.message || 'Unknown error'}`); } } + + /** + * Ensure that LLM tools are properly initialized + * This helps prevent issues with tool execution + */ + private async ensureToolsInitialized(): Promise { + try { + log.info("Initializing LLM agent tools..."); + + // Initialize LLM tools without depending on aiServiceManager + const toolInitializer = await import('./tools/tool_initializer.js'); + await toolInitializer.default.initializeTools(); + + // Get the tool registry to check if tools were initialized + const toolRegistry = (await import('./tools/tool_registry.js')).default; + const tools = toolRegistry.getAllTools(); + log.info(`LLM tools initialized successfully: ${tools.length} tools available`); + + // Log available tools + if (tools.length > 0) { + log.info(`Available tools: ${tools.map(t => t.definition.function.name).join(', ')}`); + } + } catch (error: any) { + log.error(`Error initializing LLM tools: ${error.message}`); + // Don't throw, just log the error to prevent breaking the pipeline + } + } } // Create singleton instance diff --git a/src/services/llm/tools/attribute_manager_tool.ts b/src/services/llm/tools/attribute_manager_tool.ts new file mode 100644 index 000000000..2d322b1d9 --- /dev/null +++ b/src/services/llm/tools/attribute_manager_tool.ts @@ -0,0 +1,226 @@ +/** + * Attribute Manager Tool + * + * This tool allows the LLM to add, remove, or modify note attributes in Trilium. + */ + +import type { Tool, ToolHandler } from './tool_interfaces.js'; +import log from '../../log.js'; +import becca from '../../../becca/becca.js'; +import attributes from '../../attributes.js'; + +/** + * Definition of the attribute manager tool + */ +export const attributeManagerToolDefinition: Tool = { + type: 'function', + function: { + name: 'manage_attributes', + description: 'Add, remove, or modify attributes (labels/relations) on a note', + parameters: { + type: 'object', + properties: { + noteId: { + type: 'string', + description: 'ID of the note to manage attributes for' + }, + action: { + type: 'string', + description: 'Action to perform on the attribute', + enum: ['add', 'remove', 'update', 'list'] + }, + attributeName: { + type: 'string', + description: 'Name of the attribute (e.g., "#tag" for a label, or "relation" for a relation)' + }, + attributeValue: { + type: 'string', + description: 'Value of the attribute (for add/update actions). Not needed for label-type attributes.' + } + }, + required: ['noteId', 'action'] + } + } +}; + +/** + * Attribute manager tool implementation + */ +export class AttributeManagerTool implements ToolHandler { + public definition: Tool = attributeManagerToolDefinition; + + /** + * Execute the attribute manager tool + */ + public async execute(args: { noteId: string, action: string, attributeName?: string, attributeValue?: string }): Promise { + try { + const { noteId, action, attributeName, attributeValue } = args; + + log.info(`Executing manage_attributes tool - NoteID: "${noteId}", Action: ${action}, AttributeName: ${attributeName || 'not specified'}`); + + // Get the note from becca + const note = becca.notes[noteId]; + + if (!note) { + log.info(`Note with ID ${noteId} not found - returning error`); + return `Error: Note with ID ${noteId} not found`; + } + + log.info(`Found note: "${note.title}" (Type: ${note.type})`); + + // List all existing attributes + if (action === 'list') { + const noteAttributes = note.getOwnedAttributes(); + log.info(`Listing ${noteAttributes.length} attributes for note "${note.title}"`); + + const formattedAttributes = noteAttributes.map(attr => ({ + name: attr.name, + value: attr.value, + type: attr.type + })); + + return { + success: true, + noteId: note.noteId, + title: note.title, + attributeCount: noteAttributes.length, + attributes: formattedAttributes + }; + } + + // For other actions, attribute name is required + if (!attributeName) { + return 'Error: attributeName is required for add, remove, and update actions'; + } + + // Perform the requested action + if (action === 'add') { + // Add a new attribute + try { + const startTime = Date.now(); + + // For label-type attributes (starting with #), no value is needed + const isLabel = attributeName.startsWith('#'); + const value = isLabel ? '' : (attributeValue || ''); + + // Check if attribute already exists + const existingAttrs = note.getOwnedAttributes() + .filter(attr => attr.name === attributeName && attr.value === value); + + if (existingAttrs.length > 0) { + log.info(`Attribute ${attributeName}=${value} already exists on note "${note.title}"`); + return { + success: false, + message: `Attribute ${attributeName}=${value || ''} already exists on note "${note.title}"` + }; + } + + // Create the attribute + await attributes.createAttribute(noteId, attributeName, value); + const duration = Date.now() - startTime; + + log.info(`Added attribute ${attributeName}=${value || ''} in ${duration}ms`); + return { + success: true, + noteId: note.noteId, + title: note.title, + action: 'add', + attributeName: attributeName, + attributeValue: value, + message: `Added attribute ${attributeName}=${value || ''} to note "${note.title}"` + }; + } catch (error: any) { + log.error(`Error adding attribute: ${error.message || String(error)}`); + return `Error: ${error.message || String(error)}`; + } + } else if (action === 'remove') { + // Remove an attribute + try { + const startTime = Date.now(); + + // Find the attribute to remove + const attributesToRemove = note.getOwnedAttributes() + .filter(attr => attr.name === attributeName && + (attributeValue === undefined || attr.value === attributeValue)); + + if (attributesToRemove.length === 0) { + log.info(`Attribute ${attributeName} not found on note "${note.title}"`); + return { + success: false, + message: `Attribute ${attributeName} not found on note "${note.title}"` + }; + } + + // Remove all matching attributes + for (const attr of attributesToRemove) { + await attributes.deleteAttribute(attr.attributeId); + } + + const duration = Date.now() - startTime; + log.info(`Removed ${attributesToRemove.length} attribute(s) in ${duration}ms`); + + return { + success: true, + noteId: note.noteId, + title: note.title, + action: 'remove', + attributeName: attributeName, + attributesRemoved: attributesToRemove.length, + message: `Removed ${attributesToRemove.length} attribute(s) from note "${note.title}"` + }; + } catch (error: any) { + log.error(`Error removing attribute: ${error.message || String(error)}`); + return `Error: ${error.message || String(error)}`; + } + } else if (action === 'update') { + // Update an attribute + try { + const startTime = Date.now(); + + if (attributeValue === undefined) { + return 'Error: attributeValue is required for update action'; + } + + // Find the attribute to update + const attributesToUpdate = note.getOwnedAttributes() + .filter(attr => attr.name === attributeName); + + if (attributesToUpdate.length === 0) { + log.info(`Attribute ${attributeName} not found on note "${note.title}"`); + return { + success: false, + message: `Attribute ${attributeName} not found on note "${note.title}"` + }; + } + + // Update all matching attributes + for (const attr of attributesToUpdate) { + await attributes.updateAttributeValue(attr.attributeId, attributeValue); + } + + const duration = Date.now() - startTime; + log.info(`Updated ${attributesToUpdate.length} attribute(s) in ${duration}ms`); + + return { + success: true, + noteId: note.noteId, + title: note.title, + action: 'update', + attributeName: attributeName, + attributeValue: attributeValue, + attributesUpdated: attributesToUpdate.length, + message: `Updated ${attributesToUpdate.length} attribute(s) on note "${note.title}"` + }; + } catch (error: any) { + log.error(`Error updating attribute: ${error.message || String(error)}`); + return `Error: ${error.message || String(error)}`; + } + } else { + return `Error: Unsupported action "${action}". Supported actions are: add, remove, update, list`; + } + } catch (error: any) { + log.error(`Error executing manage_attributes tool: ${error.message || String(error)}`); + return `Error: ${error.message || String(error)}`; + } + } +} diff --git a/src/services/llm/tools/calendar_integration_tool.ts b/src/services/llm/tools/calendar_integration_tool.ts new file mode 100644 index 000000000..a9db37708 --- /dev/null +++ b/src/services/llm/tools/calendar_integration_tool.ts @@ -0,0 +1,481 @@ +/** + * Calendar Integration Tool + * + * This tool allows the LLM to find date-related notes or create date-based entries. + */ + +import type { Tool, ToolHandler } from './tool_interfaces.js'; +import log from '../../log.js'; +import becca from '../../../becca/becca.js'; +import notes from '../../notes.js'; +import attributes from '../../attributes.js'; +import dateNotes from '../../date_notes.js'; + +/** + * Definition of the calendar integration tool + */ +export const calendarIntegrationToolDefinition: Tool = { + type: 'function', + function: { + name: 'calendar_integration', + description: 'Find date-related notes or create date-based entries', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Action to perform', + enum: ['find_date_notes', 'create_date_note', 'find_notes_with_date_range', 'get_daily_note'] + }, + date: { + type: 'string', + description: 'Date in ISO format (YYYY-MM-DD) for the note' + }, + dateStart: { + type: 'string', + description: 'Start date in ISO format (YYYY-MM-DD) for date range queries' + }, + dateEnd: { + type: 'string', + description: 'End date in ISO format (YYYY-MM-DD) for date range queries' + }, + title: { + type: 'string', + description: 'Title for creating a new date-related note' + }, + content: { + type: 'string', + description: 'Content for creating a new date-related note' + }, + parentNoteId: { + type: 'string', + description: 'Optional parent note ID for the new date note. If not specified, will use default calendar container.' + } + }, + required: ['action'] + } + } +}; + +/** + * Calendar integration tool implementation + */ +export class CalendarIntegrationTool implements ToolHandler { + public definition: Tool = calendarIntegrationToolDefinition; + + /** + * Execute the calendar integration tool + */ + public async execute(args: { + action: string, + date?: string, + dateStart?: string, + dateEnd?: string, + title?: string, + content?: string, + parentNoteId?: string + }): Promise { + try { + const { action, date, dateStart, dateEnd, title, content, parentNoteId } = args; + + log.info(`Executing calendar_integration tool - Action: ${action}, Date: ${date || 'not specified'}`); + + // Handle different actions + if (action === 'find_date_notes') { + return await this.findDateNotes(date); + } else if (action === 'create_date_note') { + return await this.createDateNote(date, title, content, parentNoteId); + } else if (action === 'find_notes_with_date_range') { + return await this.findNotesWithDateRange(dateStart, dateEnd); + } else if (action === 'get_daily_note') { + return await this.getDailyNote(date); + } else { + return `Error: Unsupported action "${action}". Supported actions are: find_date_notes, create_date_note, find_notes_with_date_range, get_daily_note`; + } + } catch (error: any) { + log.error(`Error executing calendar_integration tool: ${error.message || String(error)}`); + return `Error: ${error.message || String(error)}`; + } + } + + /** + * Find notes related to a specific date + */ + private async findDateNotes(date?: string): Promise { + if (!date) { + // If no date is provided, use today's date + const today = new Date(); + date = today.toISOString().split('T')[0]; + log.info(`No date specified, using today's date: ${date}`); + } + + try { + // Validate date format + if (!this.isValidDate(date)) { + return { + success: false, + message: `Invalid date format. Please use YYYY-MM-DD format.` + }; + } + + log.info(`Finding notes related to date: ${date}`); + + // Get notes with dateNote attribute matching this date + const notesWithDateAttribute = this.getNotesWithDateAttribute(date); + log.info(`Found ${notesWithDateAttribute.length} notes with date attribute for ${date}`); + + // Get year, month, day notes if they exist + const yearMonthDayNotes = await this.getYearMonthDayNotes(date); + + // Format results + return { + success: true, + date: date, + yearNote: yearMonthDayNotes.yearNote ? { + noteId: yearMonthDayNotes.yearNote.noteId, + title: yearMonthDayNotes.yearNote.title + } : null, + monthNote: yearMonthDayNotes.monthNote ? { + noteId: yearMonthDayNotes.monthNote.noteId, + title: yearMonthDayNotes.monthNote.title + } : null, + dayNote: yearMonthDayNotes.dayNote ? { + noteId: yearMonthDayNotes.dayNote.noteId, + title: yearMonthDayNotes.dayNote.title + } : null, + relatedNotes: notesWithDateAttribute.map(note => ({ + noteId: note.noteId, + title: note.title, + type: note.type + })), + message: `Found ${notesWithDateAttribute.length} notes related to date ${date}` + }; + } catch (error: any) { + log.error(`Error finding date notes: ${error.message || String(error)}`); + throw error; + } + } + + /** + * Create a new note associated with a date + */ + private async createDateNote(date?: string, title?: string, content?: string, parentNoteId?: string): Promise { + if (!date) { + // If no date is provided, use today's date + const today = new Date(); + date = today.toISOString().split('T')[0]; + log.info(`No date specified, using today's date: ${date}`); + } + + // Validate date format + if (!this.isValidDate(date)) { + return { + success: false, + message: `Invalid date format. Please use YYYY-MM-DD format.` + }; + } + + if (!title) { + title = `Note for ${date}`; + } + + if (!content) { + content = `

Date note created for ${date}

`; + } + + try { + log.info(`Creating new date note for ${date} with title "${title}"`); + + // If no parent is specified, try to find appropriate date container + if (!parentNoteId) { + // Get or create day note to use as parent + const dateComponents = this.parseDateString(date); + if (!dateComponents) { + return { + success: false, + message: `Invalid date format. Please use YYYY-MM-DD format.` + }; + } + + // Use the date string directly with getDayNote + const dayNote = await dateNotes.getDayNote(date); + + if (dayNote) { + parentNoteId = dayNote.noteId; + log.info(`Using day note ${dayNote.title} (${parentNoteId}) as parent`); + } else { + // Use root if day note couldn't be found/created + parentNoteId = 'root'; + log.info(`Could not find/create day note, using root as parent`); + } + } + + // Validate parent note exists + const parent = becca.notes[parentNoteId]; + if (!parent) { + return { + success: false, + message: `Parent note with ID ${parentNoteId} not found. Please specify a valid parent note ID.` + }; + } + + // Create the new note + const createStartTime = Date.now(); + const noteId = await notes.createNewNote({ + parentNoteId: parent.noteId, + title: title, + content: content, + type: 'text', + mime: 'text/html' + }); + const createDuration = Date.now() - createStartTime; + + if (!noteId) { + return { + success: false, + message: `Failed to create date note. An unknown error occurred.` + }; + } + + log.info(`Created new note with ID ${noteId} in ${createDuration}ms`); + + // Add dateNote attribute with the specified date + const attrStartTime = Date.now(); + await attributes.createLabel(noteId, 'dateNote', date); + const attrDuration = Date.now() - attrStartTime; + + log.info(`Added dateNote=${date} attribute in ${attrDuration}ms`); + + // Return the new note information + return { + success: true, + noteId: noteId, + date: date, + title: title, + message: `Created new date note "${title}" for ${date}` + }; + } catch (error: any) { + log.error(`Error creating date note: ${error.message || String(error)}`); + throw error; + } + } + + /** + * Find notes with date attributes in a specified range + */ + private async findNotesWithDateRange(dateStart?: string, dateEnd?: string): Promise { + if (!dateStart || !dateEnd) { + return { + success: false, + message: `Both dateStart and dateEnd are required for find_notes_with_date_range action.` + }; + } + + // Validate date formats + if (!this.isValidDate(dateStart) || !this.isValidDate(dateEnd)) { + return { + success: false, + message: `Invalid date format. Please use YYYY-MM-DD format.` + }; + } + + try { + log.info(`Finding notes with date attributes in range ${dateStart} to ${dateEnd}`); + + // Get all notes with dateNote attribute + const allNotes = this.getAllNotesWithDateAttribute(); + + // Filter by date range + const startDate = new Date(dateStart); + const endDate = new Date(dateEnd); + + const filteredNotes = allNotes.filter(note => { + const dateAttr = note.getOwnedAttributes() + .find((attr: any) => attr.name === 'dateNote'); + + if (dateAttr && dateAttr.value) { + const noteDate = new Date(dateAttr.value); + return noteDate >= startDate && noteDate <= endDate; + } + + return false; + }); + + log.info(`Found ${filteredNotes.length} notes in date range`); + + // Sort notes by date + filteredNotes.sort((a, b) => { + const aDateAttr = a.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote'); + const bDateAttr = b.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote'); + + if (aDateAttr && bDateAttr) { + const aDate = new Date(aDateAttr.value); + const bDate = new Date(bDateAttr.value); + return aDate.getTime() - bDate.getTime(); + } + + return 0; + }); + + // Format results + return { + success: true, + dateStart: dateStart, + dateEnd: dateEnd, + noteCount: filteredNotes.length, + notes: filteredNotes.map(note => { + const dateAttr = note.getOwnedAttributes().find((attr: any) => attr.name === 'dateNote'); + return { + noteId: note.noteId, + title: note.title, + type: note.type, + date: dateAttr ? dateAttr.value : null + }; + }), + message: `Found ${filteredNotes.length} notes in date range ${dateStart} to ${dateEnd}` + }; + } catch (error: any) { + log.error(`Error finding notes in date range: ${error.message || String(error)}`); + throw error; + } + } + + /** + * Get or create a daily note for a specific date + */ + private async getDailyNote(date?: string): Promise { + if (!date) { + // If no date is provided, use today's date + const today = new Date(); + date = today.toISOString().split('T')[0]; + log.info(`No date specified, using today's date: ${date}`); + } + + // Validate date format + if (!this.isValidDate(date)) { + return { + success: false, + message: `Invalid date format. Please use YYYY-MM-DD format.` + }; + } + + try { + log.info(`Getting daily note for ${date}`); + + // Get or create day note - directly pass the date string + const startTime = Date.now(); + const dayNote = await dateNotes.getDayNote(date); + const duration = Date.now() - startTime; + + if (!dayNote) { + return { + success: false, + message: `Could not find or create daily note for ${date}` + }; + } + + log.info(`Retrieved/created daily note for ${date} in ${duration}ms`); + + // Get parent month and year notes + const yearStr = date.substring(0, 4); + const monthStr = date.substring(0, 7); + + const monthNote = await dateNotes.getMonthNote(monthStr); + const yearNote = await dateNotes.getYearNote(yearStr); + + // Return the note information + return { + success: true, + date: date, + dayNote: { + noteId: dayNote.noteId, + title: dayNote.title, + content: await dayNote.getContent() + }, + monthNote: monthNote ? { + noteId: monthNote.noteId, + title: monthNote.title + } : null, + yearNote: yearNote ? { + noteId: yearNote.noteId, + title: yearNote.title + } : null, + message: `Retrieved daily note for ${date}` + }; + } catch (error: any) { + log.error(`Error getting daily note: ${error.message || String(error)}`); + throw error; + } + } + + /** + * Helper method to get notes with a specific date attribute + */ + private getNotesWithDateAttribute(date: string): any[] { + // Find notes with matching dateNote attribute + return attributes.getNotesWithLabel('dateNote', date) || []; + } + + /** + * Helper method to get all notes with any date attribute + */ + private getAllNotesWithDateAttribute(): any[] { + // Find all notes with dateNote attribute + return attributes.getNotesWithLabel('dateNote') || []; + } + + /** + * Helper method to get year, month, and day notes for a date + */ + private async getYearMonthDayNotes(date: string): Promise<{ + yearNote: any | null; + monthNote: any | null; + dayNote: any | null; + }> { + if (!this.isValidDate(date)) { + return { yearNote: null, monthNote: null, dayNote: null }; + } + + // Extract the year and month from the date string + const yearStr = date.substring(0, 4); + const monthStr = date.substring(0, 7); + + // Use the dateNotes service to get the notes + const yearNote = await dateNotes.getYearNote(yearStr); + const monthNote = await dateNotes.getMonthNote(monthStr); + const dayNote = await dateNotes.getDayNote(date); + + return { yearNote, monthNote, dayNote }; + } + + /** + * Helper method to validate date string format + */ + private isValidDate(dateString: string): boolean { + const regex = /^\d{4}-\d{2}-\d{2}$/; + + if (!regex.test(dateString)) { + return false; + } + + const date = new Date(dateString); + return date.toString() !== 'Invalid Date'; + } + + /** + * Helper method to parse date string into components + */ + private parseDateString(dateString: string): { year: number; month: number; day: number } | null { + if (!this.isValidDate(dateString)) { + return null; + } + + const [yearStr, monthStr, dayStr] = dateString.split('-'); + + return { + year: parseInt(yearStr, 10), + month: parseInt(monthStr, 10), + day: parseInt(dayStr, 10) + }; + } +} diff --git a/src/services/llm/tools/content_extraction_tool.ts b/src/services/llm/tools/content_extraction_tool.ts new file mode 100644 index 000000000..5412cfd10 --- /dev/null +++ b/src/services/llm/tools/content_extraction_tool.ts @@ -0,0 +1,544 @@ +/** + * Content Extraction Tool + * + * This tool allows the LLM to extract structured information from notes. + */ + +import type { Tool, ToolHandler } from './tool_interfaces.js'; +import log from '../../log.js'; +import becca from '../../../becca/becca.js'; + +/** + * Definition of the content extraction tool + */ +export const contentExtractionToolDefinition: Tool = { + type: 'function', + function: { + name: 'extract_content', + description: 'Extract structured information from a note\'s content, such as lists, tables, or specific sections', + parameters: { + type: 'object', + properties: { + noteId: { + type: 'string', + description: 'ID of the note to extract content from' + }, + extractionType: { + type: 'string', + description: 'Type of content to extract', + enum: ['lists', 'tables', 'headings', 'codeBlocks', 'all'] + }, + format: { + type: 'string', + description: 'Format to return the extracted content in', + enum: ['json', 'markdown', 'text'] + }, + query: { + type: 'string', + description: 'Optional search query to filter extracted content (e.g., "tasks related to finance")' + } + }, + required: ['noteId', 'extractionType'] + } + } +}; + +/** + * Content extraction tool implementation + */ +export class ContentExtractionTool implements ToolHandler { + public definition: Tool = contentExtractionToolDefinition; + + /** + * Execute the content extraction tool + */ + public async execute(args: { + noteId: string, + extractionType: 'lists' | 'tables' | 'headings' | 'codeBlocks' | 'all', + format?: 'json' | 'markdown' | 'text', + query?: string + }): Promise { + try { + const { noteId, extractionType, format = 'json', query } = args; + + log.info(`Executing extract_content tool - NoteID: "${noteId}", Type: ${extractionType}, Format: ${format}`); + + // Get the note from becca + const note = becca.notes[noteId]; + + if (!note) { + log.info(`Note with ID ${noteId} not found - returning error`); + return `Error: Note with ID ${noteId} not found`; + } + + log.info(`Found note: "${note.title}" (Type: ${note.type})`); + + // Get the note content + const content = await note.getContent(); + if (!content) { + return { + success: false, + message: 'Note content is empty' + }; + } + + log.info(`Retrieved note content, length: ${content.length} chars`); + + // Extract the requested content + const extractedContent: any = {}; + + if (extractionType === 'lists' || extractionType === 'all') { + extractedContent.lists = this.extractLists(content); + log.info(`Extracted ${extractedContent.lists.length} lists`); + } + + if (extractionType === 'tables' || extractionType === 'all') { + extractedContent.tables = this.extractTables(content); + log.info(`Extracted ${extractedContent.tables.length} tables`); + } + + if (extractionType === 'headings' || extractionType === 'all') { + extractedContent.headings = this.extractHeadings(content); + log.info(`Extracted ${extractedContent.headings.length} headings`); + } + + if (extractionType === 'codeBlocks' || extractionType === 'all') { + extractedContent.codeBlocks = this.extractCodeBlocks(content); + log.info(`Extracted ${extractedContent.codeBlocks.length} code blocks`); + } + + // Filter by query if provided + if (query) { + log.info(`Filtering extracted content with query: "${query}"`); + this.filterContentByQuery(extractedContent, query); + } + + // Format the response based on requested format + if (format === 'markdown') { + return this.formatAsMarkdown(extractedContent, extractionType); + } else if (format === 'text') { + return this.formatAsText(extractedContent, extractionType); + } else { + // Default to JSON format + return { + success: true, + noteId: note.noteId, + title: note.title, + extractionType, + content: extractedContent + }; + } + } catch (error: any) { + log.error(`Error executing extract_content tool: ${error.message || String(error)}`); + return `Error: ${error.message || String(error)}`; + } + } + + /** + * Extract lists from HTML content + */ + private extractLists(content: string): Array<{ type: string, items: string[] }> { + const lists = []; + + // Extract unordered lists + const ulRegex = /]*>([\s\S]*?)<\/ul>/gi; + let ulMatch; + + while ((ulMatch = ulRegex.exec(content)) !== null) { + const listContent = ulMatch[1]; + const items = this.extractListItems(listContent); + + if (items.length > 0) { + lists.push({ + type: 'unordered', + items + }); + } + } + + // Extract ordered lists + const olRegex = /]*>([\s\S]*?)<\/ol>/gi; + let olMatch; + + while ((olMatch = olRegex.exec(content)) !== null) { + const listContent = olMatch[1]; + const items = this.extractListItems(listContent); + + if (items.length > 0) { + lists.push({ + type: 'ordered', + items + }); + } + } + + return lists; + } + + /** + * Extract list items from list content + */ + private extractListItems(listContent: string): string[] { + const items = []; + const itemRegex = /]*>([\s\S]*?)<\/li>/gi; + let itemMatch; + + while ((itemMatch = itemRegex.exec(listContent)) !== null) { + const itemText = this.stripHtml(itemMatch[1]).trim(); + if (itemText) { + items.push(itemText); + } + } + + return items; + } + + /** + * Extract tables from HTML content + */ + private extractTables(content: string): Array<{ headers: string[], rows: string[][] }> { + const tables = []; + const tableRegex = /]*>([\s\S]*?)<\/table>/gi; + let tableMatch; + + while ((tableMatch = tableRegex.exec(content)) !== null) { + const tableContent = tableMatch[1]; + const headers = []; + const rows = []; + + // Extract table headers + const headerRegex = /]*>([\s\S]*?)<\/th>/gi; + let headerMatch; + while ((headerMatch = headerRegex.exec(tableContent)) !== null) { + headers.push(this.stripHtml(headerMatch[1]).trim()); + } + + // Extract table rows + const rowRegex = /]*>([\s\S]*?)<\/tr>/gi; + let rowMatch; + while ((rowMatch = rowRegex.exec(tableContent)) !== null) { + const rowContent = rowMatch[1]; + const cells = []; + + const cellRegex = /]*>([\s\S]*?)<\/td>/gi; + let cellMatch; + while ((cellMatch = cellRegex.exec(rowContent)) !== null) { + cells.push(this.stripHtml(cellMatch[1]).trim()); + } + + if (cells.length > 0) { + rows.push(cells); + } + } + + if (headers.length > 0 || rows.length > 0) { + tables.push({ + headers, + rows + }); + } + } + + return tables; + } + + /** + * Extract headings from HTML content + */ + private extractHeadings(content: string): Array<{ level: number, text: string }> { + const headings = []; + + for (let i = 1; i <= 6; i++) { + const headingRegex = new RegExp(`]*>([\s\S]*?)<\/h${i}>`, 'gi'); + let headingMatch; + + while ((headingMatch = headingRegex.exec(content)) !== null) { + const headingText = this.stripHtml(headingMatch[1]).trim(); + if (headingText) { + headings.push({ + level: i, + text: headingText + }); + } + } + } + + return headings; + } + + /** + * Extract code blocks from HTML content + */ + private extractCodeBlocks(content: string): Array<{ language?: string, code: string }> { + const codeBlocks = []; + + // Look for
 and  blocks
+        const preRegex = /]*>([\s\S]*?)<\/pre>/gi;
+        let preMatch;
+
+        while ((preMatch = preRegex.exec(content)) !== null) {
+            const preContent = preMatch[1];
+            // Check if there's a nested  tag
+            const codeMatch = /]*>([\s\S]*?)<\/code>/i.exec(preContent);
+
+            if (codeMatch) {
+                // Extract language if it's in the class attribute
+                const classMatch = /class="[^"]*language-([^"\s]+)[^"]*"/i.exec(preMatch[0]);
+                codeBlocks.push({
+                    language: classMatch ? classMatch[1] : undefined,
+                    code: this.decodeHtmlEntities(codeMatch[1]).trim()
+                });
+            } else {
+                // Just a 
 without 
+                codeBlocks.push({
+                    code: this.decodeHtmlEntities(preContent).trim()
+                });
+            }
+        }
+
+        // Also look for standalone  blocks not inside 
+        const standaloneCodeRegex = /(?]*>[\s\S]*?)]*>([\s\S]*?)<\/code>/gi;
+        let standaloneCodeMatch;
+
+        while ((standaloneCodeMatch = standaloneCodeRegex.exec(content)) !== null) {
+            codeBlocks.push({
+                code: this.decodeHtmlEntities(standaloneCodeMatch[1]).trim()
+            });
+        }
+
+        return codeBlocks;
+    }
+
+    /**
+     * Filter content by query
+     */
+    private filterContentByQuery(content: any, query: string): void {
+        const lowerQuery = query.toLowerCase();
+
+        // Filter lists
+        if (content.lists) {
+            content.lists = content.lists.filter(list => {
+                // Keep the list if any item matches the query
+                return list.items.some(item => item.toLowerCase().includes(lowerQuery));
+            });
+
+            // Also filter individual items in each list
+            content.lists.forEach(list => {
+                list.items = list.items.filter(item => item.toLowerCase().includes(lowerQuery));
+            });
+        }
+
+        // Filter headings
+        if (content.headings) {
+            content.headings = content.headings.filter(heading =>
+                heading.text.toLowerCase().includes(lowerQuery)
+            );
+        }
+
+        // Filter tables
+        if (content.tables) {
+            content.tables = content.tables.filter(table => {
+                // Check headers
+                const headerMatch = table.headers.some(header =>
+                    header.toLowerCase().includes(lowerQuery)
+                );
+
+                // Check cells
+                const cellMatch = table.rows.some(row =>
+                    row.some(cell => cell.toLowerCase().includes(lowerQuery))
+                );
+
+                return headerMatch || cellMatch;
+            });
+        }
+
+        // Filter code blocks
+        if (content.codeBlocks) {
+            content.codeBlocks = content.codeBlocks.filter(block =>
+                block.code.toLowerCase().includes(lowerQuery)
+            );
+        }
+    }
+
+    /**
+     * Format extracted content as Markdown
+     */
+    private formatAsMarkdown(content: any, extractionType: string): string {
+        let markdown = '';
+
+        if (extractionType === 'lists' || extractionType === 'all') {
+            if (content.lists && content.lists.length > 0) {
+                markdown += '## Lists\n\n';
+
+                content.lists.forEach((list: any, index: number) => {
+                    markdown += `### List ${index + 1} (${list.type})\n\n`;
+
+                    list.items.forEach((item: string) => {
+                        if (list.type === 'unordered') {
+                            markdown += `- ${item}\n`;
+                        } else {
+                            markdown += `1. ${item}\n`;
+                        }
+                    });
+
+                    markdown += '\n';
+                });
+            }
+        }
+
+        if (extractionType === 'headings' || extractionType === 'all') {
+            if (content.headings && content.headings.length > 0) {
+                markdown += '## Headings\n\n';
+
+                content.headings.forEach((heading: any) => {
+                    markdown += `${'#'.repeat(heading.level)} ${heading.text}\n\n`;
+                });
+            }
+        }
+
+        if (extractionType === 'tables' || extractionType === 'all') {
+            if (content.tables && content.tables.length > 0) {
+                markdown += '## Tables\n\n';
+
+                content.tables.forEach((table: any, index: number) => {
+                    markdown += `### Table ${index + 1}\n\n`;
+
+                    // Add headers
+                    if (table.headers.length > 0) {
+                        markdown += '| ' + table.headers.join(' | ') + ' |\n';
+                        markdown += '| ' + table.headers.map(() => '---').join(' | ') + ' |\n';
+                    }
+
+                    // Add rows
+                    table.rows.forEach((row: string[]) => {
+                        markdown += '| ' + row.join(' | ') + ' |\n';
+                    });
+
+                    markdown += '\n';
+                });
+            }
+        }
+
+        if (extractionType === 'codeBlocks' || extractionType === 'all') {
+            if (content.codeBlocks && content.codeBlocks.length > 0) {
+                markdown += '## Code Blocks\n\n';
+
+                content.codeBlocks.forEach((block: any, index: number) => {
+                    markdown += `### Code Block ${index + 1}\n\n`;
+
+                    if (block.language) {
+                        markdown += '```' + block.language + '\n';
+                    } else {
+                        markdown += '```\n';
+                    }
+
+                    markdown += block.code + '\n';
+                    markdown += '```\n\n';
+                });
+            }
+        }
+
+        return markdown.trim();
+    }
+
+    /**
+     * Format extracted content as plain text
+     */
+    private formatAsText(content: any, extractionType: string): string {
+        let text = '';
+
+        if (extractionType === 'lists' || extractionType === 'all') {
+            if (content.lists && content.lists.length > 0) {
+                text += 'LISTS:\n\n';
+
+                content.lists.forEach((list: any, index: number) => {
+                    text += `List ${index + 1} (${list.type}):\n\n`;
+
+                    list.items.forEach((item: string, itemIndex: number) => {
+                        if (list.type === 'unordered') {
+                            text += `• ${item}\n`;
+                        } else {
+                            text += `${itemIndex + 1}. ${item}\n`;
+                        }
+                    });
+
+                    text += '\n';
+                });
+            }
+        }
+
+        if (extractionType === 'headings' || extractionType === 'all') {
+            if (content.headings && content.headings.length > 0) {
+                text += 'HEADINGS:\n\n';
+
+                content.headings.forEach((heading: any) => {
+                    text += `${heading.text} (Level ${heading.level})\n`;
+                });
+
+                text += '\n';
+            }
+        }
+
+        if (extractionType === 'tables' || extractionType === 'all') {
+            if (content.tables && content.tables.length > 0) {
+                text += 'TABLES:\n\n';
+
+                content.tables.forEach((table: any, index: number) => {
+                    text += `Table ${index + 1}:\n\n`;
+
+                    // Add headers
+                    if (table.headers.length > 0) {
+                        text += table.headers.join(' | ') + '\n';
+                        text += table.headers.map(() => '-----').join(' | ') + '\n';
+                    }
+
+                    // Add rows
+                    table.rows.forEach((row: string[]) => {
+                        text += row.join(' | ') + '\n';
+                    });
+
+                    text += '\n';
+                });
+            }
+        }
+
+        if (extractionType === 'codeBlocks' || extractionType === 'all') {
+            if (content.codeBlocks && content.codeBlocks.length > 0) {
+                text += 'CODE BLOCKS:\n\n';
+
+                content.codeBlocks.forEach((block: any, index: number) => {
+                    text += `Code Block ${index + 1}`;
+
+                    if (block.language) {
+                        text += ` (${block.language})`;
+                    }
+
+                    text += ':\n\n';
+                    text += block.code + '\n\n';
+                });
+            }
+        }
+
+        return text.trim();
+    }
+
+    /**
+     * Strip HTML tags from content
+     */
+    private stripHtml(html: string): string {
+        return html.replace(/<[^>]*>/g, '');
+    }
+
+    /**
+     * Decode HTML entities
+     */
+    private decodeHtmlEntities(text: string): string {
+        return text
+            .replace(/</g, '<')
+            .replace(/>/g, '>')
+            .replace(/&/g, '&')
+            .replace(/"/g, '"')
+            .replace(/'/g, "'")
+            .replace(/ /g, ' ');
+    }
+}
diff --git a/src/services/llm/tools/note_creation_tool.ts b/src/services/llm/tools/note_creation_tool.ts
new file mode 100644
index 000000000..326b2796e
--- /dev/null
+++ b/src/services/llm/tools/note_creation_tool.ts
@@ -0,0 +1,170 @@
+/**
+ * Note Creation Tool
+ *
+ * This tool allows the LLM to create new notes in Trilium.
+ */
+
+import type { Tool, ToolHandler } from './tool_interfaces.js';
+import log from '../../log.js';
+import becca from '../../../becca/becca.js';
+import notes from '../../notes.js';
+
+/**
+ * Definition of the note creation tool
+ */
+export const noteCreationToolDefinition: Tool = {
+    type: 'function',
+    function: {
+        name: 'create_note',
+        description: 'Create a new note in Trilium with the specified content and attributes',
+        parameters: {
+            type: 'object',
+            properties: {
+                parentNoteId: {
+                    type: 'string',
+                    description: 'ID of the parent note under which to create the new note. If not specified, creates under root.'
+                },
+                title: {
+                    type: 'string',
+                    description: 'Title of the new note'
+                },
+                content: {
+                    type: 'string',
+                    description: 'Content of the new note'
+                },
+                type: {
+                    type: 'string',
+                    description: 'Type of the note (text, code, etc.)',
+                    enum: ['text', 'code', 'file', 'image', 'search', 'relation-map', 'book', 'mermaid', 'canvas']
+                },
+                mime: {
+                    type: 'string',
+                    description: 'MIME type of the note (e.g., text/html, application/json). Only required for certain note types.'
+                },
+                attributes: {
+                    type: 'array',
+                    description: 'Array of attributes to set on the note (e.g., [{"name":"#tag"}, {"name":"priority", "value":"high"}])',
+                    items: {
+                        type: 'object',
+                        properties: {
+                            name: {
+                                type: 'string',
+                                description: 'Name of the attribute'
+                            },
+                            value: {
+                                type: 'string',
+                                description: 'Value of the attribute (if applicable)'
+                            }
+                        }
+                    }
+                }
+            },
+            required: ['title', 'content']
+        }
+    }
+};
+
+/**
+ * Note creation tool implementation
+ */
+export class NoteCreationTool implements ToolHandler {
+    public definition: Tool = noteCreationToolDefinition;
+
+    /**
+     * Execute the note creation tool
+     */
+    public async execute(args: {
+        parentNoteId?: string,
+        title: string,
+        content: string,
+        type?: string,
+        mime?: string,
+        attributes?: Array<{ name: string, value?: string }>
+    }): Promise {
+        try {
+            const { parentNoteId, title, content, type = 'text', mime } = args;
+
+            log.info(`Executing create_note tool - Title: "${title}", Type: ${type}, ParentNoteId: ${parentNoteId || 'root'}`);
+
+            // Validate parent note exists if specified
+            let parent;
+            if (parentNoteId) {
+                parent = becca.notes[parentNoteId];
+                if (!parent) {
+                    return `Error: Parent note with ID ${parentNoteId} not found. Please specify a valid parent note ID.`;
+                }
+            } else {
+                // Use root note if no parent specified
+                parent = becca.getNote('root');
+            }
+
+            // Determine the appropriate mime type
+            let noteMime = mime;
+            if (!noteMime) {
+                // Set default mime types based on note type
+                switch (type) {
+                    case 'text':
+                        noteMime = 'text/html';
+                        break;
+                    case 'code':
+                        noteMime = 'text/plain';
+                        break;
+                    case 'file':
+                        noteMime = 'application/octet-stream';
+                        break;
+                    case 'image':
+                        noteMime = 'image/png';
+                        break;
+                    default:
+                        noteMime = 'text/html';
+                }
+            }
+
+            // Create the note
+            const createStartTime = Date.now();
+            const noteId = await notes.createNewNote({
+                parentNoteId: parent.noteId,
+                title: title,
+                content: content,
+                type: type,
+                mime: noteMime
+            });
+            const createDuration = Date.now() - createStartTime;
+
+            if (!noteId) {
+                return 'Error: Failed to create note. An unknown error occurred.';
+            }
+
+            log.info(`Note created successfully in ${createDuration}ms, ID: ${noteId}`);
+
+            // Add attributes if specified
+            if (args.attributes && args.attributes.length > 0) {
+                log.info(`Adding ${args.attributes.length} attributes to the note`);
+
+                for (const attr of args.attributes) {
+                    if (!attr.name) continue;
+
+                    const attrStartTime = Date.now();
+                    await notes.createAttribute(noteId, attr.name, attr.value || '');
+                    const attrDuration = Date.now() - attrStartTime;
+
+                    log.info(`Added attribute ${attr.name}=${attr.value || ''} in ${attrDuration}ms`);
+                }
+            }
+
+            // Return the new note's information
+            const newNote = becca.notes[noteId];
+
+            return {
+                success: true,
+                noteId: noteId,
+                title: newNote.title,
+                type: newNote.type,
+                message: `Note "${title}" created successfully`
+            };
+        } catch (error: any) {
+            log.error(`Error executing create_note tool: ${error.message || String(error)}`);
+            return `Error: ${error.message || String(error)}`;
+        }
+    }
+}
diff --git a/src/services/llm/tools/note_summarization_tool.ts b/src/services/llm/tools/note_summarization_tool.ts
new file mode 100644
index 000000000..d29555879
--- /dev/null
+++ b/src/services/llm/tools/note_summarization_tool.ts
@@ -0,0 +1,185 @@
+/**
+ * Note Summarization Tool
+ *
+ * This tool allows the LLM to generate concise summaries of longer notes.
+ */
+
+import type { Tool, ToolHandler } from './tool_interfaces.js';
+import log from '../../log.js';
+import becca from '../../../becca/becca.js';
+import aiServiceManager from '../ai_service_manager.js';
+
+/**
+ * Definition of the note summarization tool
+ */
+export const noteSummarizationToolDefinition: Tool = {
+    type: 'function',
+    function: {
+        name: 'summarize_note',
+        description: 'Generate a concise summary of a note\'s content',
+        parameters: {
+            type: 'object',
+            properties: {
+                noteId: {
+                    type: 'string',
+                    description: 'ID of the note to summarize'
+                },
+                maxLength: {
+                    type: 'number',
+                    description: 'Maximum length of the summary in characters (default: 500)'
+                },
+                format: {
+                    type: 'string',
+                    description: 'Format of the summary',
+                    enum: ['paragraph', 'bullets', 'executive']
+                },
+                focus: {
+                    type: 'string',
+                    description: 'Optional focus for the summary (e.g., "technical details", "key findings")'
+                }
+            },
+            required: ['noteId']
+        }
+    }
+};
+
+/**
+ * Note summarization tool implementation
+ */
+export class NoteSummarizationTool implements ToolHandler {
+    public definition: Tool = noteSummarizationToolDefinition;
+
+    /**
+     * Execute the note summarization tool
+     */
+    public async execute(args: {
+        noteId: string,
+        maxLength?: number,
+        format?: 'paragraph' | 'bullets' | 'executive',
+        focus?: string
+    }): Promise {
+        try {
+            const { noteId, maxLength = 500, format = 'paragraph', focus } = args;
+
+            log.info(`Executing summarize_note tool - NoteID: "${noteId}", MaxLength: ${maxLength}, Format: ${format}`);
+
+            // Get the note from becca
+            const note = becca.notes[noteId];
+
+            if (!note) {
+                log.info(`Note with ID ${noteId} not found - returning error`);
+                return `Error: Note with ID ${noteId} not found`;
+            }
+
+            log.info(`Found note: "${note.title}" (Type: ${note.type})`);
+
+            // Get the note content
+            const content = await note.getContent();
+
+            if (!content || typeof content !== 'string' || content.trim().length === 0) {
+                return {
+                    success: false,
+                    message: 'Note content is empty or invalid'
+                };
+            }
+
+            log.info(`Retrieved note content, length: ${content.length} chars`);
+
+            // Check if content needs summarization (if it's short enough, just return it)
+            if (content.length <= maxLength && !focus) {
+                log.info(`Note content is already shorter than maxLength, returning as is`);
+                return {
+                    success: true,
+                    noteId: note.noteId,
+                    title: note.title,
+                    summary: this.cleanHtml(content),
+                    wasAlreadyShort: true
+                };
+            }
+
+            // Remove HTML tags for summarization
+            const cleanContent = this.cleanHtml(content);
+
+            // Generate the summary using the AI service
+            const aiService = aiServiceManager.getService();
+
+            if (!aiService) {
+                log.error('No AI service available for summarization');
+                return `Error: No AI service is available for summarization`;
+            }
+
+            log.info(`Using ${aiService.getName()} to generate summary`);
+
+            // Create a prompt based on format and focus
+            let prompt = `Summarize the following text`;
+
+            if (focus) {
+                prompt += ` with a focus on ${focus}`;
+            }
+
+            if (format === 'bullets') {
+                prompt += ` in a bullet point format`;
+            } else if (format === 'executive') {
+                prompt += ` as a brief executive summary`;
+            } else {
+                prompt += ` in a concise paragraph`;
+            }
+
+            prompt += `. Keep the summary under ${maxLength} characters:\n\n${cleanContent}`;
+
+            // Generate the summary
+            const summaryStartTime = Date.now();
+
+            const completion = await aiService.generateChatCompletion([
+                { role: 'system', content: 'You are a skilled summarizer. Create concise, accurate summaries while preserving the key information.' },
+                { role: 'user', content: prompt }
+            ], {
+                temperature: 0.3, // Lower temperature for more focused summaries
+                maxTokens: 1000 // Enough tokens for the summary
+            });
+
+            const summaryDuration = Date.now() - summaryStartTime;
+
+            log.info(`Generated summary in ${summaryDuration}ms, length: ${completion.text.length} chars`);
+
+            return {
+                success: true,
+                noteId: note.noteId,
+                title: note.title,
+                originalLength: content.length,
+                summary: completion.text,
+                format: format,
+                focus: focus || 'general content'
+            };
+        } catch (error: any) {
+            log.error(`Error executing summarize_note tool: ${error.message || String(error)}`);
+            return `Error: ${error.message || String(error)}`;
+        }
+    }
+
+    /**
+     * Clean HTML content for summarization
+     */
+    private cleanHtml(html: string): string {
+        if (typeof html !== 'string') {
+            return '';
+        }
+
+        // Remove HTML tags
+        let text = html.replace(/<[^>]*>/g, '');
+
+        // Decode common HTML entities
+        text = text
+            .replace(/</g, '<')
+            .replace(/>/g, '>')
+            .replace(/&/g, '&')
+            .replace(/"/g, '"')
+            .replace(/'/g, "'")
+            .replace(/ /g, ' ');
+
+        // Normalize whitespace
+        text = text.replace(/\s+/g, ' ').trim();
+
+        return text;
+    }
+}
diff --git a/src/services/llm/tools/note_update_tool.ts b/src/services/llm/tools/note_update_tool.ts
new file mode 100644
index 000000000..6e43bed8e
--- /dev/null
+++ b/src/services/llm/tools/note_update_tool.ts
@@ -0,0 +1,136 @@
+/**
+ * Note Update Tool
+ *
+ * This tool allows the LLM to update existing notes in Trilium.
+ */
+
+import type { Tool, ToolHandler } from './tool_interfaces.js';
+import log from '../../log.js';
+import becca from '../../../becca/becca.js';
+
+/**
+ * Definition of the note update tool
+ */
+export const noteUpdateToolDefinition: Tool = {
+    type: 'function',
+    function: {
+        name: 'update_note',
+        description: 'Update the content or title of an existing note',
+        parameters: {
+            type: 'object',
+            properties: {
+                noteId: {
+                    type: 'string',
+                    description: 'ID of the note to update'
+                },
+                title: {
+                    type: 'string',
+                    description: 'New title for the note (if you want to change it)'
+                },
+                content: {
+                    type: 'string',
+                    description: 'New content for the note (if you want to change it)'
+                },
+                mode: {
+                    type: 'string',
+                    description: 'How to update content: replace (default), append, or prepend',
+                    enum: ['replace', 'append', 'prepend']
+                }
+            },
+            required: ['noteId']
+        }
+    }
+};
+
+/**
+ * Note update tool implementation
+ */
+export class NoteUpdateTool implements ToolHandler {
+    public definition: Tool = noteUpdateToolDefinition;
+
+    /**
+     * Execute the note update tool
+     */
+    public async execute(args: { noteId: string, title?: string, content?: string, mode?: 'replace' | 'append' | 'prepend' }): Promise {
+        try {
+            const { noteId, title, content, mode = 'replace' } = args;
+
+            if (!title && !content) {
+                return 'Error: At least one of title or content must be provided to update a note.';
+            }
+
+            log.info(`Executing update_note tool - NoteID: "${noteId}", Mode: ${mode}`);
+
+            // Get the note from becca
+            const note = becca.notes[noteId];
+
+            if (!note) {
+                log.info(`Note with ID ${noteId} not found - returning error`);
+                return `Error: Note with ID ${noteId} not found`;
+            }
+
+            log.info(`Found note: "${note.title}" (Type: ${note.type})`);
+
+            let titleUpdateResult;
+            let contentUpdateResult;
+
+            // Update title if provided
+            if (title && title !== note.title) {
+                const titleStartTime = Date.now();
+
+                try {
+                    await note.setTitle(title);
+                    const titleDuration = Date.now() - titleStartTime;
+                    log.info(`Updated note title to "${title}" in ${titleDuration}ms`);
+                    titleUpdateResult = `Title updated from "${note.title}" to "${title}"`;
+                } catch (error: any) {
+                    log.error(`Error updating note title: ${error.message || String(error)}`);
+                    titleUpdateResult = `Failed to update title: ${error.message || 'Unknown error'}`;
+                }
+            }
+
+            // Update content if provided
+            if (content) {
+                const contentStartTime = Date.now();
+
+                try {
+                    let newContent = content;
+
+                    // For append or prepend modes, get the current content first
+                    if (mode === 'append' || mode === 'prepend') {
+                        const currentContent = await note.getContent();
+
+                        if (mode === 'append') {
+                            newContent = currentContent + '\n\n' + content;
+                            log.info(`Appending content to existing note content`);
+                        } else if (mode === 'prepend') {
+                            newContent = content + '\n\n' + currentContent;
+                            log.info(`Prepending content to existing note content`);
+                        }
+                    }
+
+                    await note.setContent(newContent);
+                    const contentDuration = Date.now() - contentStartTime;
+                    log.info(`Updated note content in ${contentDuration}ms, new content length: ${newContent.length}`);
+                    contentUpdateResult = `Content updated successfully (${mode} mode)`;
+                } catch (error: any) {
+                    log.error(`Error updating note content: ${error.message || String(error)}`);
+                    contentUpdateResult = `Failed to update content: ${error.message || 'Unknown error'}`;
+                }
+            }
+
+            // Return the results
+            return {
+                success: true,
+                noteId: note.noteId,
+                title: note.title,
+                titleUpdate: titleUpdateResult || 'No title update requested',
+                contentUpdate: contentUpdateResult || 'No content update requested',
+                message: `Note "${note.title}" updated successfully`
+            };
+        } catch (error: any) {
+            log.error(`Error executing update_note tool: ${error.message || String(error)}`);
+            return `Error: ${error.message || String(error)}`;
+        }
+    }
+}
diff --git a/src/services/llm/tools/relationship_tool.ts b/src/services/llm/tools/relationship_tool.ts
new file mode 100644
index 000000000..a9e010e4a
--- /dev/null
+++ b/src/services/llm/tools/relationship_tool.ts
@@ -0,0 +1,382 @@
+/**
+ * Relationship Tool
+ *
+ * This tool allows the LLM to create, identify, or modify relationships between notes.
+ */
+
+import type { Tool, ToolHandler } from './tool_interfaces.js';
+import log from '../../log.js';
+import becca from '../../../becca/becca.js';
+import attributes from '../../attributes.js';
+import aiServiceManager from '../ai_service_manager.js';
+
+/**
+ * Definition of the relationship tool
+ */
+export const relationshipToolDefinition: Tool = {
+    type: 'function',
+    function: {
+        name: 'manage_relationships',
+        description: 'Create, list, or modify relationships between notes',
+        parameters: {
+            type: 'object',
+            properties: {
+                action: {
+                    type: 'string',
+                    description: 'Action to perform on relationships',
+                    enum: ['create', 'list', 'find_related', 'suggest']
+                },
+                sourceNoteId: {
+                    type: 'string',
+                    description: 'ID of the source note for the relationship'
+                },
+                targetNoteId: {
+                    type: 'string',
+                    description: 'ID of the target note for the relationship (for create action)'
+                },
+                relationName: {
+                    type: 'string',
+                    description: 'Name of the relation (for create action, e.g., "references", "belongs to", "depends on")'
+                },
+                limit: {
+                    type: 'number',
+                    description: 'Maximum number of relationships to return (for list action)'
+                }
+            },
+            required: ['action', 'sourceNoteId']
+        }
+    }
+};
+
+/**
+ * Relationship tool implementation
+ */
+export class RelationshipTool implements ToolHandler {
+    public definition: Tool = relationshipToolDefinition;
+
+    /**
+     * Execute the relationship tool
+     */
+    public async execute(args: {
+        action: 'create' | 'list' | 'find_related' | 'suggest',
+        sourceNoteId: string,
+        targetNoteId?: string,
+        relationName?: string,
+        limit?: number
+    }): Promise {
+        try {
+            const { action, sourceNoteId, targetNoteId, relationName, limit = 10 } = args;
+
+            log.info(`Executing manage_relationships tool - Action: ${action}, SourceNoteId: ${sourceNoteId}`);
+
+            // Get the source note from becca
+            const sourceNote = becca.notes[sourceNoteId];
+
+            if (!sourceNote) {
+                log.info(`Source note with ID ${sourceNoteId} not found - returning error`);
+                return `Error: Source note with ID ${sourceNoteId} not found`;
+            }
+
+            log.info(`Found source note: "${sourceNote.title}" (Type: ${sourceNote.type})`);
+
+            // Handle different actions
+            if (action === 'create') {
+                return await this.createRelationship(sourceNote, targetNoteId, relationName);
+            } else if (action === 'list') {
+                return await this.listRelationships(sourceNote, limit);
+            } else if (action === 'find_related') {
+                return await this.findRelatedNotes(sourceNote, limit);
+            } else if (action === 'suggest') {
+                return await this.suggestRelationships(sourceNote, limit);
+            } else {
+                return `Error: Unsupported action "${action}". Supported actions are: create, list, find_related, suggest`;
+            }
+        } catch (error: any) {
+            log.error(`Error executing manage_relationships tool: ${error.message || String(error)}`);
+            return `Error: ${error.message || String(error)}`;
+        }
+    }
+
+    /**
+     * Create a relationship between notes
+     */
+    private async createRelationship(sourceNote: any, targetNoteId?: string, relationName?: string): Promise {
+        if (!targetNoteId) {
+            return {
+                success: false,
+                message: 'Target note ID is required for create action'
+            };
+        }
+
+        if (!relationName) {
+            return {
+                success: false,
+                message: 'Relation name is required for create action'
+            };
+        }
+
+        // Get the target note from becca
+        const targetNote = becca.notes[targetNoteId];
+
+        if (!targetNote) {
+            log.info(`Target note with ID ${targetNoteId} not found - returning error`);
+            return {
+                success: false,
+                message: `Target note with ID ${targetNoteId} not found`
+            };
+        }
+
+        log.info(`Found target note: "${targetNote.title}" (Type: ${targetNote.type})`);
+
+        try {
+            // Check if relationship already exists
+            const existingRelations = sourceNote.getRelationTargets(relationName);
+
+            for (const existingNote of existingRelations) {
+                if (existingNote.noteId === targetNoteId) {
+                    log.info(`Relationship ${relationName} already exists from "${sourceNote.title}" to "${targetNote.title}"`);
+                    return {
+                        success: false,
+                        sourceNoteId: sourceNote.noteId,
+                        sourceTitle: sourceNote.title,
+                        targetNoteId: targetNote.noteId,
+                        targetTitle: targetNote.title,
+                        relationName: relationName,
+                        message: `Relationship ${relationName} already exists from "${sourceNote.title}" to "${targetNote.title}"`
+                    };
+                }
+            }
+
+            // Create the relationship attribute
+            const startTime = Date.now();
+            await attributes.createRelation(sourceNote.noteId, relationName, targetNote.noteId);
+            const duration = Date.now() - startTime;
+
+            log.info(`Created relationship ${relationName} from "${sourceNote.title}" to "${targetNote.title}" in ${duration}ms`);
+
+            return {
+                success: true,
+                sourceNoteId: sourceNote.noteId,
+                sourceTitle: sourceNote.title,
+                targetNoteId: targetNote.noteId,
+                targetTitle: targetNote.title,
+                relationName: relationName,
+                message: `Created relationship ${relationName} from "${sourceNote.title}" to "${targetNote.title}"`
+            };
+        } catch (error: any) {
+            log.error(`Error creating relationship: ${error.message || String(error)}`);
+            throw error;
+        }
+    }
+
+    /**
+     * List relationships for a note
+     */
+    private async listRelationships(sourceNote: any, limit: number): Promise {
+        try {
+            // Get outgoing relationships (where this note is the source)
+            const outgoingAttributes = sourceNote.getAttributes()
+                .filter((attr: any) => attr.type === 'relation')
+                .slice(0, limit);
+
+            const outgoingRelations = [];
+
+            for (const attr of outgoingAttributes) {
+                const targetNote = becca.notes[attr.value];
+
+                if (targetNote) {
+                    outgoingRelations.push({
+                        relationName: attr.name,
+                        targetNoteId: targetNote.noteId,
+                        targetTitle: targetNote.title
+                    });
+                }
+            }
+
+            // Get incoming relationships (where this note is the target)
+            const incomingNotes = becca.findNotesWithRelation(sourceNote.noteId);
+            const incomingRelations = [];
+
+            for (const sourceOfRelation of incomingNotes) {
+                const incomingAttributes = sourceOfRelation.getOwnedAttributes()
+                    .filter((attr: any) => attr.type === 'relation' && attr.value === sourceNote.noteId);
+
+                for (const attr of incomingAttributes) {
+                    incomingRelations.push({
+                        relationName: attr.name,
+                        sourceNoteId: sourceOfRelation.noteId,
+                        sourceTitle: sourceOfRelation.title
+                    });
+                }
+
+                if (incomingRelations.length >= limit) {
+                    break;
+                }
+            }
+
+            log.info(`Found ${outgoingRelations.length} outgoing and ${incomingRelations.length} incoming relationships`);
+
+            return {
+                success: true,
+                noteId: sourceNote.noteId,
+                title: sourceNote.title,
+                outgoingRelations: outgoingRelations,
+                incomingRelations: incomingRelations.slice(0, limit),
+                message: `Found ${outgoingRelations.length} outgoing and ${incomingRelations.length} incoming relationships for "${sourceNote.title}"`
+            };
+        } catch (error: any) {
+            log.error(`Error listing relationships: ${error.message || String(error)}`);
+            throw error;
+        }
+    }
+
+    /**
+     * Find related notes using vector similarity
+     */
+    private async findRelatedNotes(sourceNote: any, limit: number): Promise {
+        try {
+            // Get the vector search tool from the AI service manager
+            const vectorSearchTool = aiServiceManager.getVectorSearchTool();
+
+            if (!vectorSearchTool) {
+                log.error('Vector search tool not available');
+                return {
+                    success: false,
+                    message: 'Vector search capability not available'
+                };
+            }
+
+            log.info(`Using vector search to find notes related to "${sourceNote.title}"`);
+
+            // Get note content for semantic search
+            const content = await sourceNote.getContent();
+            const title = sourceNote.title;
+
+            // Use both title and content for search
+            const searchQuery = title + (content && typeof content === 'string' ? ': ' + content.substring(0, 500) : '');
+
+            // Execute the search
+            const searchStartTime = Date.now();
+            const results = await vectorSearchTool.searchNotes(searchQuery, {
+                maxResults: limit + 1 // Add 1 to account for the source note itself
+            });
+            const searchDuration = Date.now() - searchStartTime;
+
+            // Filter out the source note from results
+            const filteredResults = results.filter(note => note.noteId !== sourceNote.noteId);
+            log.info(`Found ${filteredResults.length} related notes in ${searchDuration}ms`);
+
+            return {
+                success: true,
+                noteId: sourceNote.noteId,
+                title: sourceNote.title,
+                relatedNotes: filteredResults.slice(0, limit).map(note => ({
+                    noteId: note.noteId,
+                    title: note.title,
+                    similarity: Math.round(note.similarity * 100) / 100
+                })),
+                message: `Found ${filteredResults.length} notes semantically related to "${sourceNote.title}"`
+            };
+        } catch (error: any) {
+            log.error(`Error finding related notes: ${error.message || String(error)}`);
+            throw error;
+        }
+    }
+
+    /**
+     * Suggest possible relationships based on content analysis
+     */
+    private async suggestRelationships(sourceNote: any, limit: number): Promise {
+        try {
+            // First, find related notes using vector search
+            const relatedResult = await this.findRelatedNotes(sourceNote, limit) as any;
+
+            if (!relatedResult.success || !relatedResult.relatedNotes || relatedResult.relatedNotes.length === 0) {
+                return {
+                    success: false,
+                    message: 'Could not find any related notes to suggest relationships'
+                };
+            }
+
+            // Get the AI service for relationship suggestion
+            const aiService = aiServiceManager.getService();
+
+            if (!aiService) {
+                log.error('No AI service available for relationship suggestions');
+                return {
+                    success: false,
+                    message: 'AI service not available for relationship suggestions',
+                    relatedNotes: relatedResult.relatedNotes
+                };
+            }
+
+            log.info(`Using ${aiService.getName()} to suggest relationships for ${relatedResult.relatedNotes.length} related notes`);
+
+            // Get the source note content
+            const sourceContent = await sourceNote.getContent();
+
+            // Prepare suggestions
+            const suggestions = [];
+
+            for (const relatedNote of relatedResult.relatedNotes) {
+                try {
+                    // Get the target note content
+                    const targetNote = becca.notes[relatedNote.noteId];
+                    const targetContent = await targetNote.getContent();
+
+                    // Prepare a prompt for the AI service
+                    const prompt = `Analyze the relationship between these two notes and suggest a descriptive relation name (like "references", "implements", "depends on", etc.)
+
+SOURCE NOTE: "${sourceNote.title}"
+${typeof sourceContent === 'string' ? sourceContent.substring(0, 300) : ''}
+
+TARGET NOTE: "${targetNote.title}"
+${typeof targetContent === 'string' ? targetContent.substring(0, 300) : ''}
+
+Suggest the most appropriate relationship type that would connect the source note to the target note. Reply with ONLY the relationship name, nothing else.`;
+
+                    // Get the suggestion
+                    const completion = await aiService.generateChatCompletion([
+                        {
+                            role: 'system',
+                            content: 'You analyze the relationship between notes and suggest a concise, descriptive relation name.'
+                        },
+                        { role: 'user', content: prompt }
+                    ], {
+                        temperature: 0.4,
+                        maxTokens: 50
+                    });
+
+                    // Extract just the relation name (remove any formatting or explanation)
+                    const relationName = completion.text
+                        .replace(/^["']|["']$/g, '') // Remove quotes
+                        .replace(/^relationship:|\./gi, '') // Remove prefixes/suffixes
+                        .trim();
+
+                    suggestions.push({
+                        targetNoteId: relatedNote.noteId,
+                        targetTitle: relatedNote.title,
+                        similarity: relatedNote.similarity,
+                        suggestedRelation: relationName
+                    });
+
+                    log.info(`Suggested relationship "${relationName}" from "${sourceNote.title}" to "${targetNote.title}"`);
+                } catch (error: any) {
+                    log.error(`Error generating suggestion: ${error.message || String(error)}`);
+                    // Continue with other suggestions
+                }
+            }
+
+            return {
+                success: true,
+                noteId: sourceNote.noteId,
+                title: sourceNote.title,
+                suggestions: suggestions,
+                message: `Generated ${suggestions.length} relationship suggestions for "${sourceNote.title}"`
+            };
+        } catch (error: any) {
+            log.error(`Error suggesting relationships: ${error.message || String(error)}`);
+            throw error;
+        }
+    }
+}