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';