diff --git a/main.js b/main.js index 4330866..38a4f6d 100644 --- a/main.js +++ b/main.js @@ -12011,25 +12011,11 @@ ${actualMessage}` : actualMessage; assistantMessageId ); } + let shouldFallbackToReadTools = false; const toolCapableModes = ["edit", "organize", "research"]; if (toolCalls.length === 0 && toolCapableModes.includes(this.currentAgentMode) && fullResponse.trim().length > 0) { - const intentPhrases = [ - "let me", - "i will", - "i need to", - "search for", - "find", - "read", - "explore", - "look for", - "check", - "move", - "rename", - "create" - ]; - const lowerResponse = fullResponse.toLowerCase(); - const seemsToWantTools = intentPhrases.some((p) => lowerResponse.includes(p)); - if (seemsToWantTools) { + shouldFallbackToReadTools = this.shouldAutoRunReadTools(fullResponse); + if (shouldFallbackToReadTools) { const nudgeMessages = [ ...messagesWithMemory, { role: "assistant", content: fullResponse }, @@ -12065,6 +12051,23 @@ ${actualMessage}` : actualMessage; } } } + if (!shouldFallbackToReadTools) { + shouldFallbackToReadTools = this.shouldAutoRunReadTools(fullResponse); + } + if (toolCalls.length === 0 && shouldFallbackToReadTools) { + const autoToolCalls = this.buildAutomaticReadToolCalls(actualMessage, tools); + if (autoToolCalls.length > 0) { + toolCalls = autoToolCalls; + fullResponse = ""; + await this.processToolCalls( + autoToolCalls, + messagesWithMemory, + tools, + fullResponse, + assistantMessageId + ); + } + } if (toolCalls.length === 0) { this.updateMessageById(assistantMessageId, { content: fullResponse, @@ -12120,6 +12123,65 @@ ${actualMessage}` : actualMessage; this.cleanupStreamingResources(); } } + shouldAutoRunReadTools(response) { + const toolCapableModes = ["edit", "organize", "research"]; + if (!toolCapableModes.includes(this.currentAgentMode)) { + return false; + } + const lowerResponse = response.toLowerCase(); + const intentPhrases = [ + "let me", + "i will", + "i need to", + "search for", + "look for", + "read", + "explore", + "check" + ]; + return intentPhrases.some((phrase) => lowerResponse.includes(phrase)); + } + buildAutomaticReadToolCalls(message, tools) { + const availableTools = new Set(tools.map((tool) => tool.function.name)); + const lowerMessage = message.toLowerCase(); + const calls = []; + const addCall = (name, args) => { + if (!availableTools.has(name)) { + return; + } + calls.push({ + id: crypto.randomUUID(), + type: "function", + function: { + name, + arguments: JSON.stringify(args) + } + }); + }; + const wantsStructure = lowerMessage.includes("folder") || lowerMessage.includes("structure") || lowerMessage.includes("organize") || lowerMessage.includes("vault") || lowerMessage.includes("move"); + const wantsTags = lowerMessage.includes("tag"); + if (wantsStructure) { + addCall("get_vault_stats", {}); + } + if (wantsTags || wantsStructure) { + addCall("list_vault_tags", { sortBy: wantsTags ? "count" : "name" }); + } + const searchQuery = this.buildAutomaticSearchQuery(message); + if (searchQuery) { + addCall("search_vault_files", { + query: searchQuery, + limit: this.settings.vaultSearchLimit + }); + } + return calls.slice(0, MAX_TOOL_CALLS); + } + buildAutomaticSearchQuery(message) { + const lowerMessage = message.toLowerCase().trim(); + if (lowerMessage === "continue" || lowerMessage === "please continue") { + return "folder structure tags organization vault index prompts"; + } + return message.replace(/\bplease\b/gi, "").replace(/\bcontinue\b/gi, "").replace(/\bimplement\b/gi, "").replace(/\bcreate\b/gi, "").replace(/\bmove\b/gi, "").replace(/\bnotes?\b/gi, "").replace(/\bfolders?\b/gi, "").replace(/\bstructure\b/gi, "").replace(/\s+/g, " ").trim(); + } createOllamaClient(model, settings) { return new OllamaClient(settings.ollamaUrl, model, void 0, settings.cacheConfig); } diff --git a/src/chat-view.ts b/src/chat-view.ts index 07599ba..10f9870 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -1379,29 +1379,15 @@ export class ChatView extends ItemView { } // Auto-nudge for tool-capable modes if assistant didn't emit tools but seems to intend to + let shouldFallbackToReadTools = false; const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research']; if ( toolCalls.length === 0 && toolCapableModes.includes(this.currentAgentMode) && fullResponse.trim().length > 0 ) { - const intentPhrases = [ - 'let me', - 'i will', - 'i need to', - 'search for', - 'find', - 'read', - 'explore', - 'look for', - 'check', - 'move', - 'rename', - 'create', - ]; - const lowerResponse = fullResponse.toLowerCase(); - const seemsToWantTools = intentPhrases.some((p) => lowerResponse.includes(p)); - if (seemsToWantTools) { + shouldFallbackToReadTools = this.shouldAutoRunReadTools(fullResponse); + if (shouldFallbackToReadTools) { const nudgeMessages: OllamaMessage[] = [ ...messagesWithMemory, { role: 'assistant', content: fullResponse }, @@ -1439,6 +1425,25 @@ export class ChatView extends ItemView { } } + if (!shouldFallbackToReadTools) { + shouldFallbackToReadTools = this.shouldAutoRunReadTools(fullResponse); + } + + if (toolCalls.length === 0 && shouldFallbackToReadTools) { + const autoToolCalls = this.buildAutomaticReadToolCalls(actualMessage, tools); + if (autoToolCalls.length > 0) { + toolCalls = autoToolCalls; + fullResponse = ''; + await this.processToolCalls( + autoToolCalls, + messagesWithMemory, + tools, + fullResponse, + assistantMessageId + ); + } + } + // Update assistant message immutably — only if no tool calls were processed if (toolCalls.length === 0) { this.updateMessageById(assistantMessageId, { @@ -1508,6 +1513,90 @@ export class ChatView extends ItemView { } } + private shouldAutoRunReadTools(response: string): boolean { + const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research']; + if (!toolCapableModes.includes(this.currentAgentMode)) { + return false; + } + + const lowerResponse = response.toLowerCase(); + const intentPhrases = [ + 'let me', + 'i will', + 'i need to', + 'search for', + 'look for', + 'read', + 'explore', + 'check', + ]; + return intentPhrases.some((phrase) => lowerResponse.includes(phrase)); + } + + private buildAutomaticReadToolCalls(message: string, tools: OllamaTool[]): OllamaToolCall[] { + const availableTools = new Set(tools.map((tool) => tool.function.name)); + const lowerMessage = message.toLowerCase(); + const calls: OllamaToolCall[] = []; + + const addCall = (name: string, args: Record) => { + if (!availableTools.has(name)) { + return; + } + calls.push({ + id: crypto.randomUUID(), + type: 'function', + function: { + name, + arguments: JSON.stringify(args), + }, + }); + }; + + const wantsStructure = + lowerMessage.includes('folder') || + lowerMessage.includes('structure') || + lowerMessage.includes('organize') || + lowerMessage.includes('vault') || + lowerMessage.includes('move'); + const wantsTags = lowerMessage.includes('tag'); + + if (wantsStructure) { + addCall('get_vault_stats', {}); + } + if (wantsTags || wantsStructure) { + addCall('list_vault_tags', { sortBy: wantsTags ? 'count' : 'name' }); + } + + const searchQuery = this.buildAutomaticSearchQuery(message); + if (searchQuery) { + addCall('search_vault_files', { + query: searchQuery, + limit: this.settings.vaultSearchLimit, + }); + } + + return calls.slice(0, MAX_TOOL_CALLS); + } + + private buildAutomaticSearchQuery(message: string): string { + const lowerMessage = message.toLowerCase().trim(); + if (lowerMessage === 'continue' || lowerMessage === 'please continue') { + return 'folder structure tags organization vault index prompts'; + } + + return message + .replace(/\bplease\b/gi, '') + .replace(/\bcontinue\b/gi, '') + .replace(/\bimplement\b/gi, '') + .replace(/\bcreate\b/gi, '') + .replace(/\bmove\b/gi, '') + .replace(/\bnotes?\b/gi, '') + .replace(/\bfolders?\b/gi, '') + .replace(/\bstructure\b/gi, '') + .replace(/\s+/g, ' ') + .trim(); + } + // State private messages: ChatMessage[] = []; private lastMessageEl: HTMLElement | null = null; diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index 21d65df..adcde8a 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -395,6 +395,69 @@ describe('ChatView', () => { expect(chatSpy).toHaveBeenCalled(); }); + it('should auto-run read tools when organize mode returns a non-action response', async () => { + view.setAgentMode('organize'); + view['sendButton'] = document.createElement('button'); + view['inputEl'] = document.createElement('textarea'); + (view['inputEl'] as HTMLTextAreaElement).value = + 'please implement the suggested structure by creating folders and moving notes'; + + const streamSpy = jest.spyOn(view['ollamaClient'], 'streamChat'); + streamSpy + .mockReturnValueOnce( + (async function* () { + yield { + role: 'assistant', + content: + 'Let me look at the current note and previous conversation to understand what structure was suggested.', + }; + })() + ) + .mockReturnValueOnce( + (async function* () { + yield { + role: 'assistant', + content: + 'Let me read the current Prompts note and look at the Vault Index for more context.', + }; + })() + ); + + const handleToolSpy = jest + .spyOn(view['toolExecutor'], 'handleToolCall') + .mockResolvedValue({ + success: true, + message: 'Found vault context', + data: [{ path: 'Prompts.md', title: 'Prompts' }], + }); + jest.spyOn(view['ollamaClient'], 'chat').mockResolvedValue({ + role: 'assistant', + content: 'I found vault context and can now suggest the next organization step.', + }); + + await (view as any).handleUserInput( + 'please implement the suggested structure by creating folders and moving notes' + ); + + expect(handleToolSpy).toHaveBeenCalled(); + expect( + handleToolSpy.mock.calls.some( + ([toolCall]) => toolCall.function.name === 'get_vault_stats' + ) + ).toBe(true); + expect( + handleToolSpy.mock.calls.some( + ([toolCall]) => toolCall.function.name === 'list_vault_tags' + ) + ).toBe(true); + + const messages = (view as any).messages; + const lastMessage = messages[messages.length - 1]; + expect(lastMessage.content).toBe( + 'I found vault context and can now suggest the next organization step.' + ); + }); + it('should handle errors during user input gracefully', async () => { view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea');