From 2db7c349206aca1c987a73c1bf9f0ef6d8386100 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Wed, 20 May 2026 21:49:41 +0200 Subject: [PATCH] Add configurable agent modes with per-mode tool filtering Introduces `ask`, `edit`, `organize`, `research`, and `workflow` modes. Each mode defines its own system prompt, allowed tools, and preview requirements. The active mode can be switched via a dropdown in the chat UI and defaults can be set in plugin settings. Mode selection affects which tools are exposed to the LLM and whether write actions require user preview before execution. --- src/agent-modes.ts | 161 ++++++++++++++++++++++++++++++++++++ src/chat-view.ts | 83 ++++++++++++++++--- src/constants.ts | 1 + src/main.ts | 23 ++++++ src/types.ts | 7 ++ styles.css | 24 ++++++ tests/agent-modes.test.ts | 167 ++++++++++++++++++++++++++++++++++++++ tests/chat-view.test.ts | 5 ++ 8 files changed, 461 insertions(+), 10 deletions(-) create mode 100644 src/agent-modes.ts create mode 100644 tests/agent-modes.test.ts diff --git a/src/agent-modes.ts b/src/agent-modes.ts new file mode 100644 index 0000000..b2be302 --- /dev/null +++ b/src/agent-modes.ts @@ -0,0 +1,161 @@ +// src/agent-modes.ts + +import { AgentMode, OllamaTool } from './types'; +export { AgentMode }; + +/** + * Supported agent modes that change the assistant's behavior, + * available tools, and system prompt. + */ + +export const ALL_AGENT_MODES: AgentMode[] = ['ask', 'edit', 'organize', 'research', 'workflow']; + +export const DEFAULT_AGENT_MODE: AgentMode = 'ask'; + +export interface AgentModeConfig { + label: string; + description: string; + systemPrompt: string; + toolFilter: (tools: OllamaTool[]) => OllamaTool[]; + requiresPreview: boolean; + showModeIndicator: boolean; +} + +function filterToolsByName(tools: OllamaTool[], allowed: Set): OllamaTool[] { + return tools.filter((t) => allowed.has(t.function.name)); +} + +const READ_TOOLS = new Set([ + 'read_vault_file', + 'search_vault_files', +]); + +const ORGANIZE_TOOLS = new Set([ + 'read_vault_file', + 'search_vault_files', + 'update_frontmatter', + 'rename_note', + 'move_note', + 'insert_link', +]); + +const EDIT_TOOLS = new Set([ + 'read_vault_file', + 'search_vault_files', + 'create_note', + 'append_to_note', + 'replace_note_section', + 'update_frontmatter', + 'rename_note', + 'move_note', + 'delete_note', + 'insert_link', +]); + +const RESEARCH_TOOLS = new Set([ + 'read_vault_file', + 'search_vault_files', +]); + +export const AGENT_MODE_CONFIGS: Record = { + ask: { + label: 'Ask', + description: 'Answer questions using vault context. Read-only mode.', + systemPrompt: `You are a helpful assistant that answers questions using the contents of the user's Obsidian vault. +You have access to search and read tools to find relevant information. +Always base your answers on vault content when possible. +If you cannot find relevant information, say so clearly. +Do not make up facts.`, + toolFilter: (tools) => filterToolsByName(tools, READ_TOOLS), + requiresPreview: false, + showModeIndicator: true, + }, + + edit: { + label: 'Edit', + description: 'Create, modify, and organize notes with full editing tools.', + systemPrompt: `You are an assistant that helps edit and manage notes in the user's Obsidian vault. +You have full access to reading, searching, creating, appending, renaming, moving, and deleting notes. +When editing notes: +- Prefer modifying existing content over creating duplicates. +- Use the replace_note_section tool to update specific sections. +- Use update_frontmatter to manage metadata. +- Always confirm destructive actions (deletes, moves) with the user when possible. +- Preview changes when the system supports it.`, + toolFilter: (tools) => filterToolsByName(tools, EDIT_TOOLS), + requiresPreview: true, + showModeIndicator: true, + }, + + organize: { + label: 'Organize', + description: 'Tag, rename, move, and link notes to keep the vault tidy.', + systemPrompt: `You are an assistant that helps organize the user's Obsidian vault. +You can search notes, read them, update frontmatter tags, rename files, move files to folders, and insert wiki-links. +When organizing: +- Suggest consistent tag vocabularies. +- Group related notes by linking them. +- Propose folder structures that match the user's existing patterns. +- Avoid destructive changes unless explicitly requested.`, + toolFilter: (tools) => filterToolsByName(tools, ORGANIZE_TOOLS), + requiresPreview: true, + showModeIndicator: true, + }, + + research: { + label: 'Research', + description: 'Deep vault search and synthesis across multiple notes.', + systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault. +Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries. +Search broadly, read key sources, and cross-reference information. +Cite specific notes and quotes where possible. +If information is incomplete or contradictory, note it explicitly.`, + toolFilter: (tools) => filterToolsByName(tools, RESEARCH_TOOLS), + requiresPreview: false, + showModeIndicator: true, + }, + + workflow: { + label: 'Workflow', + description: 'Execute multi-step workflows via the /workflow command.', + systemPrompt: `You are a workflow orchestrator. Users can trigger workflows with the /workflow command. +When a user describes a multi-step task, you can suggest using /workflow. +Workflows can chain vault searches, LLM calls, tool executions, and formatting steps together. +You do not have direct tool access in this mode — workflows handle tool use.`, + toolFilter: () => [], + requiresPreview: false, + showModeIndicator: true, + }, +}; + +/** + * Get the display label for an agent mode. + */ +export function getAgentModeLabel(mode: AgentMode): string { + return AGENT_MODE_CONFIGS[mode]?.label ?? mode; +} + +/** + * Check if a mode requires action previews for write tools. + */ +export function modeRequiresPreview(mode: AgentMode): boolean { + return AGENT_MODE_CONFIGS[mode]?.requiresPreview ?? false; +} + +/** + * Get the system prompt for an agent mode. + */ +export function getSystemPromptForMode(mode: AgentMode): string { + return AGENT_MODE_CONFIGS[mode]?.systemPrompt ?? AGENT_MODE_CONFIGS.ask.systemPrompt; +} + +/** + * Filter tools based on the current agent mode. + */ +export function filterToolsForMode(tools: OllamaTool[], mode: AgentMode): OllamaTool[] { + const config = AGENT_MODE_CONFIGS[mode]; + if (!config) { + return tools; + } + return config.toolFilter(tools); +} diff --git a/src/chat-view.ts b/src/chat-view.ts index 674b483..1aed40c 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -1,4 +1,11 @@ import { ItemView, Notice, WorkspaceLeaf } from 'obsidian'; +import { + ALL_AGENT_MODES, + getAgentModeLabel, + getSystemPromptForMode, + filterToolsForMode, + modeRequiresPreview, +} from './agent-modes'; import { OllamaClient } from './ollama-client'; import { VaultIndexer } from './vault-indexer'; import { VaultVectorStore } from './vault-vector-store'; @@ -7,6 +14,7 @@ import { ActionPreviewBuilder, isWriteTool } from './action-preview-builder'; import { WorkflowEngine } from './workflow-engine'; import { NoteContextBuilder } from './note-context-builder'; import { + AgentMode, PluginSettings, OllamaMessage, OllamaTool, @@ -50,6 +58,7 @@ export class ChatView extends ItemView { this.newChatButtonClickWrapper = null; this.listenersAttached = false; this.settings = settings; + this.currentAgentMode = settings.agentMode ?? 'ask'; this.ollamaClient = new OllamaClient( settings.ollamaUrl, settings.model, @@ -72,6 +81,10 @@ export class ChatView extends ItemView { updateSettings(newSettings: PluginSettings) { this.settings = newSettings; + this.currentAgentMode = newSettings.agentMode ?? 'ask'; + if (this.modeSelectorEl) { + this.modeSelectorEl.value = this.currentAgentMode; + } this.ollamaClient = new OllamaClient( newSettings.ollamaUrl, newSettings.model, @@ -214,6 +227,27 @@ export class ChatView extends ItemView { } } + // Setup mode selector + if (!this.modeSelectorEl) { + this.modeSelectorEl = newChatContainer.createEl('select', { + cls: 'ollama-mode-selector', + }); + for (const mode of ALL_AGENT_MODES) { + const option = this.modeSelectorEl.createEl('option', { + text: getAgentModeLabel(mode), + attr: { value: mode }, + }); + if (mode === this.currentAgentMode) { + option.setAttribute('selected', 'selected'); + } + } + this.modeSelectorEl.addEventListener('change', () => { + this.currentAgentMode = this.modeSelectorEl!.value as AgentMode; + }); + } else { + newChatContainer.appendChild(this.modeSelectorEl); + } + // Setup new chat button if (!this.newChatButton) { this.newChatButton = newChatContainer.createEl('button', { @@ -308,6 +342,17 @@ export class ChatView extends ItemView { this.listenersAttached = false; } + getAgentMode(): AgentMode { + return this.currentAgentMode; + } + + setAgentMode(mode: AgentMode): void { + this.currentAgentMode = mode; + if (this.modeSelectorEl) { + this.modeSelectorEl.value = mode; + } + } + clearConversation(): void { this.messages = []; this.conversationStateManager.clear(); @@ -334,7 +379,8 @@ export class ChatView extends ItemView { } getTools(): OllamaTool[] { - return [ + const allTools: OllamaTool[] = [ + { type: 'function', function: { @@ -549,15 +595,11 @@ export class ChatView extends ItemView { }, }, ]; + return filterToolsForMode(allTools, this.currentAgentMode); } buildMessages(userMessageContent: string, tools?: OllamaTool[]): OllamaMessage[] { - const systemContent = `You are an assistant that can help answer questions using the contents of a vault. - The user can ask questions about their vault contents, and you should provide helpful responses based on the files. - Vault context includes note titles, content, and any tags (shown as "Tags: ..." at the top of a note entry). - When organizing or categorizing notes, pay attention to tags as they reflect the note's topics and categories. - When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool. - Only use the tools if you need to access vault content that is not already in the context.`; + const systemContent = getSystemPromptForMode(this.currentAgentMode); const systemMessage: OllamaMessage = { role: 'system', content: systemContent, @@ -616,7 +658,7 @@ export class ChatView extends ItemView { } } - if (writePreviews.length > 0) { + if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) { // Store pending state for apply/cancel this.pendingActions = writePreviews; this.pendingReadResults = readResults; @@ -632,8 +674,26 @@ export class ChatView extends ItemView { return; } - // No write tools — proceed with follow-up as before - const allResults = readResults; + // If mode does not require preview, execute write tools immediately + let writeResults: (ToolResult & { id?: string })[] = []; + if (writePreviews.length > 0 && !modeRequiresPreview(this.currentAgentMode)) { + writeResults = ( + await Promise.all( + writePreviews.map(async (action) => { + try { + const toolResult = await this.toolExecutor.handleToolCall(action.toolCall); + return { ...toolResult, id: action.toolCall.id }; + } catch (error) { + ErrorHandler.handleError(error, 'ChatView.processToolCalls'); + return null; + } + }) + ) + ).filter((result): result is NonNullable => result !== null); + } + + // No write tools (or they were already executed) — proceed with follow-up + const allResults = [...readResults, ...writeResults]; const followUpMessages: OllamaMessage[] = allResults.map((result) => ({ role: 'tool', content: JSON.stringify(result), @@ -1042,6 +1102,9 @@ export class ChatView extends ItemView { private conversationStateManager: ConversationStateManager; private vectorStore?: VaultVectorStore; + private modeSelectorEl: HTMLSelectElement | null = null; + private currentAgentMode: AgentMode; + // Pending action state private pendingActions: ProposedAction[] = []; private pendingReadResults: (ToolResult & { id?: string })[] = []; diff --git a/src/constants.ts b/src/constants.ts index b15c6e3..5b23190 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,7 @@ export const DEFAULT_SETTINGS = { maxMessageHistory: 50, maxContextLength: 8000, lastIndexTime: 0, + agentMode: 'ask' as const, cacheConfig: { enabled: false, similarityThreshold: 0.85, diff --git a/src/main.ts b/src/main.ts index 1aa75f7..519e3c3 100755 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,8 @@ import { VaultIndexer } from './vault-indexer'; import { AutoTagger, AutoLinker } from './auto-organizer'; import { PluginSettings } from './types'; import { Logger } from './utils'; +import { ALL_AGENT_MODES, getAgentModeLabel } from './agent-modes'; +import { AgentMode } from './types'; export default class OllamaPlugin extends Plugin { settings: PluginSettings = DEFAULT_SETTINGS; @@ -443,6 +445,27 @@ class OllamaSettingTab extends PluginSettingTab { }) ); + // Agent Mode Setting + containerEl.createEl('h3', { text: 'Agent Mode' }); + containerEl.createEl('p', { + text: 'Default chat mode that controls available tools and system behavior.', + }); + + new Setting(containerEl) + .setName('Default Agent Mode') + .setDesc('Select the default mode for new chat sessions.') + .addDropdown((dropdown) => { + for (const mode of ALL_AGENT_MODES) { + dropdown.addOption(mode, getAgentModeLabel(mode)); + } + dropdown.setValue(this.plugin.settings.agentMode ?? 'ask'); + dropdown.onChange(async (value) => { + this.plugin.settings.agentMode = value as AgentMode; + await this.plugin.saveSettings(); + this.plugin.notifyChatViews(); + }); + }); + // Vault Index Settings containerEl.createEl('h3', { text: 'Vault Semantic Index' }); diff --git a/src/types.ts b/src/types.ts index b532637..f129a8f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -197,6 +197,12 @@ export interface DependencyGraph { }[]; } +// ============================================================ +// Agent Modes +// ============================================================ + +export type AgentMode = 'ask' | 'edit' | 'organize' | 'research' | 'workflow'; + // ============================================================ // Plugin Configuration // ============================================================ @@ -224,6 +230,7 @@ export interface PluginSettings { maxMessageHistory: number; maxContextLength: number; lastIndexTime: number; + agentMode: AgentMode; cacheConfig: CacheConfig; vaultIndexConfig: VaultIndexConfig; autoTagConfig: { diff --git a/styles.css b/styles.css index 62f7034..61772cc 100644 --- a/styles.css +++ b/styles.css @@ -273,3 +273,27 @@ .ollama-cancel-button:hover { background-color: var(--background-modifier-hover); } + +/* Agent Mode Selector */ +.ollama-new-chat-container { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--size-4-1); + padding: var(--size-4-1) var(--size-4-2); +} + +.ollama-mode-selector { + padding: var(--size-4-1) var(--size-4-2); + border-radius: var(--ollama-radius); + border: 1px solid var(--ollama-border); + background-color: var(--background-modifier-form-field); + color: var(--text-normal); + font-size: var(--font-ui-small); + cursor: pointer; +} + +.ollama-mode-selector:focus { + outline: none; + border-color: var(--interactive-accent); +} diff --git a/tests/agent-modes.test.ts b/tests/agent-modes.test.ts new file mode 100644 index 0000000..e868c5e --- /dev/null +++ b/tests/agent-modes.test.ts @@ -0,0 +1,167 @@ +import { + AgentMode, + ALL_AGENT_MODES, + DEFAULT_AGENT_MODE, + AGENT_MODE_CONFIGS, + getAgentModeLabel, + modeRequiresPreview, + getSystemPromptForMode, + filterToolsForMode, +} from '../src/agent-modes'; +import { OllamaTool } from '../src/types'; + +describe('Agent Modes', () => { + const allTools: OllamaTool[] = [ + { + type: 'function', + function: { + name: 'read_vault_file', + description: 'Read', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'search_vault_files', + description: 'Search', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'create_note', + description: 'Create', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'update_frontmatter', + description: 'Update frontmatter', + parameters: { type: 'object', properties: {} }, + }, + }, + { + type: 'function', + function: { + name: 'delete_note', + description: 'Delete', + parameters: { type: 'object', properties: {} }, + }, + }, + ]; + + describe('constants', () => { + it('should define all expected modes', () => { + expect(ALL_AGENT_MODES).toEqual(['ask', 'edit', 'organize', 'research', 'workflow']); + }); + + it('should have default mode ask', () => { + expect(DEFAULT_AGENT_MODE).toBe('ask'); + }); + }); + + describe('getAgentModeLabel', () => { + it('should return labels for known modes', () => { + expect(getAgentModeLabel('ask')).toBe('Ask'); + expect(getAgentModeLabel('edit')).toBe('Edit'); + expect(getAgentModeLabel('organize')).toBe('Organize'); + expect(getAgentModeLabel('research')).toBe('Research'); + expect(getAgentModeLabel('workflow')).toBe('Workflow'); + }); + + it('should fallback to raw mode name for unknown modes', () => { + expect(getAgentModeLabel('unknown' as AgentMode)).toBe('unknown'); + }); + }); + + describe('modeRequiresPreview', () => { + it('should require preview for edit and organize', () => { + expect(modeRequiresPreview('edit')).toBe(true); + expect(modeRequiresPreview('organize')).toBe(true); + }); + + it('should not require preview for ask, research, workflow', () => { + expect(modeRequiresPreview('ask')).toBe(false); + expect(modeRequiresPreview('research')).toBe(false); + expect(modeRequiresPreview('workflow')).toBe(false); + }); + }); + + describe('getSystemPromptForMode', () => { + it('should return a non-empty prompt for each mode', () => { + for (const mode of ALL_AGENT_MODES) { + const prompt = getSystemPromptForMode(mode); + expect(typeof prompt).toBe('string'); + expect(prompt.length).toBeGreaterThan(0); + } + }); + + it('should fallback to ask prompt for unknown mode', () => { + const askPrompt = getSystemPromptForMode('ask'); + const fallback = getSystemPromptForMode('unknown' as AgentMode); + expect(fallback).toBe(askPrompt); + }); + }); + + describe('filterToolsForMode', () => { + it('ask mode should only allow read/search tools', () => { + const filtered = filterToolsForMode(allTools, 'ask'); + const names = filtered.map((t) => t.function.name); + expect(names).toContain('read_vault_file'); + expect(names).toContain('search_vault_files'); + expect(names).not.toContain('create_note'); + expect(names).not.toContain('delete_note'); + }); + + it('edit mode should allow all tools', () => { + const filtered = filterToolsForMode(allTools, 'edit'); + const names = filtered.map((t) => t.function.name); + expect(names).toContain('read_vault_file'); + expect(names).toContain('create_note'); + expect(names).toContain('delete_note'); + }); + + it('organize mode should allow organize tools but not delete', () => { + const filtered = filterToolsForMode(allTools, 'organize'); + const names = filtered.map((t) => t.function.name); + expect(names).toContain('read_vault_file'); + expect(names).toContain('search_vault_files'); + expect(names).toContain('update_frontmatter'); + expect(names).not.toContain('delete_note'); + expect(names).not.toContain('create_note'); + }); + + it('research mode should only allow read/search tools', () => { + const filtered = filterToolsForMode(allTools, 'research'); + const names = filtered.map((t) => t.function.name); + expect(names).toContain('read_vault_file'); + expect(names).toContain('search_vault_files'); + expect(names).not.toContain('create_note'); + }); + + it('workflow mode should return no tools', () => { + const filtered = filterToolsForMode(allTools, 'workflow'); + expect(filtered).toHaveLength(0); + }); + + it('unknown mode should return all tools', () => { + const filtered = filterToolsForMode(allTools, 'unknown' as AgentMode); + expect(filtered).toEqual(allTools); + }); + }); + + describe('AGENT_MODE_CONFIGS', () => { + it('should have a config for every mode', () => { + for (const mode of ALL_AGENT_MODES) { + expect(AGENT_MODE_CONFIGS[mode]).toBeDefined(); + expect(AGENT_MODE_CONFIGS[mode].label).toBeDefined(); + expect(AGENT_MODE_CONFIGS[mode].description).toBeDefined(); + expect(AGENT_MODE_CONFIGS[mode].systemPrompt).toBeDefined(); + } + }); + }); +}); diff --git a/tests/chat-view.test.ts b/tests/chat-view.test.ts index 0aa26c5..629906e 100755 --- a/tests/chat-view.test.ts +++ b/tests/chat-view.test.ts @@ -38,6 +38,7 @@ const mockSettings: PluginSettings = { maxMessageHistory: 50, maxContextLength: 8000, lastIndexTime: 0, + agentMode: 'ask', cacheConfig: { enabled: false, similarityThreshold: 0.9, @@ -412,6 +413,7 @@ describe('ChatView', () => { }); it('should show preview for write tool calls and defer follow-up', async () => { + view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; @@ -464,6 +466,7 @@ describe('ChatView', () => { }); it('should apply pending actions and trigger follow-up', async () => { + view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; @@ -524,6 +527,7 @@ describe('ChatView', () => { }); it('should cancel pending actions', async () => { + view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test'; @@ -567,6 +571,7 @@ describe('ChatView', () => { }); it('should handle tool call errors gracefully and continue with partial results', async () => { + view.setAgentMode('edit'); view['sendButton'] = document.createElement('button'); view['inputEl'] = document.createElement('textarea'); (view['inputEl'] as HTMLTextAreaElement).value = 'test';