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.
This commit is contained in:
2026-05-21 10:39:22 +02:00
parent e7b753014c
commit db96858222
3 changed files with 248 additions and 34 deletions
+79 -17
View File
@@ -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);
}
+106 -17
View File
@@ -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<string, unknown>) => {
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;
+63
View File
@@ -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');