From db96858222975dc501f900a7cf3df772bab1c8d1 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 21 May 2026 10:39:22 +0200 Subject: [PATCH] Add automatic read tool execution for non-action assistant responses Replace manual intent phrase matching with dedicated helpers that detect when the assistant wants to use tools but didn't emit tool calls. When detected, automatically build and execute read-only tool calls based on the user's original message instead of nudging the model again. This reduces back-and-forth latency for organize and research modes by directly querying vault stats, tags, and files when the assistant expresses intent like "let me read" or "I will check" but fails to actually call tools. Includes tests for the new auto-run behavior in organize mode. --- main.js | 96 +++++++++++++++++++++++++------ src/chat-view.ts | 123 ++++++++++++++++++++++++++++++++++------ tests/chat-view.test.ts | 63 ++++++++++++++++++++ 3 files changed, 248 insertions(+), 34 deletions(-) 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');