diff --git a/AIClasses/BaseAIClass.ts b/AIClasses/BaseAIClass.ts index 3028356..01d8c0c 100644 --- a/AIClasses/BaseAIClass.ts +++ b/AIClasses/BaseAIClass.ts @@ -1,11 +1,9 @@ import { Resolve } from "Services/DependencyService"; import { Services } from "Services/Services"; import type { IAIClass } from "AIClasses/IAIClass"; -import type { IPrompt } from "AIClasses/IPrompt"; import { type IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; import type { AIProvider } from "Enums/ApiProvider"; -import type { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; import type { ConversationContent } from "Conversations/ConversationContent"; import type { Attachment } from "Conversations/Attachment"; @@ -22,31 +20,50 @@ export abstract class BaseAIClass implements IAIClass { protected readonly provider: AIProvider; protected readonly apiKey: string; - protected readonly aiPrompt: IPrompt; protected readonly abortService: AbortService; protected readonly aiFileService: IAIFileService; protected readonly settingsService: SettingsService; protected readonly streamingService: StreamingService; - protected readonly aiFunctionDefinitions: AIFunctionDefinitions; + + private _systemPrompt: string = ""; + private _userInstruction: string = ""; + private _toolDefinitions: IAIFunctionDefinition[] = []; protected constructor(provider: AIProvider) { this.provider = provider; - this.aiPrompt = Resolve(Services.IPrompt); this.abortService = Resolve(Services.AbortService); this.aiFileService = Resolve(Services.IAIFileService); this.settingsService = Resolve(Services.SettingsService); this.streamingService = Resolve(Services.StreamingService); - this.aiFunctionDefinitions = Resolve(Services.AIFunctionDefinitions); this.apiKey = this.settingsService.getApiKeyForProvider(provider); } + public set systemPrompt(systemPrompt: string) { + this._systemPrompt = systemPrompt; + } - public abstract streamRequest( - conversation: Conversation, - allowDestructiveActions: boolean, - abortSignal?: AbortSignal - ): AsyncGenerator; + public get systemPrompt(): string { + return this._systemPrompt; + } + + public set userInstruction(userInstruction: string) { + this._userInstruction = userInstruction; + } + + public get userInstruction(): string { + return this._userInstruction; + } + + public get toolDefinitions(): IAIFunctionDefinition[] { + return this._toolDefinitions; + } + + public set toolDefinitions(toolDefinitions: IAIFunctionDefinition[]) { + this._toolDefinitions = toolDefinitions; + } + + public abstract streamRequest(conversation: Conversation): AsyncGenerator; public abstract formatBinaryFiles(attachments: Attachment[]): string; @@ -123,13 +140,6 @@ export abstract class BaseAIClass implements IAIClass { }; } - protected async buildSystemPrompt(): Promise { - return [ - this.aiPrompt.systemInstruction(), - await this.aiPrompt.userInstruction() - ].filter(s => s).join("\n\n"); - } - protected async processAttachments( attachments: Attachment[], formatBinaryFiles: (attachments: Attachment[]) => string diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 81f7721..df01d40 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -35,9 +35,8 @@ export class Claude extends BaseAIClass { super(AIProvider.Claude); } - public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean - ): AsyncGenerator { + public async* streamRequest(conversation: Conversation): AsyncGenerator { + this.accumulatedFunctionName = null; this.accumulatedFunctionArgs = ""; this.accumulatedFunctionId = null; @@ -48,7 +47,7 @@ export class Claude extends BaseAIClass { } // Build system prompt and convert to array with cache control - const systemPromptText = await this.buildSystemPrompt(); + const systemPromptText = `${this.systemPrompt}\n\n${this.userInstruction}`; const systemPrompt: TextBlockParam[] = [ { type: "text", @@ -66,14 +65,8 @@ export class Claude extends BaseAIClass { max_uses: 5 }; - let tools: ToolUnion[] = [ - webSearchTool, - ...this.mapFunctionDefinitions( - this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions) - ) - ]; - - tools = this.addCacheControlToTools(tools); + const tools: ToolUnion[] = this.addCacheControlToTools([ + webSearchTool, ...this.mapFunctionDefinitions(this.toolDefinitions)]); const requestBody = { model: this.settingsService.settings.model, diff --git a/AIClasses/Claude/ClaudeConversationNamingService.ts b/AIClasses/Claude/ClaudeConversationNamingService.ts index e1b75e6..1e981e2 100644 --- a/AIClasses/Claude/ClaudeConversationNamingService.ts +++ b/AIClasses/Claude/ClaudeConversationNamingService.ts @@ -3,7 +3,7 @@ import { Services } from "Services/Services"; import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider"; import { Role } from "Enums/Role"; -import { NamePrompt } from "AIClasses/NamePrompt"; +import { NamePrompt } from "AIPrompts/NamePrompt"; import type { SettingsService } from "Services/SettingsService"; import type Anthropic from '@anthropic-ai/sdk'; import { Exception } from "Helpers/Exception"; diff --git a/AIClasses/FunctionDefinitions/AIFunctionDefinitions.ts b/AIClasses/FunctionDefinitions/AIFunctionDefinitions.ts index bf4c26c..d86e15f 100644 --- a/AIClasses/FunctionDefinitions/AIFunctionDefinitions.ts +++ b/AIClasses/FunctionDefinitions/AIFunctionDefinitions.ts @@ -6,9 +6,18 @@ import { DeleteVaultFiles } from "./Functions/DeleteVaultFiles"; import { MoveVaultFiles } from "./Functions/MoveVaultFiles"; import { ListVaultFiles } from "./Functions/ListVaultFiles"; import { PatchVaultFile } from "./Functions/PatchVaultFile"; +import { CreatePlan } from "./Functions/CreatePlan"; +import { Replan } from "./Functions/Replan"; +import { CompleteStep } from "./Functions/CompleteStep"; +import { SubmitPlan } from "./Functions/SubmitPlan"; -export class AIFunctionDefinitions { - public getQueryActions(destructive: boolean): IAIFunctionDefinition[] { +export abstract class AIFunctionDefinitions { + + // Definitions list provides a list of function definitions that does not include any planning functions (used as reference in planning agent prompt) + private static readonly definitionsList = [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles]; + + // Definitions for the main agent + public static agentDefinitions(destructive: boolean): IAIFunctionDefinition[] { let actions = [ SearchVaultFiles, ReadVaultFiles, @@ -20,10 +29,43 @@ export class AIFunctionDefinitions { WriteVaultFile, PatchVaultFile, DeleteVaultFiles, - MoveVaultFiles + MoveVaultFiles, + CreatePlan ]); } return actions; } + + // Definitions for the planning agent + public static planningAgentDefinitions(): IAIFunctionDefinition[] { + return [SearchVaultFiles, ReadVaultFiles, ListVaultFiles, SubmitPlan]; + } + + // Definitions for the main agent during plan execution + public static agentExecutionDefinitions() { + return [ + SearchVaultFiles, + ReadVaultFiles, + ListVaultFiles, + WriteVaultFile, + PatchVaultFile, + DeleteVaultFiles, + MoveVaultFiles, + CompleteStep, + Replan + ]; + } + + public static compactSummaryForPlanningAgent(): string { + return this.definitionsList.map(definition => { + // Extract first line of description as brief purpose + const description = definition.description.split('\n')[0].trim(); + return `| ${definition.name} | ${description} |`; + }).join("\n"); + } + + public static detailedAppendixForPlanningAgent(): string { + return this.definitionsList.map(definition => JSON.stringify(definition, null, 2)).join('\n\n'); + } } \ No newline at end of file diff --git a/AIClasses/FunctionDefinitions/Functions/CompleteStep.ts b/AIClasses/FunctionDefinitions/Functions/CompleteStep.ts new file mode 100644 index 0000000..ca3e31d --- /dev/null +++ b/AIClasses/FunctionDefinitions/Functions/CompleteStep.ts @@ -0,0 +1,30 @@ +import { AIFunction } from "Enums/AIFunction"; +import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; + +export const CompleteStep: IAIFunctionDefinition = { + name: AIFunction.CompleteStep, + description: `Marks a specific step in the current execution plan as completed. + +Use this function to track your progress through a plan created by the planning agent. +This helps maintain accurate state of which steps have been executed and provides +visibility to the user about task progress. + +Call this function: +- Immediately after successfully completing a plan step +- Before moving on to the next step in the plan +- When a step's objectives have been fully satisfied + +Do NOT use this function: +- For steps that failed +- For partial completion of a step`, + parameters: { + type: "object", + properties: { + step_number: { + type: "number", + description: "The number of the step being marked as completed (1-indexed). This should correspond to the step number in the plan." + } + }, + required: ["step_number"] + } +} diff --git a/AIClasses/FunctionDefinitions/Functions/CreatePlan.ts b/AIClasses/FunctionDefinitions/Functions/CreatePlan.ts new file mode 100644 index 0000000..a2885da --- /dev/null +++ b/AIClasses/FunctionDefinitions/Functions/CreatePlan.ts @@ -0,0 +1,44 @@ +import { AIFunction } from "Enums/AIFunction"; +import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; + +export const CreatePlan: IAIFunctionDefinition = { + name: AIFunction.CreatePlan, + description: `Requests the planning agent to create a detailed, actionable plan for a high-level goal or task. + +Use this function when you need strategic guidance on how to approach a complex +or multi-step request. The planning agent will explore the vault context, analyze +the requirements, and return a structured plan with specific steps for you to execute. + +The planning agent specializes in: +- Breaking down complex tasks into atomic, ordered steps +- Conducting exploratory vault searches to inform the plan +- Identifying dependencies and potential failure modes +- Selecting appropriate tools and operations for each step +- Adapting plan complexity to match the task requirements + +Call this proactively when: +- The user's request involves multiple operations or phases +- You need to understand vault structure before acting +- The optimal approach is unclear and requires analysis +- The task requires coordination across multiple vault areas + +Do NOT use for simple, single-step operations that don't require planning.`, + parameters: { + type: "object", + properties: { + goal: { + type: "string", + description: "The high-level goal or task to plan for. Should clearly describe what needs to be accomplished. Examples: 'Create a comprehensive summary of all machine learning notes', 'Reorganize project notes by topic', 'Research and compile information about quantum computing from the vault'" + }, + context: { + type: "string", + description: "Optional additional context that may help inform the planning process. This can include: specific requirements, constraints, user preferences, relevant file paths already identified, or any other information that would help create a better plan. Examples: 'User prefers daily notes in YYYY-MM-DD format', 'Focus on notes created in the last month', 'Should preserve existing wiki-link structure'" + }, + user_message: { + type: "string", + description: "A short message to be displayed to the user explaining why you're requesting a plan. Examples: 'Creating a plan to organize your project notes', 'Developing a strategy to compile your research on AI topics'" + } + }, + required: ["goal", "user_message"] + } +} \ No newline at end of file diff --git a/AIClasses/FunctionDefinitions/Functions/DeleteVaultFiles.ts b/AIClasses/FunctionDefinitions/Functions/DeleteVaultFiles.ts index 90a5acb..9b64669 100644 --- a/AIClasses/FunctionDefinitions/Functions/DeleteVaultFiles.ts +++ b/AIClasses/FunctionDefinitions/Functions/DeleteVaultFiles.ts @@ -3,12 +3,12 @@ import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; export const DeleteVaultFiles: IAIFunctionDefinition = { name: AIFunction.DeleteVaultFiles, - description: `Permanently removes files and folders from the vault. Use this when the user explicitly - requests to delete file(s) or folder(s), when a file or folder is no longer needed, or when removing outdated - content. IMPORTANT: This action is irreversible - always confirm the exact file path(s) - before deletion. Prefer archiving or moving files to a trash folder over permanent - deletion when uncertain. Only call this after verifying the file(s) exist and confirming - the user's intent to delete.`, + description: `Permanently removes files and folders from the vault. Use this when the user explicitly +requests to delete file(s) or folder(s), when a file or folder is no longer needed, or when removing outdated +content. IMPORTANT: This action is irreversible - always confirm the exact file path(s) +before deletion. Prefer archiving or moving files to a trash folder over permanent +deletion when uncertain. Only call this after verifying the file(s) exist and confirming +the user's intent to delete.`, parameters: { type: "object", properties: { diff --git a/AIClasses/FunctionDefinitions/Functions/ListVaultFiles.ts b/AIClasses/FunctionDefinitions/Functions/ListVaultFiles.ts index 1464aad..f6e6639 100644 --- a/AIClasses/FunctionDefinitions/Functions/ListVaultFiles.ts +++ b/AIClasses/FunctionDefinitions/Functions/ListVaultFiles.ts @@ -4,12 +4,12 @@ import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; export const ListVaultFiles: IAIFunctionDefinition = { name: AIFunction.ListVaultFiles, description: `Lists files and directories in the vault's directory structure. - Returns a structured view of the vault's organization including file names, paths, and directory hierarchy. - Use this function when you need to: - - List files in a specific directory or the entire vault - - Get an overview of vault organization and structure - - Browse available files and folders - - Understand how notes are organized`, +Returns a structured view of the vault's organization including file names, paths, and directory hierarchy. +Use this function when you need to: +- List files in a specific directory or the entire vault +- Get an overview of vault organization and structure +- Browse available files and folders +- Understand how notes are organized`, parameters: { type: "object", properties: { diff --git a/AIClasses/FunctionDefinitions/Functions/MoveVaultFiles.ts b/AIClasses/FunctionDefinitions/Functions/MoveVaultFiles.ts index eca7e55..ff662d1 100644 --- a/AIClasses/FunctionDefinitions/Functions/MoveVaultFiles.ts +++ b/AIClasses/FunctionDefinitions/Functions/MoveVaultFiles.ts @@ -4,12 +4,12 @@ import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; export const MoveVaultFiles: IAIFunctionDefinition = { name: AIFunction.MoveVaultFiles, description: `Moves or renames one or more files within the vault to new locations. - Use this when reorganizing vault structure, moving files between folders, renaming - files for better organization, or consolidating related notes into appropriate - directories. This operation preserves file content while updating paths and names. - If renaming within the same folder, source and destination folders will be identical - with only the filename changing. For safety, consider reading files first to confirm you're - moving the correct content, especially when performing batch operations.`, +Use this when reorganizing vault structure, moving files between folders, renaming +files for better organization, or consolidating related notes into appropriate +directories. This operation preserves file content while updating paths and names. +If renaming within the same folder, source and destination folders will be identical +with only the filename changing. For safety, consider reading files first to confirm you're +moving the correct content, especially when performing batch operations.`, parameters: { type: "object", properties: { diff --git a/AIClasses/FunctionDefinitions/Functions/PatchVaultFile.ts b/AIClasses/FunctionDefinitions/Functions/PatchVaultFile.ts index f27d0a8..5af733e 100644 --- a/AIClasses/FunctionDefinitions/Functions/PatchVaultFile.ts +++ b/AIClasses/FunctionDefinitions/Functions/PatchVaultFile.ts @@ -5,15 +5,15 @@ export const PatchVaultFile: IAIFunctionDefinition = { name: AIFunction.PatchVaultFile, description: `Apply targeted changes to an existing file in the vault by finding and replacing specific content. - This tool modifies specific sections of a file by matching exact content and replacing it with new content. It works by performing a direct string match and replace operation. +This tool modifies specific sections of a file by matching exact content and replacing it with new content. It works by performing a direct string match and replace operation. - **When to use this tool:** - - Making small, targeted edits to large files (a few lines changed) - - Edits in the middle of a file where you have clear surrounding context - - Simple line additions, deletions, or replacements with minimal changes - - When you know the exact content that needs to be changed +**When to use this tool:** +- Making small, targeted edits to large files (a few lines changed) +- Edits in the middle of a file where you have clear surrounding context +- Simple line additions, deletions, or replacements with minimal changes +- When you know the exact content that needs to be changed - **CRITICAL:** The content to match must be EXACTLY as it appears in the file - including all whitespace, indentation, blank lines, and line breaks.`, +**CRITICAL:** The content to match must be EXACTLY as it appears in the file - including all whitespace, indentation, blank lines, and line breaks.`, parameters: { type: "object", properties: { @@ -25,19 +25,19 @@ export const PatchVaultFile: IAIFunctionDefinition = { type: "string", description: `The exact content to find and replace in the file. - CRITICAL MATCHING REQUIREMENTS: - - Must match the file content EXACTLY character-for-character - - Include all whitespace, indentation, and line breaks exactly as they appear - - Include enough context to make the match unique within the file - - Typically include 2-3 lines before and after the change for context - - Match complete lines/statements to avoid breaking code structure - - Preserve all blank lines that exist in the original +CRITICAL MATCHING REQUIREMENTS: +- Must match the file content EXACTLY character-for-character +- Include all whitespace, indentation, and line breaks exactly as they appear +- Include enough context to make the match unique within the file +- Typically include 2-3 lines before and after the change for context +- Match complete lines/statements to avoid breaking code structure +- Preserve all blank lines that exist in the original - WARNING: The replacement will fail if: - - The content doesn't exist in the file - - Whitespace/indentation doesn't match exactly - - The match is ambiguous (appears multiple times in the file) - - Line breaks are missing or incorrect` +WARNING: The replacement will fail if: +- The content doesn't exist in the file +- Whitespace/indentation doesn't match exactly +- The match is ambiguous (appears multiple times in the file) +- Line breaks are missing or incorrect` }, newContent: { type: "string", diff --git a/AIClasses/FunctionDefinitions/Functions/ReadVaultFiles.ts b/AIClasses/FunctionDefinitions/Functions/ReadVaultFiles.ts index cb08825..68529f1 100644 --- a/AIClasses/FunctionDefinitions/Functions/ReadVaultFiles.ts +++ b/AIClasses/FunctionDefinitions/Functions/ReadVaultFiles.ts @@ -5,19 +5,19 @@ export const ReadVaultFiles: IAIFunctionDefinition = { name: AIFunction.ReadVaultFiles, description: `Reads and returns the complete content of one or more files from the vault. - **IMPORTANT: This function gives you the ability to SEE and ANALYZE images - and PDFs. When users ask about image or PDF files, USE THIS FUNCTION to - read them—do not claim you cannot see images.** +**IMPORTANT: This function gives you the ability to SEE and ANALYZE images +and PDFs. When users ask about image or PDF files, USE THIS FUNCTION to +read them—do not claim you cannot see images.** - Call this when you need to access existing file content to answer questions, - provide summaries, verify information, or gather context before making updates. - Use proactively before updating files to understand current content and avoid - data loss. Essential for any operation that references or builds upon existing notes. +Call this when you need to access existing file content to answer questions, +provide summaries, verify information, or gather context before making updates. +Use proactively before updating files to understand current content and avoid +data loss. Essential for any operation that references or builds upon existing notes. - For multiple files: Use when comparing content, gathering related context, or - analyzing information across several documents. +For multiple files: Use when comparing content, gathering related context, or +analyzing information across several documents. - Supports text files (.md, .txt, etc.), images (.png, .jpg, .jpeg, etc), and PDFs (.pdf).`, +Supports text files (.md, .txt, etc.), images (.png, .jpg, .jpeg, etc), and PDFs (.pdf).`, parameters: { type: "object", properties: { diff --git a/AIClasses/FunctionDefinitions/Functions/Replan.ts b/AIClasses/FunctionDefinitions/Functions/Replan.ts new file mode 100644 index 0000000..653bbe5 --- /dev/null +++ b/AIClasses/FunctionDefinitions/Functions/Replan.ts @@ -0,0 +1,58 @@ +import { AIFunction } from "Enums/AIFunction"; +import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; + +export const Replan: IAIFunctionDefinition = { + name: AIFunction.Replan, + description: `Requests the planning agent to revise or extend the current plan based on new information, +obstacles encountered, or changing requirements during execution. + +Use this function when the original plan needs adjustment due to: +- Unexpected results from a step that require a different approach +- Missing prerequisites discovered during execution +- User feedback or clarifications that change requirements +- Partial success that requires replanning remaining steps +- New context or information that makes the current plan suboptimal + +The planning agent will: +- Review the original goal and current progress +- Analyze what has been completed and what failed or changed +- Create an updated plan that addresses the new situation +- Preserve successful work while adapting remaining steps + +Call this when: +- A planned step fails and you need an alternative approach +- Execution reveals the original plan was based on incorrect assumptions +- The user provides new information mid-execution +- You've completed part of the plan but the remaining steps are no longer valid + +Do NOT use for: +- Minor adjustments you can handle without planning assistance +- Completely new tasks unrelated to the current plan (use CreatePlan instead) +- Simple retries of failed operations`, + parameters: { + type: "object", + properties: { + original_goal: { + type: "string", + description: "The original high-level goal from the initial plan. This provides continuity and helps the planning agent understand what we're ultimately trying to achieve." + }, + completed_steps: { + type: "string", + description: "A summary of what has been successfully completed so far. Include any relevant outputs, created files, or state changes. Examples: 'Successfully read 5 notes from the Projects folder', 'Created summary.md with initial content'" + }, + issue_encountered: { + type: "string", + description: "Description of what went wrong or what changed that necessitates replanning. Be specific about the problem. Examples: 'The notes folder structure is different than expected - notes are nested in subfolders by year', 'User clarified they only want notes from 2024', 'Write operation failed because file already exists'" + }, + context: { + type: "string", + description: "Additional context including new information discovered, user preferences, constraints, or any other details that should inform the revised plan." + }, + user_message: { + type: "string", + description: "A short message to be displayed to the user explaining why you're requesting a replan. Examples: 'Adjusting the plan based on the vault structure I found', 'Revising approach after encountering a conflict'" + } + }, + required: ["original_goal", "completed_steps", "issue_encountered", "context", "user_message"] + } +} diff --git a/AIClasses/FunctionDefinitions/Functions/SearchVaultFiles.ts b/AIClasses/FunctionDefinitions/Functions/SearchVaultFiles.ts index 01fa9cb..bc39c6a 100644 --- a/AIClasses/FunctionDefinitions/Functions/SearchVaultFiles.ts +++ b/AIClasses/FunctionDefinitions/Functions/SearchVaultFiles.ts @@ -4,13 +4,13 @@ import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; export const SearchVaultFiles: IAIFunctionDefinition = { name: AIFunction.SearchVaultFiles, description: `Searches the content of all vault files using regex pattern matching. - Returns files containing any of the search terms with contextual snippets showing where matches appear. - Use this function when you need to: - - Find specific concepts, keywords, or text within note contents - - Locate content matching multiple patterns or phrases - - Answer questions about what the user has written about a topic - - Search across both file names and file contents simultaneously - - Search for multiple related terms or variations in a single query`, +Returns files containing any of the search terms with contextual snippets showing where matches appear. +Use this function when you need to: +- Find specific concepts, keywords, or text within note contents +- Locate content matching multiple patterns or phrases +- Answer questions about what the user has written about a topic +- Search across both file names and file contents simultaneously +- Search for multiple related terms or variations in a single query`, parameters: { type: "object", properties: { diff --git a/AIClasses/FunctionDefinitions/Functions/SubmitPlan.ts b/AIClasses/FunctionDefinitions/Functions/SubmitPlan.ts new file mode 100644 index 0000000..03d9c93 --- /dev/null +++ b/AIClasses/FunctionDefinitions/Functions/SubmitPlan.ts @@ -0,0 +1,38 @@ +import { AIFunction } from "Enums/AIFunction"; +import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; + +export const SubmitPlan: IAIFunctionDefinition = { + name: AIFunction.SubmitPlan, + description: `Submits an execution plan with ordered, actionable steps. +Use this function after analyzing the goal and vault context to provide a structured +plan that will be followed step-by-step. Each step should be clear and executable.`, + + parameters: { + type: "object", + properties: { + steps: { + type: "array", + description: "Ordered array of execution steps. Each step represents an actionable task that moves toward the goal.", + items: { + type: "object", + properties: { + description: { + type: "string", + description: "Brief summary of what this step accomplishes (e.g., 'Search for ML notes', 'Create index file'). This is user-facing and should be concise." + }, + instruction: { + type: "string", + description: "Detailed instructions for executing this step. Should be specific enough to guide the execution without ambiguity. Examples: 'Search vault for all notes with tag #machine-learning using search_vault_files', 'Create new file ML-Index.md in /Research folder with heading structure', 'Update frontmatter in daily note 2024-01-15 to add tag #reviewed'" + }, + context: { + type: "string", + description: "Optional supporting context for this step. May include extracts from vault notes, relevant information gathered during planning, or other contextual details that will help execute this step effectively." + } + }, + required: ["description", "instruction"] + } + } + }, + required: ["steps"] + } + }; \ No newline at end of file diff --git a/AIClasses/FunctionDefinitions/Functions/WriteVaultFile.ts b/AIClasses/FunctionDefinitions/Functions/WriteVaultFile.ts index a7c21fe..945d7eb 100644 --- a/AIClasses/FunctionDefinitions/Functions/WriteVaultFile.ts +++ b/AIClasses/FunctionDefinitions/Functions/WriteVaultFile.ts @@ -4,11 +4,11 @@ import type { IAIFunctionDefinition } from "../IAIFunctionDefinition"; export const WriteVaultFile: IAIFunctionDefinition = { name: AIFunction.WriteVaultFile, description: `Writes content to a file, creating it if it doesn't exist or replacing its contents if it does. - - **When to use this tool:** - - Creating new notes, documents, or files from scratch - - Completely rewriting a file's contents (when most/all content needs to change) - - Generating new files from templates or structured data`, + +**When to use this tool:** +- Creating new notes, documents, or files from scratch +- Completely rewriting a file's contents (when most/all content needs to change) +- Generating new files from templates or structured data`, parameters: { type: "object", properties: { diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 4a12dcd..a158253 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -76,9 +76,7 @@ export class Gemini extends BaseAIClass { super(AIProvider.Gemini); } - public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean - ): AsyncGenerator { + public async* streamRequest(conversation: Conversation): AsyncGenerator { // next request should use web search only (gemini api doesn't support custom tooling and grounding at the same time) const requestWebSearch = this.accumulatedFunctionName == this.REQUEST_WEB_SEARCH; @@ -102,7 +100,7 @@ export class Gemini extends BaseAIClass { information, recent events, news, or facts that may have changed. After calling this, you will be able to perform web searches.`, }, - ...this.mapFunctionDefinitions(this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions)), + ...this.mapFunctionDefinitions(this.toolDefinitions), ] } @@ -110,7 +108,7 @@ export class Gemini extends BaseAIClass { system_instruction: { parts: [ { - text: this.aiPrompt.systemInstruction() + text: this.systemPrompt }, { text: `## IMPORTANT: Web Search Directive @@ -120,7 +118,7 @@ export class Gemini extends BaseAIClass { - Recent news, events, or happenings. - Up-to-date prices, statistics, or factual data that is dynamic. - Any information where "current," "latest," or "today's" is implied or explicitly requested. - + When you need current information from the web, you *must* follow these steps: 1. First call the \`request_web_search\` function with a clear and concise \`reasoning\` explaining why web search is needed. 2. After calling this, you will be given access to Google Search. @@ -128,7 +126,7 @@ export class Gemini extends BaseAIClass { 4. Subsequent interactions will revert to standard function calls or general assistance as appropriate.` }, { - text: await this.aiPrompt.userInstruction() + text: this.userInstruction } ] }, diff --git a/AIClasses/Gemini/GeminiConversationNamingService.ts b/AIClasses/Gemini/GeminiConversationNamingService.ts index 99a1248..caca391 100644 --- a/AIClasses/Gemini/GeminiConversationNamingService.ts +++ b/AIClasses/Gemini/GeminiConversationNamingService.ts @@ -3,7 +3,7 @@ import { Services } from "Services/Services"; import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider"; import { Role } from "Enums/Role"; -import { NamePrompt } from "AIClasses/NamePrompt"; +import { NamePrompt } from "AIPrompts/NamePrompt"; import type { GenerateContentResponse } from "@google/genai"; import type { SettingsService } from "Services/SettingsService"; import { Exception } from "Helpers/Exception"; diff --git a/AIClasses/IAIClass.ts b/AIClasses/IAIClass.ts index 9a979e1..59ef3bc 100644 --- a/AIClasses/IAIClass.ts +++ b/AIClasses/IAIClass.ts @@ -1,8 +1,13 @@ import type { IStreamChunk } from "Services/StreamingService"; import type { Conversation } from "Conversations/Conversation"; import type { Attachment } from "Conversations/Attachment"; +import type { IAIFunctionDefinition } from "./FunctionDefinitions/IAIFunctionDefinition"; export interface IAIClass { - streamRequest(conversation: Conversation, allowDestructiveActions: boolean): AsyncGenerator; + set systemPrompt(systemPrompt: string); + set userInstruction(userInstruction: string); + set toolDefinitions(toolDefinitions: IAIFunctionDefinition[]); + + streamRequest(conversation: Conversation): AsyncGenerator; formatBinaryFiles(attachments: Attachment[]): string; } \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 904c580..574e4e5 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -28,24 +28,20 @@ export class OpenAI extends BaseAIClass { super(AIProvider.OpenAI); } - public async* streamRequest( - conversation: Conversation, allowDestructiveActions: boolean - ): AsyncGenerator { + public async* streamRequest(conversation: Conversation): AsyncGenerator { // Refresh file cache only if conversation has attachments if (conversation.hasAttachments()) { await this.aiFileService.refreshCache(); } - const systemPrompt = await this.buildSystemPrompt(); + const systemPrompt = `${this.systemPrompt}\n\n${this.userInstruction}`; const input = await this.extractContents(conversation.contents); const tools = [{ type: "web_search" - }, ...this.mapFunctionDefinitions( - this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions) - )]; + }, ...this.mapFunctionDefinitions(this.toolDefinitions)]; const requestBody = { model: toProviderModel(this.settingsService.settings.model), diff --git a/AIClasses/OpenAI/OpenAIConversationNamingService.ts b/AIClasses/OpenAI/OpenAIConversationNamingService.ts index e7d09a4..5873ec2 100644 --- a/AIClasses/OpenAI/OpenAIConversationNamingService.ts +++ b/AIClasses/OpenAI/OpenAIConversationNamingService.ts @@ -3,7 +3,7 @@ import { Services } from "Services/Services"; import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; import { AIProvider, AIProviderURL, AIProviderModel } from "Enums/ApiProvider"; import { Role } from "Enums/Role"; -import { NamePrompt } from "AIClasses/NamePrompt"; +import { NamePrompt } from "AIPrompts/NamePrompt"; import type { SettingsService } from "Services/SettingsService"; import type OpenAI from "openai"; import { Exception } from "Helpers/Exception"; diff --git a/AIClasses/Schemas/AIFunctionSchemas.ts b/AIClasses/Schemas/AIFunctionSchemas.ts index 4b772d0..793ca4a 100644 --- a/AIClasses/Schemas/AIFunctionSchemas.ts +++ b/AIClasses/Schemas/AIFunctionSchemas.ts @@ -46,6 +46,32 @@ export const ListVaultFilesArgsSchema = z.object({ user_message: z.string() }); +export const CreatePlanArgsSchema = z.object({ + goal: z.string(), + context: z.string().optional(), + user_message: z.string() +}); + +export const ReplanArgsSchema = z.object({ + original_goal: z.string(), + completed_steps: z.string(), + issue_encountered: z.string(), + context: z.string(), + user_message: z.string() +}); + +export const CompleteStepArgsSchema = z.object({ + step_number: z.number() +}); + +export const SubmitPlanArgsSchema = z.object({ + steps: z.array(z.object({ + description: z.string(), + instruction: z.string(), + context: z.string().optional() + })) +}); + // Infer TypeScript types from schemas export type SearchVaultFilesArgs = z.infer; export type ReadVaultFilesArgs = z.infer; @@ -54,3 +80,7 @@ export type PatchVaultFileArgs = z.infer; export type DeleteVaultFilesArgs = z.infer; export type MoveVaultFilesArgs = z.infer; export type ListVaultFilesArgs = z.infer; +export type CreatePlanArgs = z.infer; +export type ReplanArgs = z.infer; +export type CompleteStepArgs = z.infer; +export type SubmitPlanArgs = z.infer; diff --git a/AIClasses/IPrompt.ts b/AIPrompts/IPrompt.ts similarity index 83% rename from AIClasses/IPrompt.ts rename to AIPrompts/IPrompt.ts index c3b5587..2bbfceb 100644 --- a/AIClasses/IPrompt.ts +++ b/AIPrompts/IPrompt.ts @@ -3,9 +3,11 @@ import { Services } from "Services/Services"; import { SystemInstruction } from "./SystemPrompt"; import type { FileSystemService } from "Services/FileSystemService"; import type { SettingsService } from "Services/SettingsService"; +import { PlanningAgentSystemPrompt } from "AIPrompts/PlanningAgentSystemPrompt"; export interface IPrompt { systemInstruction(): string; + planningInstruction(): string; userInstruction(): Promise; } @@ -23,6 +25,10 @@ export class AIPrompt implements IPrompt { return SystemInstruction; } + public planningInstruction(): string { + return PlanningAgentSystemPrompt; + } + public async userInstruction(): Promise { const result = await this.fileSystemService.readFile(this.settingsService.settings.userInstruction, true); return result instanceof Error ? "" : result; diff --git a/AIClasses/NamePrompt.ts b/AIPrompts/NamePrompt.ts similarity index 100% rename from AIClasses/NamePrompt.ts rename to AIPrompts/NamePrompt.ts diff --git a/AIPrompts/PlanningAgentSystemPrompt.ts b/AIPrompts/PlanningAgentSystemPrompt.ts new file mode 100644 index 0000000..fc71097 --- /dev/null +++ b/AIPrompts/PlanningAgentSystemPrompt.ts @@ -0,0 +1,197 @@ +import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; + +export const PlanningAgentSystemPrompt: string = ` +# Obsidian Vault Planning Agent + +You are a specialized planning agent within a multi-agent Obsidian vault assistant system. Your role is to analyze user requests, explore the vault's context, and create actionable, detailed plans that the main agent will execute. + +## Core Responsibilities + +### 1. Request Analysis +When you receive a planning request: +- Parse the user's intent and identify the core objective +- Determine the scope and complexity of the task +- Identify which vault operations and tools will be needed +- Consider dependencies between steps + +### 2. Adaptive Planning Strategy + +**Scale your planning effort to match query complexity:** + +| Complexity | Exploration | Plan Detail | Example | +|------------|-------------|-------------|---------| +| Simple | 1-2 searches | 2-3 steps | "Create note about X" | +| Moderate | 3-5 searches | 4-7 steps | "Organize notes on topic Y" | +| Complex | 6-10 searches | 8-15 steps | "Research and synthesize Z across vault" | +| Advanced | 10+ searches | 15+ steps with sub-plans | "Comprehensive vault restructuring" | + +### 3. Deep Contextual Analysis +**Before creating any plan, you MUST conduct thorough exploratory work:** + +- **Vault Exploration**: Search the vault comprehensively to understand: + - Existing relevant notes and their relationships + - User's writing style, terminology, and organizational patterns + - Naming conventions, folder structure, and tagging systems + - Related concepts through [[wiki-links]] and backlinks + +- **Knowledge Gap Analysis**: Identify what information exists vs. what's needed + +- **Pattern Recognition**: Detect user preferences from existing vault structure + +### 4. Progressive Search Methodology + +**NEVER accept a failed search as final. Execute progressive tiers:** + +**Tier 1 - Entity Extraction**: Extract key entities and search broadly +**Tier 2 - Regex Patterns**: Use case-insensitive, wildcard, and alternative patterns +**Tier 3 - Synonym Exploration**: Try variations, abbreviations, related terms +**Tier 4 - Contextual Inference**: Check tags, backlinks, folder structures, related notes +**Tier 5 - Cross-Reference**: Read found content to infer connections + +**Example Search Progression:** +1. Direct search: "machine learning" +2. Regex: \`/(machine.?learning|ML|neural)/i\` +3. Related: "AI", "deep learning", "models" +4. Context: Check [[AI]] note for ML mentions +5. Infer: Found in #technology tags or /projects/ai/ folder + +**CRITICAL**: Always perform necessary exploratory work FIRST. A plan based on actual vault state is infinitely better than assumptions. + +### 5. Plan Generation Principles + +**Atomic Steps**: Break down the objective into clear, single-responsibility steps +- Each step should have ONE clear action +- Steps should be ordered to respect dependencies +- Include conditional logic only when necessary + +**Failure Anticipation**: Build robustness into your plans +- Identify steps that might fail and why +- Suggest fallback strategies for critical operations +- Note when human intervention might be needed + +## Available Tools + +The main agent has access to the following vault operations. See the Appendix for complete parameter specifications. + +| Function | Purpose | +|----------|---------| +${AIFunctionDefinitions.compactSummaryForPlanningAgent()} + +**Important**: +- Always use exact function names from the table above +- Refer to the Appendix below for required parameters and detailed usage +- Each function requires a \`user_message\` parameter to explain the action to the user + +## Planning Architecture Patterns + +### For Simple Tasks (1-3 steps) +Use linear execution: +\`\`\` +1. Search vault for X +2. Extract information Y +3. Create note Z with findings +\`\`\` + +### For Medium Complexity (4-7 steps) +Use sequential execution with checkpoints: +\`\`\` +1. [Discovery] Search for related notes +2. [Discovery] Read and analyze key files +3. [Checkpoint] Verify sufficient information exists +4. [Synthesis] Extract and combine information +5. [Creation] Generate new content +6. [Validation] Verify output meets requirements +\`\`\` + +### For Complex Tasks (8+ steps) +Use phased execution with validation: +\`\`\` +Phase 1: Information Gathering +- Steps 1-3: Multi-angle vault searches +- Validation: Confirm data completeness + +Phase 2: Analysis +- Steps 4-6: Process and synthesize information +- Validation: Verify analysis quality + +Phase 3: Execution +- Steps 7-9: Create/modify vault content +- Validation: Confirm deliverables meet spec +\`\`\` + +## Obsidian Vault-Specific Considerations + +### Progressive Search Strategy +When planning searches, incorporate the multi-tier approach: +1. **Tier 1**: Direct entity/keyword search +2. **Tier 2**: Regex pattern matching for variations +3. **Tier 3**: Related content exploration (tags, backlinks) +4. **Tier 4**: Contextual inference from similar notes + +### Wiki-Link Integration +Plans should preserve and create knowledge graph connections: +- When referencing existing notes, use [[wiki-link]] notation +- When creating new notes, specify links to related content +- Plan for bidirectional linking where appropriate + +### File Type Handling +Account for different content types: +- **Text notes**: Can be searched, created, updated inline +- **Images/PDFs**: Must be read first, then referenced +- **Complex structures**: May need multi-step processing + +## Replanning Protocol + +If the main agent requests a replan: +1. Analyze the feedback provided +2. Identify what went wrong and why +3. Perform additional exploratory work if needed +4. Generate an updated plan addressing the issues + +DO NOT simply retry the same approach—learn from the failure. + +## Quality Checklist + +Before returning any plan, verify: +- [ ] Have I explored the vault to inform this plan? +- [ ] Is each step atomic and clearly defined? +- [ ] Are tool names and parameters exact and correct? +- [ ] Do step dependencies make logical sense? +- [ ] Have I anticipated likely failure modes? +- [ ] Does the plan preserve Obsidian's knowledge graph through wiki-links? +- [ ] Is the output structure valid and complete? + +## Anti-Patterns to Avoid + +❌ Planning without vault exploration—always search first +❌ Ignoring failure modes—plan for things going wrong +❌ Over-complex plans for simple tasks—match complexity to need +❌ Micromanaging execution instead of providing actionable guidance +❌ Missing wiki-link opportunities—always preserve knowledge graph +❌ Planning steps that don't align with available tools + +## Example Planning Flow + +**User Request**: "Create a summary of all my machine learning notes" + +**Your Process**: +1. Search vault to find ML-related notes +2. Analyze the results to understand scope (10 notes? 100?) +3. Read a sample to understand structure/content +4. Design a plan that: + - Searches comprehensively for ML notes + - Reads each note systematically + - Extracts key concepts and connections + - Synthesizes findings + - Creates a new summary note with proper wiki-links + +**Remember**: You are the strategic intelligence of the system. The main agent executes; you ensure it executes optimally. + +--- + +## Appendix: Complete Tool Specifications + +Below are the complete function definitions available to the main agent, specified in JSON format as per Anthropic's documentation standards. + +${AIFunctionDefinitions.detailedAppendixForPlanningAgent()} +`; \ No newline at end of file diff --git a/AIClasses/SystemPrompt.ts b/AIPrompts/SystemPrompt.ts similarity index 51% rename from AIClasses/SystemPrompt.ts rename to AIPrompts/SystemPrompt.ts index 2cce678..5a1676d 100644 --- a/AIClasses/SystemPrompt.ts +++ b/AIPrompts/SystemPrompt.ts @@ -31,7 +31,92 @@ User: "Create a note about today's meeting with Sarah" ❌ Wrong: "I can create a note for you. Would you like me to proceed?" ✅ Correct: [Immediately calls write_vault_file with appropriate content] -### 2. Historical Context Interpretation +### 2. PLAN-THEN-EXECUTE ARCHITECTURE + +**For complex tasks, separate strategic planning from tactical execution.** + +You operate within a plan-and-execute architecture that improves task completion by: +- **Explicit long-term planning**: Thinking through all steps required before acting +- **Reduced cognitive load**: Allowing each step to focus on a narrow, well-defined objective +- **Adaptive replanning**: Adjusting strategy when execution reveals new information or obstacles + +#### When to Request Planning + +**Request strategic planning for tasks that exhibit these characteristics:** + +| Characteristic | Examples | +|----------------|----------| +| Multi-step coordination | "Reorganize my project notes by topic and create a summary index" | +| Uncertain vault structure | "Find everything related to my startup idea and compile a report" | +| Multiple vault areas | "Cross-reference my reading notes with my project plans" | +| Dependency chains | "Create a content calendar based on my draft ideas" | +| Research synthesis | "Give me an overview of all my notes on machine learning" | +| Unclear optimal approach | "Help me prepare for my quarterly review using my vault" | + +**Do NOT request planning for:** +- Single-step operations (create one note, search for a term, read a file) +- Clear, direct requests with obvious execution paths +- Simple queries answerable from existing knowledge or a single search +- Operations where you can immediately see the complete path to success + +#### Decision Framework: Plan or Execute? + +Ask yourself: +1. **Can I complete this in 1-3 tool calls?** → Execute directly +2. **Do I need to explore the vault structure first?** → Consider planning +3. **Are there dependencies between steps?** → Consider planning +4. **Could the optimal approach vary based on what I find?** → Consider planning +5. **Is this a routine operation I've done before?** → Execute directly + +**Default to direct execution. Elevate to planning only when complexity warrants it.** + +#### Planning Workflow + +When strategic guidance is needed: + +1. **Request a plan** with a clear goal description and relevant context +2. **Receive structured steps** from the planning agent, including: + - Ordered sequence of actions + - Dependencies between steps + - Success criteria for each step + - Potential failure modes and recovery strategies +3. **Execute steps sequentially**, gathering ground truth at each stage +4. **Monitor progress** against the plan's success criteria +5. **Request replanning** if conditions change or obstacles emerge + +### 3. ADAPTIVE REPLANNING + +**Plans are hypotheses. Reality provides the test.** + +Execution often reveals information that wasn't available during planning. Effective agents recognize when plans need adjustment rather than blindly following outdated instructions. + +#### When to Request Replanning + +| Trigger | Example Situation | +|---------|-------------------| +| **Unexpected results** | Search returned no results; expected folder structure doesn't exist | +| **Failed prerequisites** | A note that should exist doesn't; permissions or access issues | +| **Changed requirements** | User provides clarification that shifts the goal | +| **Partial success** | Some steps completed but remaining steps are now invalid | +| **Incorrect assumptions** | Plan assumed certain vault structure that doesn't match reality | +| **New information** | Discovered content that suggests a better approach | + +#### When NOT to Replan + +- **Minor adjustments**: If you can adapt without strategic guidance, do so +- **Simple retries**: If an operation failed but can succeed on retry +- **Completed tasks**: Don't replan for a new, unrelated goal (request a fresh plan instead) +- **Cosmetic issues**: Formatting or minor output adjustments don't need replanning + +#### Replanning Best Practices + +When requesting a replan: +1. **Summarize completed work** clearly so it can be preserved +2. **Diagnose the issue** specifically—what went wrong or changed? +3. **Provide new context** discovered during execution +4. **Maintain goal continuity** to ensure the original intent is honored + +### 4. HISTORICAL CONTEXT INTERPRETATION **Tool call history from previous sessions may appear with HTML comment markers.** @@ -43,12 +128,13 @@ These represent completed actions—NOT patterns to reproduce: If you see JSON preceded by "Historical tool call/result", it documents a past action. Use your native function calling for new operations. -### 3. Request Completion +### 5. Request Completion - Execute ALL necessary operations before concluding your turn - Ensure the user's complete request is fulfilled, not just the first step - For multi-step tasks, gather all information before presenting findings +- When executing a plan, complete all steps or explicitly pause at decision points -### 4. Wiki-Link Everything from the Vault +### 6. Wiki-Link Everything from the Vault **ALWAYS use [[wiki-link]] notation when referencing any information from the user's notes.** - Every mention of a note, concept, person, or topic from the vault must be linked @@ -60,7 +146,7 @@ Examples: - "[[Sarah]] mentioned this in her meeting with [[John]]" - "This relates to your ideas about [[Machine Learning]] in [[Research Notes]]" -### 5. Vault-First Decision Framework +### 7. Vault-First Decision Framework **The cost of an unnecessary search is negligible. Missing relevant information is costly.** @@ -124,25 +210,37 @@ When searches return or reference images or PDFs: ## Multi-Tool Workflow Architecture -### Planning Phase (for Complex Queries) +### Complexity Assessment -Before executing complex queries: -1. **Intent Analysis**: Determine scope and complexity -2. **Query Decomposition**: Break into searchable components -3. **Strategy Design**: Plan search progression -4. **Success Criteria**: Define what "complete" looks like +Before executing, assess task complexity to determine the appropriate approach: -### Workflow Scaling +| Complexity | Indicators | Approach | +|------------|-----------|----------| +| **Simple** | Single operation, clear target, 1-3 tool calls | Execute directly | +| **Moderate** | Multiple searches, some ambiguity, 4-7 tool calls | Execute with fallback strategies | +| **Complex** | Multi-phase, dependencies, exploration needed, 8-15 tool calls | Request strategic planning | +| **Research** | Synthesis across many sources, 15+ tool calls | Request planning with research focus | -| Complexity | Operations | Example | Strategy | -|------------|-----------|---------|----------| -| Simple | 1-3 | "What did I write about React?" | Entity → Search → Done | -| Moderate | 4-7 | "Find Docker and K8s notes" | Search → Regex fallback → Domain expansion | -| Complex | 8-15 | "Compare microservices vs monoliths" | Full planning → Multi-search → Synthesis | -| Research | 15+ | "Overview of my ML journey" | Comprehensive multi-source analysis | +### Direct Execution (Simple & Moderate Tasks) + +For straightforward operations: +1. **Intent Analysis**: Understand what the user needs +2. **Immediate Action**: Execute the appropriate tool calls +3. **Progressive Fallback**: If initial approach fails, try alternatives +4. **Complete Delivery**: Present findings with proper wiki-links + +### Planned Execution (Complex & Research Tasks) + +For tasks requiring coordination: +1. **Request Planning**: Provide goal, context, and any known constraints +2. **Receive Plan**: Get structured steps with dependencies and success criteria +3. **Execute Sequentially**: Work through steps, gathering ground truth +4. **Monitor & Adapt**: Check progress; request replan if needed +5. **Synthesize Results**: Integrate findings across all steps ### Synthesis Phase +After multi-step execution: 1. **Information Integration**: Combine results from all search attempts 2. **Relationship Mapping**: Identify connections between sources 3. **Universal Wiki-Linking**: Apply [[wiki-links]] to ALL vault references @@ -179,19 +277,25 @@ Before executing complex queries: ❌ Noting that a PDF/image exists without reading its contents when relevant ❌ Asking users to describe images instead of reading them yourself ❌ Saying "I cannot see/interpret images" +❌ Requesting planning for simple, single-step operations +❌ Blindly following a plan when execution reveals it's no longer valid +❌ Replanning for minor issues you can handle directly ## Decision Framework **Always ask yourself:** -1. "Have I completed the user's request?" → Reflect on what can still be achieved -2. "Am I using [[wiki-links]] for every vault reference?" → Always required -3. "Could this information exist in the user's notes?" → Search vault first -4. "Did my search fail? Have I tried all progressive tiers?" → Keep searching -5. "Can I infer the answer from related content I found?" → Read and reason +1. "Is this simple enough to execute directly, or do I need strategic planning?" → Default to direct execution +2. "Have I completed the user's request?" → Reflect on what can still be achieved +3. "Am I using [[wiki-links]] for every vault reference?" → Always required +4. "Could this information exist in the user's notes?" → Search vault first +5. "Did my search fail? Have I tried all progressive tiers?" → Keep searching +6. "Can I infer the answer from related content I found?" → Read and reason +7. "Has something changed that invalidates my current plan?" → Consider replanning +8. "Am I adapting or do I need strategic guidance?" → Replan only for significant pivots -**When uncertain**: Always search the vault first. Always try alternative strategies before concluding "not found." +**When uncertain**: Always search the vault first. Always try alternative strategies before concluding "not found." Scale complexity to match the query. Complete the full request before concluding. --- -**Core Philosophy**: Act first, explain after. Always use [[wiki-links]] for vault references. Be proactive with vault searches using progressive strategies—never give up after the first attempt. Scale search complexity to match the query. Complete the full request before concluding. +**Core Philosophy**: Act first, explain after. Default to direct execution; elevate to planning only when task complexity warrants strategic coordination. Always use [[wiki-links]] for vault references. Be proactive with vault searches using progressive strategies—never give up after the first attempt. When executing plans, stay adaptive: replan when reality diverges from assumptions, but handle minor adjustments yourself. `; \ No newline at end of file diff --git a/Enums/AIFunction.ts b/Enums/AIFunction.ts index 6d9edd3..15623d2 100644 --- a/Enums/AIFunction.ts +++ b/Enums/AIFunction.ts @@ -11,6 +11,12 @@ export enum AIFunction { // only used by gemini RequestWebSearch = "request_web_search", + + // multi agent calls + CreatePlan = "create_plan", + Replan = "replan", + SubmitPlan = "submit_plan", + CompleteStep = "complete_step" } export function fromString(functionName: string): AIFunction { @@ -19,4 +25,8 @@ export function fromString(functionName: string): AIFunction { return enumValue as AIFunction; } Exception.throw(`Unknown function name: ${functionName}`); +} + +export function isAIFunction(value: unknown, aiFunction: AIFunction): value is AIFunction { + return value === aiFunction; } \ No newline at end of file diff --git a/Enums/Copy.ts b/Enums/Copy.ts index e363c22..9372000 100644 --- a/Enums/Copy.ts +++ b/Enums/Copy.ts @@ -1,3 +1,5 @@ +import { Exception } from "Helpers/Exception"; + export enum Copy { // General Copy UserInstructions1 = "You can create custom ", @@ -64,6 +66,41 @@ export enum Copy { AIThoughtMessage = "Thinking...", + // Execution Plan Messages + PlanningFailedError = "Planning failed. No execution plan was generated. Please consult with the user about how to proceed.", + StepDoesNotExistError = "Step {stepNumber} does not exist in the execution plan. Valid step numbers are 1-{totalSteps}.", + StepMustBeCompletedInOrderError = "Cannot complete step {stepNumber}. Step \"{incompletStep}\" is not yet completed. Steps must be completed in order.", + StepCompletedWithNextStep = "Step {stepNumber} completed successfully. Now proceed with step {nextStepNumber}: {nextStepDescription}", + AllStepsCompleted = "Step {stepNumber} completed successfully. All steps in the execution plan have been completed. Provide a final summary to the user based on the completed steps and overall success criteria.", + MaxPlanningIterationsReached = "I've attempted multiple planning iterations but encountered persistent issues completing the task. It may need to be broken down further or requires additional clarification.", + + // Execution Plan Request Templates + ContextTags = ` + +{context} +`, + ReplanRequestTemplate = `Plan execution has encountered an unexpected issue. Replan based on the following. + +### Original Goal +{originalGoal} + +### Completed Steps +{completedSteps} + +### Issue Encountered +{issueEncountered} + +### Additional Context +{context}`, + IncompleteExecutionRequestTemplate = `Plan execution stopped before all steps were completed. Review the execution history and create a revised plan to complete the remaining work. + +{completedSection} + +{remainingSection}`, + CompletedStepsHeader = "### Completed Steps", + RemainingStepsHeader = "### Remaining Steps", + NoSteps = "None", + // Help Modal Copy HelpModalAboutTitle = "About", HelpModalAboutContent = `#### About Vaultkeeper AI @@ -516,4 +553,44 @@ Preferred programming language: {{language}} --- **Remember:** A good system prompt is clear, dense, and easy to understand, leaving no room for misinterpretation. Start simple, test thoroughly, and refine based on real results.` +} + +/** + * Replaces placeholders in Copy strings with provided values. + * Placeholders are denoted by curly braces: {placeholderName} + * + * @param copyString - The Copy enum string containing placeholders + * @param replacements - Array of replacement values in the order they appear in the string + * @returns The string with all placeholders replaced + * + * @example + * replaceCopy(Copy.StepDoesNotExistError, ["5", "10"]) + * // Returns: "Step 5 does not exist in the execution plan. Valid step numbers are 1-10." + */ +export function replaceCopy(copyString: string, replacements: string[]): string { + const placeholderRegex = /\{[^}]+\}/g; + const placeholders = copyString.match(placeholderRegex); + + if (!placeholders) { + if (replacements.length > 0) { + Exception.log(`No placeholders found in copy string, but ${replacements.length} replacement(s) provided.`); + } + return copyString; + } + + if (placeholders.length !== replacements.length) { + Exception.log(`Placeholder count (${placeholders.length}) does not match replacement count (${replacements.length}). Using best effort.`); + } + + let result = copyString; + let replacementIndex = 0; + + result = result.replace(placeholderRegex, () => { + if (replacementIndex < replacements.length) { + return replacements[replacementIndex++]; + } + return placeholders[replacementIndex++]; // Return original placeholder if no replacement available + }); + + return result; } \ No newline at end of file diff --git a/Enums/ExecutionStatus.ts b/Enums/ExecutionStatus.ts new file mode 100644 index 0000000..a2ea757 --- /dev/null +++ b/Enums/ExecutionStatus.ts @@ -0,0 +1,5 @@ +export enum ExecutionStatus { + Pending = "pending", + Active = "active", + Completed = "completed" +} \ No newline at end of file diff --git a/Enums/FileType.ts b/Enums/FileType.ts index 83d4bb1..465c6b7 100644 --- a/Enums/FileType.ts +++ b/Enums/FileType.ts @@ -153,7 +153,7 @@ export function isKnownFileType(value: string): value is FileType { return Object.values(FileType).includes(value as FileType) && value !== FileType.UNKNOWN.toString(); } -export function isFileType(value: string, fileType: FileType) { +export function isFileType(value: unknown, fileType: FileType): value is FileType { return value === fileType.toString(); } diff --git a/Helpers/ResponseHelper.ts b/Helpers/ResponseHelper.ts new file mode 100644 index 0000000..bcdaca2 --- /dev/null +++ b/Helpers/ResponseHelper.ts @@ -0,0 +1,60 @@ +import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; + +// handle the rare event where a function call is also included in content (gemini sometimes does this) +export function sanitizeFunctionCallContent(content: string, functionCall: AIFunctionCall | null): string { + // Early returns for simple cases + if (!functionCall || !content.trim()) { + return content; + } + + // If content has no JSON-like characters, return as-is + if (!content.includes('{') || !content.includes('}')) { + return content; + } + + const functionCallString = functionCall.toConversationString(); + let sanitized = content; + + // Step 1: Remove markdown code blocks that might contain the function call + // Pattern matches ```json\n...\n``` or ```\n...\n``` + sanitized = sanitized.replace(/```(?:json)?\s*\n?([\s\S]*?)\n?```/g, (match: string, codeContent: string) => { + // If the code block contains our function call, remove it entirely + if (codeContent.trim() === functionCallString.trim()) { + return ''; + } + // Otherwise keep the code block + return match; + }); + + // Step 2: Remove exact JSON match (handles compact JSON) + sanitized = sanitized.replace(functionCallString, '').trim(); + + // Step 3: Handle pretty-printed variations by normalizing both strings + try { + const functionCallObj: unknown = JSON.parse(functionCallString); + const normalizedTarget = JSON.stringify(functionCallObj); + + // Find and remove any JSON that matches when normalized + // This regex finds JSON objects/arrays in the text + const jsonPattern = /\{(?:[^{}]|(?:\{(?:[^{}]|(?:\{[^{}]*\}))*\}))*\}|\[(?:[^[\]]|(?:\[(?:[^[\]]|(?:\[[^[\]]*\]))*\]))*\]/g; + + sanitized = sanitized.replace(jsonPattern, (match) => { + try { + const parsedMatch: unknown = JSON.parse(match); + const normalizedMatch = JSON.stringify(parsedMatch); + // Remove if it matches our function call when normalized + return normalizedMatch === normalizedTarget ? '' : match; + } catch { + // If it's not valid JSON, keep it + return match; + } + }); + } catch { + // If function call string isn't valid JSON, we've done what we can + } + + // Step 4: Clean up multiple consecutive whitespace/newlines left by removals + sanitized = sanitized.replace(/\n{3,}/g, '\n\n').trim(); + + return sanitized; +} \ No newline at end of file diff --git a/Services/AIControllerService.ts b/Services/AIControllerService.ts new file mode 100644 index 0000000..e9e6340 --- /dev/null +++ b/Services/AIControllerService.ts @@ -0,0 +1,351 @@ +import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; +import type { IAIClass } from "AIClasses/IAIClass"; +import { Services } from "./Services"; +import { Resolve } from "./DependencyService"; +import { Conversation } from "Conversations/Conversation"; +import type { IChatServiceCallbacks } from "./ChatService"; +import { ConversationContent } from "Conversations/ConversationContent"; +import { Role } from "Enums/Role"; +import type { AIFunctionService } from "./AIFunctionService"; +import { Copy, replaceCopy } from "Enums/Copy"; +import { sanitizeFunctionCallContent } from "Helpers/ResponseHelper"; +import type { IPrompt } from "AIPrompts/IPrompt"; +import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; +import { AIFunction, isAIFunction } from "Enums/AIFunction"; +import { AIFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionResponse"; +import { Exception } from "Helpers/Exception"; +import { CompleteStepArgsSchema, CreatePlanArgsSchema, ReplanArgsSchema, SubmitPlanArgsSchema, type CreatePlanArgs, type ReplanArgs } from "AIClasses/Schemas/AIFunctionSchemas"; +import { ExecutionPlan } from "Types/ExecutionPlan"; + +export class AIControllerService { + + private static readonly MAX_PLANNING_ITERATIONS = 3; + + private ai: IAIClass | undefined; + private readonly aiPrompt: IPrompt; + private readonly aiFunctionService: AIFunctionService; + + private planningConversation: Conversation; + + public constructor() { + this.aiPrompt = Resolve(Services.IPrompt); + this.aiFunctionService = Resolve(Services.AIFunctionService); + } + + public resolveAIProvider() { + this.ai = Resolve(Services.IAIClass); + } + + public async runMainAgent(conversation: Conversation, allowDestructiveActions: boolean, callbacks: IChatServiceCallbacks) { + if (!this.ai) { // this shouldn't ever happen + Exception.throw("Error: No AI provider has been set!"); + } + + // Setup initial prompts & tools + this.ai.systemPrompt = this.aiPrompt.systemInstruction(); + this.ai.userInstruction = await this.aiPrompt.userInstruction(); + this.ai.toolDefinitions = AIFunctionDefinitions.agentDefinitions(allowDestructiveActions); + + await this.runAgentLoop(conversation, callbacks, async (functionCall) => { + const functionCallName = functionCall.name; + if (isAIFunction(functionCallName, AIFunction.CreatePlan)) { + const completedSuccessfully = await this.handlePlanningWorkflow(conversation, functionCall, callbacks); + return { shouldExit: completedSuccessfully }; + } + + this.updateThought({ functionCall, shouldContinue: false }, callbacks); + const functionResponse = await this.aiFunctionService.performAIFunction(functionCall); + conversation.addFunctionResponse(functionResponse); + return { shouldExit: false }; + }); + } + + private async handlePlanningWorkflow(conversation: Conversation, functionCall: AIFunctionCall, callbacks: IChatServiceCallbacks): Promise { + if (!this.ai) { // this shouldn't ever happen + Exception.throw("Error: No AI provider has been set!"); + } + + const parseResult = CreatePlanArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + conversation.addFunctionResponse(new AIFunctionResponse( + functionCall.name, + { error: `Invalid arguments for ${AIFunction.CreatePlan}: ${parseResult.error.message}` }, + functionCall.toolId + )); + return false; // Return to main agent loop to handle the error + } + callbacks.onThoughtUpdate(parseResult.data.user_message); + + // Orchestrate planning and execution loop (handles replanning) + + let planningIteration = 0; + let continueExecution = true; + + this.planningConversation = new Conversation(); + this.planningConversation.contents.push(new ConversationContent({ + role: Role.User, + content: this.preparePlanRequest(parseResult.data) + })); + + while (continueExecution && planningIteration < AIControllerService.MAX_PLANNING_ITERATIONS) { + planningIteration++; + + // Run planning agent to get execution plan + this.ai.systemPrompt = this.aiPrompt.planningInstruction(); + this.ai.userInstruction = ""; // do not include user instruction + this.ai.toolDefinitions = AIFunctionDefinitions.planningAgentDefinitions(); + + const executionPlan = await this.runPlanningAgent(this.planningConversation, callbacks); + + // Run execution agent with the plan + this.ai.systemPrompt = this.aiPrompt.systemInstruction(); + this.ai.userInstruction = await this.aiPrompt.userInstruction(); + this.ai.toolDefinitions = AIFunctionDefinitions.agentExecutionDefinitions(); + + const executionResult = await this.runExecutionAgent(conversation, functionCall, executionPlan, callbacks); + + // Continue if either replanning was requested OR execution was incomplete + continueExecution = executionResult.shouldReplan || executionResult.isIncomplete; + + if (continueExecution && executionResult.replanData) { + // Agent explicitly requested replan with context + this.planningConversation.contents.push(new ConversationContent({ + role: Role.User, + content: this.prepareReplanRequest(executionResult.replanData) + })); + } else if (continueExecution && executionResult.isIncomplete) { + // Execution stopped prematurely - ask planner to revise + this.planningConversation.contents.push(new ConversationContent({ + role: Role.User, + content: this.prepareIncompleteExecutionRequest(executionPlan) + })); + } + } + + // Handle max iterations reached + if (planningIteration >= AIControllerService.MAX_PLANNING_ITERATIONS && continueExecution) { + conversation.contents.push(new ConversationContent({ + role: Role.Assistant, + content: Copy.MaxPlanningIterationsReached + })); + } + + return true; // Planning and execution completed - terminate main agent + } + + private async runPlanningAgent(planningConversation: Conversation, callbacks: IChatServiceCallbacks): Promise { + let capturedPlan: ExecutionPlan | null = null; + + await this.runAgentLoop(planningConversation, callbacks, async (functionCall) => { + const functionCallName = functionCall.name; + + if (isAIFunction(functionCallName, AIFunction.SubmitPlan)) { + const parseResult = SubmitPlanArgsSchema.safeParse(functionCall.arguments); + capturedPlan = parseResult.success + ? new ExecutionPlan(parseResult.data) + : new ExecutionPlan({ steps: [] }); + return { shouldExit: true }; // Exit once plan is submitted + } + + this.updateThought({ functionCall, shouldContinue: false }, callbacks); + const functionResponse = await this.aiFunctionService.performAIFunction(functionCall); + planningConversation.addFunctionResponse(functionResponse); + return { shouldExit: false }; + }); + + return capturedPlan ?? new ExecutionPlan({ steps: [] }); + } + + // The 'execution agent' is still the main agent but given specific tools related to plan execution + private async runExecutionAgent(conversation: Conversation, functionCall: AIFunctionCall, + executionPlan: ExecutionPlan, callbacks: IChatServiceCallbacks + ): Promise<{ shouldReplan: boolean, isIncomplete: boolean, replanData?: ReplanArgs }> { + // callback to ui with execution plan - gets reference to plan so auto updates when updated + + conversation.addFunctionResponse(new AIFunctionResponse( + functionCall.name, + executionPlan.toFunctionResponse(), + functionCall.toolId + )); + + let replanData: ReplanArgs | undefined; + + await this.runAgentLoop(conversation, callbacks, async (functionCall) => { + const functionCallName = functionCall.name; + + if (isAIFunction(functionCallName, AIFunction.Replan)) { + const parseResult = ReplanArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + conversation.addFunctionResponse(new AIFunctionResponse( + functionCallName, + { error: `Invalid arguments for ${AIFunction.Replan}: ${parseResult.error.message}` }, + functionCall.toolId + )); + return { shouldExit: false }; + } + // Capture replan data and exit execution loop to trigger replanning + replanData = parseResult.data; + return { shouldExit: true }; + } + + if (isAIFunction(functionCallName, AIFunction.CompleteStep)) { + const parseResult = CompleteStepArgsSchema.safeParse(functionCall.arguments); + if (!parseResult.success) { + conversation.addFunctionResponse(new AIFunctionResponse( + functionCallName, + { error: `Invalid arguments for ${AIFunction.CompleteStep}: ${parseResult.error.message}` }, + functionCall.toolId + )); + return { shouldExit: false }; + } + const functionResponse = new AIFunctionResponse( + functionCallName, + executionPlan.completeExecutionStep(parseResult.data.step_number), + functionCall.toolId + ); + conversation.addFunctionResponse(functionResponse); + return { shouldExit: false }; + } + + this.updateThought({ functionCall, shouldContinue: false }, callbacks); + const functionResponse = await this.aiFunctionService.performAIFunction(functionCall); + conversation.addFunctionResponse(functionResponse); + return { shouldExit: false }; + }); + + const isIncomplete = !executionPlan.completed(); + return { shouldReplan: replanData !== undefined, isIncomplete, replanData }; + } + + private async runAgentLoop(conversation: Conversation,callbacks: IChatServiceCallbacks, + handleFunctionCall: (functionCall: AIFunctionCall) => Promise<{ shouldExit: boolean }> + ): Promise { + let response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks); + + while (response.functionCall || response.shouldContinue) { + if (response.functionCall) { + const result = await handleFunctionCall(response.functionCall); + if (result.shouldExit) { + return; + } + } else { + callbacks.onThoughtUpdate(Copy.AIThoughtMessage); + } + + response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), callbacks); + } + } + + private updateThought(response: { functionCall: AIFunctionCall | null, shouldContinue: boolean }, callbacks: IChatServiceCallbacks) { + const userMessage = response.functionCall?.arguments.user_message; + if (userMessage && typeof userMessage === "string") { + callbacks.onThoughtUpdate(userMessage); + } + } + + private ensureCorrectConversationStructure(conversation: Conversation): Conversation { + // Check if the last message is from the assistant to prevent assistant-to-assistant structure + // This can happen when the assistant's last message had no function call and the user sends a new request + if (conversation.contents.length > 0) { + const lastMessage = conversation.contents[conversation.contents.length - 1]; + if (lastMessage.role === Role.Assistant) { + // Insert a hidden "Continue" message to maintain proper conversation structure + conversation.contents.push(ConversationContent.safeContinue()); + } + } + return conversation; + } + + private async streamRequestResponse(conversation: Conversation, callbacks: IChatServiceCallbacks + ): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> { + if (!this.ai) { // this should never happen + return { functionCall: null, shouldContinue: false }; + } + + const conversationContent = new ConversationContent({ role: Role.Assistant }); + conversation.contents.push(conversationContent); + + let accumulatedContent = ""; + let capturedFunctionCall: AIFunctionCall | null = null; + let capturedShouldContinue = false; + + for await (const chunk of this.ai.streamRequest(conversation)) { + if (chunk.error && chunk.errorType) { + conversationContent.content = chunk.error; + conversationContent.errorType = chunk.errorType; + callbacks.onStreamingUpdate(null); + break; + } + + if (chunk.functionCall) { + capturedFunctionCall = chunk.functionCall; + } + + if (chunk.shouldContinue) { + capturedShouldContinue = true; + } + + if (chunk.content) { + accumulatedContent += chunk.content; + + conversationContent.content = accumulatedContent; + if (accumulatedContent.trim() !== "") { + callbacks.onThoughtUpdate(null); + } + } + + if (chunk.isComplete) { + const sanitizedContent = sanitizeFunctionCallContent(accumulatedContent, capturedFunctionCall); + + if (sanitizedContent.trim() === "" && !capturedFunctionCall) { + conversation.contents.pop(); + } else { + conversationContent.content = sanitizedContent; + if (capturedFunctionCall) { + conversationContent.functionCall = capturedFunctionCall.toConversationString(); + if (capturedFunctionCall.thoughtSignature) { + conversationContent.thoughtSignature = capturedFunctionCall.thoughtSignature; + } + } + } + } + + if (conversationContent.content?.trim() !== "") { + callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString()); + } + } + + callbacks.onStreamingUpdate(null); + + return { functionCall: capturedFunctionCall, shouldContinue: capturedShouldContinue }; + } + + private preparePlanRequest(input: CreatePlanArgs): string { + const context = input.context ? replaceCopy(Copy.ContextTags, [input.context]) : ""; + return input.goal + context; + } + + private prepareReplanRequest(input: ReplanArgs): string { + return replaceCopy(Copy.ReplanRequestTemplate, [ + input.original_goal, + input.completed_steps, + input.issue_encountered, + input.context + ]); + } + + private prepareIncompleteExecutionRequest(executionPlan: ExecutionPlan): string { + const { completed, remaining } = executionPlan.getStatusSummary(); + const completedSection = completed.length > 0 + ? `${Copy.CompletedStepsHeader}\n${completed.join("\n")}` + : `${Copy.CompletedStepsHeader}\n${Copy.NoSteps}`; + const remainingSection = remaining.length > 0 + ? `${Copy.RemainingStepsHeader}\n${remaining.join("\n")}` + : `${Copy.RemainingStepsHeader}\n${Copy.NoSteps}`; + + return replaceCopy(Copy.IncompleteExecutionRequestTemplate, [ + completedSection, + remainingSection + ]); + } +} \ No newline at end of file diff --git a/Services/AIFunctionService.ts b/Services/AIFunctionService.ts index b37b46a..39c0550 100644 --- a/Services/AIFunctionService.ts +++ b/Services/AIFunctionService.ts @@ -1,7 +1,7 @@ import { Resolve } from "./DependencyService"; import { Services } from "./Services"; import type { FileSystemService } from "./FileSystemService"; -import { AIFunction } from "Enums/AIFunction"; +import { AIFunction, fromString } from "Enums/AIFunction"; import { AIFunctionResponse } from "AIClasses/FunctionDefinitions/AIFunctionResponse"; import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import type { ISearchMatch } from "../Helpers/SearchTypes"; @@ -37,7 +37,7 @@ export class AIFunctionService { if (!parseResult.success) { return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for SearchVaultFiles: ${parseResult.error.message}` }, + { error: `Invalid arguments for ${AIFunction.SearchVaultFiles}: ${parseResult.error.message}` }, functionCall.toolId ); } @@ -49,7 +49,7 @@ export class AIFunctionService { if (!parseResult.success) { return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for ReadVaultFiles: ${parseResult.error.message}` }, + { error: `Invalid arguments for ${AIFunction.ReadVaultFiles}: ${parseResult.error.message}` }, functionCall.toolId ); } @@ -61,7 +61,7 @@ export class AIFunctionService { if (!parseResult.success) { return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for WriteVaultFile: ${parseResult.error.message}` }, + { error: `Invalid arguments for ${AIFunction.WriteVaultFile}: ${parseResult.error.message}` }, functionCall.toolId ); } @@ -73,7 +73,7 @@ export class AIFunctionService { if (!parseResult.success) { return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for PatchVaultFile: ${parseResult.error.message}` }, + { error: `Invalid arguments for ${AIFunction.PatchVaultFile}: ${parseResult.error.message}` }, functionCall.toolId ); } @@ -85,7 +85,7 @@ export class AIFunctionService { if (!parseResult.success) { return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for DeleteVaultFiles: ${parseResult.error.message}` }, + { error: `Invalid arguments for ${AIFunction.DeleteVaultFiles}: ${parseResult.error.message}` }, functionCall.toolId ); } @@ -97,7 +97,7 @@ export class AIFunctionService { if (!parseResult.success) { return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for MoveVaultFiles: ${parseResult.error.message}` }, + { error: `Invalid arguments for ${AIFunction.MoveVaultFiles}: ${parseResult.error.message}` }, functionCall.toolId ); } @@ -109,22 +109,32 @@ export class AIFunctionService { if (!parseResult.success) { return new AIFunctionResponse( functionCall.name, - { error: `Invalid arguments for ListVaultFiles: ${parseResult.error.message}` }, + { error: `Invalid arguments for ${AIFunction.ListVaultFiles}: ${parseResult.error.message}` }, functionCall.toolId ); } return new AIFunctionResponse(functionCall.name, await this.ListVaultFiles(parseResult.data.path, parseResult.data.recursive), functionCall.toolId); } - // this is only used by gemini + // This is only used by gemini case AIFunction.RequestWebSearch: return new AIFunctionResponse(functionCall.name, {}, functionCall.toolId) - + + // multi-agent functions are handled elsewhere - this shouldn't ever get hit + case AIFunction.CreatePlan: + case AIFunction.Replan: + case AIFunction.SubmitPlan: + case AIFunction.CompleteStep: { + Exception.throw(`Multi-agent function ${functionCall.name} should not be handled by AIFunctionService`); + break; + } + default: { - const error = `Unknown function request ${functionCall.name as string}` + const functionCallName = fromString(functionCall.name); + const error = `Unknown function request ${functionCallName}` Exception.log(error); return new AIFunctionResponse( - functionCall.name, + functionCallName, { error: error }, functionCall.toolId ); diff --git a/Services/AbortService.ts b/Services/AbortService.ts index 49fc44d..cf7abe1 100644 --- a/Services/AbortService.ts +++ b/Services/AbortService.ts @@ -9,7 +9,7 @@ export class AbortService { public reset = () => this.initialiseAbortController(); // semantic alias for initialiseAbortController public initialiseAbortController(): void { - this.abortController.abort(); + this.abort(); this.abortController = new AbortController(); } diff --git a/Services/ChatService.ts b/Services/ChatService.ts index fcf84ac..ba10a55 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -1,23 +1,20 @@ import { Semaphore } from "Helpers/Semaphore"; import { Resolve } from "./DependencyService"; import { Services } from "./Services"; -import type { IAIClass } from "AIClasses/IAIClass"; import type { ConversationFileSystemService } from "./ConversationFileSystemService"; -import type { AIFunctionService } from "./AIFunctionService"; import type { ConversationNamingService } from "./ConversationNamingService"; import { Conversation } from "Conversations/Conversation"; import { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; -import type { AIFunctionCall } from "AIClasses/AIFunctionCall"; import { Notice } from "obsidian"; import type { EventService } from "./EventService"; import { Event } from "Enums/Event"; import { AbortService } from "./AbortService"; import { Exception } from "Helpers/Exception"; -import { Copy } from "Enums/Copy"; import type { Attachment } from "Conversations/Attachment"; import { Reference } from "Conversations/Reference"; import type { WorkSpaceService } from "./WorkSpaceService"; +import type { AIControllerService } from "./AIControllerService"; export interface IChatServiceCallbacks { onSubmit: () => void; @@ -28,9 +25,9 @@ export interface IChatServiceCallbacks { } export class ChatService { - private ai: IAIClass | undefined; + + private aiControllerService: AIControllerService; private conversationService: ConversationFileSystemService; - private aiFunctionService: AIFunctionService; private namingService: ConversationNamingService; private workSpaceService: WorkSpaceService; private eventService: EventService; @@ -40,8 +37,8 @@ export class ChatService { private semaphoreHeld: boolean = false; constructor() { + this.aiControllerService = Resolve(Services.AIControllerService); this.conversationService = Resolve(Services.ConversationFileSystemService); - this.aiFunctionService = Resolve(Services.AIFunctionService); this.namingService = Resolve(Services.ConversationNamingService); this.workSpaceService = Resolve(Services.WorkSpaceService); this.eventService = Resolve(Services.EventService); @@ -51,10 +48,6 @@ export class ChatService { public onNameChanged: ((name: string) => void) | undefined = undefined; - public resolveAIProvider() { - this.ai = Resolve(Services.IAIClass); - } - public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, attachments: Attachment[], callbacks: IChatServiceCallbacks) { if (!await this.semaphore.wait()) { return; @@ -100,23 +93,7 @@ export class ChatService { await this.namingService.requestName(conversation, formattedRequest, this.onNameChanged); } - // Process AI responses and function calls - let response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), allowDestructiveActions, callbacks); - while (response.functionCall || response.shouldContinue) { - if (response.functionCall) { - const userMessage = response.functionCall.arguments.user_message; - if (userMessage && typeof userMessage === "string") { - callbacks.onThoughtUpdate(userMessage); - } - - const functionResponse = await this.aiFunctionService.performAIFunction(response.functionCall); - conversation.addFunctionResponse(functionResponse); - } else { - callbacks.onThoughtUpdate(Copy.AIThoughtMessage); - } - - response = await this.streamRequestResponse(this.ensureCorrectConversationStructure(conversation), allowDestructiveActions, callbacks); - } + await this.aiControllerService.runMainAgent(conversation, allowDestructiveActions, callbacks); }); } catch (error) { if (AbortService.isAbortError(error)) { @@ -142,152 +119,15 @@ export class ChatService { this.eventService.trigger(Event.DiffClosed); } + private requestWithContext(request: string) { + const activeFile = this.workSpaceService.getActiveFile(); + return activeFile ? `${request}\nUser current active file: "${activeFile.path}"` : request; + } + private async saveConversation(conversation: Conversation) { const result = await this.conversationService.saveConversation(conversation); if (result instanceof Error) { new Notice(`Failed to save conversation data for '${conversation.title}'`); } } - - private ensureCorrectConversationStructure(conversation: Conversation): Conversation { - // Check if the last message is from the assistant to prevent assistant-to-assistant structure - // This can happen when the assistant's last message had no function call and the user sends a new request - if (conversation.contents.length > 0) { - const lastMessage = conversation.contents[conversation.contents.length - 1]; - if (lastMessage.role === Role.Assistant) { - // Insert a hidden "Continue" message to maintain proper conversation structure - conversation.contents.push(ConversationContent.safeContinue()); - } - } - return conversation; - } - - private async streamRequestResponse( - conversation: Conversation, allowDestructiveActions: boolean, callbacks: IChatServiceCallbacks - ): Promise<{ functionCall: AIFunctionCall | null, shouldContinue: boolean }> { - if (!this.ai) { // this should never happen - return { functionCall: null, shouldContinue: false };; - } - - const conversationContent = new ConversationContent({ role: Role.Assistant }); - conversation.contents.push(conversationContent); - - let accumulatedContent = ""; - let capturedFunctionCall: AIFunctionCall | null = null; - let capturedShouldContinue = false; - - for await (const chunk of this.ai.streamRequest(conversation, allowDestructiveActions)) { - if (chunk.error && chunk.errorType) { - conversationContent.content = chunk.error; - conversationContent.errorType = chunk.errorType; - callbacks.onStreamingUpdate(null); - break; - } - - if (chunk.functionCall) { - capturedFunctionCall = chunk.functionCall; - } - - if (chunk.shouldContinue) { - capturedShouldContinue = true; - } - - if (chunk.content) { - accumulatedContent += chunk.content; - - conversationContent.content = accumulatedContent; - if (accumulatedContent.trim() !== "") { - callbacks.onThoughtUpdate(null); - } - } - - if (chunk.isComplete) { - const sanitizedContent = this.sanitizeFunctionCallContent(accumulatedContent, capturedFunctionCall); - - if (sanitizedContent.trim() === "" && !capturedFunctionCall) { - conversation.contents.pop(); - } else { - conversationContent.content = sanitizedContent; - if (capturedFunctionCall) { - conversationContent.functionCall = capturedFunctionCall.toConversationString(); - if (capturedFunctionCall.thoughtSignature) { - conversationContent.thoughtSignature = capturedFunctionCall.thoughtSignature; - } - } - } - } - - if (conversationContent.content?.trim() !== "") { - callbacks.onStreamingUpdate(conversationContent.timestamp.getTime().toString()); - } - } - - callbacks.onStreamingUpdate(null); - - return { functionCall: capturedFunctionCall, shouldContinue: capturedShouldContinue }; - } - - // handle the rare event where a function call is also included in content (gemini sometimes does this) - private sanitizeFunctionCallContent(content: string, functionCall: AIFunctionCall | null): string { - // Early returns for simple cases - if (!functionCall || !content.trim()) { - return content; - } - - // If content has no JSON-like characters, return as-is - if (!content.includes('{') || !content.includes('}')) { - return content; - } - - const functionCallString = functionCall.toConversationString(); - let sanitized = content; - - // Step 1: Remove markdown code blocks that might contain the function call - // Pattern matches ```json\n...\n``` or ```\n...\n``` - sanitized = sanitized.replace(/```(?:json)?\s*\n?([\s\S]*?)\n?```/g, (match: string, codeContent: string) => { - // If the code block contains our function call, remove it entirely - if (codeContent.trim() === functionCallString.trim()) { - return ''; - } - // Otherwise keep the code block - return match; - }); - - // Step 2: Remove exact JSON match (handles compact JSON) - sanitized = sanitized.replace(functionCallString, '').trim(); - - // Step 3: Handle pretty-printed variations by normalizing both strings - try { - const functionCallObj: unknown = JSON.parse(functionCallString); - const normalizedTarget = JSON.stringify(functionCallObj); - - // Find and remove any JSON that matches when normalized - // This regex finds JSON objects/arrays in the text - const jsonPattern = /\{(?:[^{}]|(?:\{(?:[^{}]|(?:\{[^{}]*\}))*\}))*\}|\[(?:[^[\]]|(?:\[(?:[^[\]]|(?:\[[^[\]]*\]))*\]))*\]/g; - - sanitized = sanitized.replace(jsonPattern, (match) => { - try { - const parsedMatch: unknown = JSON.parse(match); - const normalizedMatch = JSON.stringify(parsedMatch); - // Remove if it matches our function call when normalized - return normalizedMatch === normalizedTarget ? '' : match; - } catch { - // If it's not valid JSON, keep it - return match; - } - }); - } catch { - // If function call string isn't valid JSON, we've done what we can - } - - // Step 4: Clean up multiple consecutive whitespace/newlines left by removals - sanitized = sanitized.replace(/\n{3,}/g, '\n\n').trim(); - - return sanitized; - } - - private requestWithContext(request: string) { - const activeFile = this.workSpaceService.getActiveFile(); - return activeFile ? `${request}\nUser current active file: "${activeFile.path}"` : request; - } } diff --git a/Services/ServiceRegistration.ts b/Services/ServiceRegistration.ts index 0d34a0a..ec9df07 100644 --- a/Services/ServiceRegistration.ts +++ b/Services/ServiceRegistration.ts @@ -2,7 +2,7 @@ import { AIProvider, fromModel } from "Enums/ApiProvider"; import type VaultkeeperAIPlugin from "main"; import { RegisterSingleton, RegisterTransient, Resolve } from "./DependencyService"; import { Services } from "./Services"; -import { AIPrompt, type IPrompt } from "AIClasses/IPrompt"; +import { AIPrompt, type IPrompt } from "AIPrompts/IPrompt"; import type { IAIClass } from "AIClasses/IAIClass"; import type { IConversationNamingService } from "AIClasses/IConversationNamingService"; import { Gemini } from "AIClasses/Gemini/Gemini"; @@ -13,7 +13,6 @@ import { ConversationFileSystemService } from "./ConversationFileSystemService"; import { ConversationHistoryModal } from "Modals/ConversationHistoryModal"; import { AIFunctionService } from "./AIFunctionService"; import { StreamingService } from "./StreamingService"; -import { AIFunctionDefinitions } from "AIClasses/FunctionDefinitions/AIFunctionDefinitions"; import { WorkSpaceService } from "./WorkSpaceService"; import { ChatService } from "./ChatService"; import { ConversationNamingService } from "./ConversationNamingService"; @@ -37,6 +36,7 @@ import type { IAIFileService } from "AIClasses/IAIFileService"; import { ClaudeFileService } from "AIClasses/Claude/ClaudeFileService"; import { GeminiFileService } from "AIClasses/Gemini/GeminiFileService"; import { OpenAIFileService } from "AIClasses/OpenAI/OpenAIFileService"; +import { AIControllerService } from "./AIControllerService"; export async function RegisterPlugin(plugin: VaultkeeperAIPlugin) { RegisterSingleton(Services.VaultkeeperAIPlugin, plugin); @@ -59,8 +59,8 @@ export function RegisterDependencies() { RegisterSingleton(Services.ConversationNamingService, new ConversationNamingService()); RegisterSingleton(Services.IPrompt, new AIPrompt()); - RegisterSingleton(Services.AIFunctionDefinitions, new AIFunctionDefinitions()); RegisterSingleton(Services.AIFunctionService, new AIFunctionService()); + RegisterSingleton(Services.AIControllerService, new AIControllerService()); RegisterSingleton(Services.StreamingService, new StreamingService()); RegisterSingleton(Services.ChatService, new ChatService()); @@ -91,7 +91,7 @@ export function RegisterAiProvider() { RegisterSingleton(Services.IConversationNamingService, new OpenAIConversationNamingService()); } - Resolve(Services.ChatService).resolveAIProvider(); + Resolve(Services.AIControllerService).resolveAIProvider(); Resolve(Services.ConversationNamingService).resolveNamingProvider(); Resolve(Services.ConversationFileSystemService).resolveAIFileService(); } diff --git a/Services/Services.ts b/Services/Services.ts index ce0a4de..93fd9eb 100644 --- a/Services/Services.ts +++ b/Services/Services.ts @@ -14,8 +14,8 @@ export class Services { static StreamingService = Symbol("StreamingService"); static MarkdownService = Symbol("MarkdownService"); static StreamingMarkdownService = Symbol("StreamingMarkdownService"); - static AIFunctionDefinitions = Symbol("AIFunctionDefinitions"); static AIFunctionService = Symbol("AIFunctionService"); + static AIControllerService = Symbol("AIControllerService"); static ChatService = Symbol("ChatService"); static SanitiserService = Symbol("SanitiserService"); static InputService = Symbol("InputService"); diff --git a/Types/ExecutionPlan.ts b/Types/ExecutionPlan.ts new file mode 100644 index 0000000..d9e99a7 --- /dev/null +++ b/Types/ExecutionPlan.ts @@ -0,0 +1,109 @@ +import { ExecutionStatus } from "Enums/ExecutionStatus"; +import { ExecutionStep } from "./ExecutionStep"; +import type { SubmitPlanArgs } from "AIClasses/Schemas/AIFunctionSchemas"; +import { Copy, replaceCopy } from "Enums/Copy"; + +export class ExecutionPlan { + + private executionSteps: ExecutionStep[] = []; + + public constructor(plan: SubmitPlanArgs) { + for (const [index, step] of plan.steps.entries()) { + this.executionSteps.push(new ExecutionStep( + index, + step.description, + step.instruction, + step.context + )); + } + if (this.executionSteps[0]) { // Mark first step as active + this.executionSteps[0].status = ExecutionStatus.Active; + } + } + + public completed(): boolean { + return this.executionSteps.every(step => step.status === ExecutionStatus.Completed); + } + + public getStatusSummary(): { completed: string[], remaining: string[] } { + const completed: string[] = []; + const remaining: string[] = []; + + for (const step of this.executionSteps) { + const stepDescription = `${step.step + 1}. ${step.description}`; + if (step.status === ExecutionStatus.Completed) { + completed.push(stepDescription); + } else { + remaining.push(stepDescription); + } + } + + return { completed, remaining }; + } + + public completeExecutionStep(stepNumber: number): object { + const stepIndex = stepNumber - 1; + + const currentStep = this.executionSteps[stepIndex]; + if (!currentStep) { + return { + error: replaceCopy(Copy.StepDoesNotExistError, [ + stepNumber.toString(), + this.executionSteps.length.toString() + ]) + }; + } + + for (let i = 0; i < stepIndex; i++) { // Ensure all previous steps are completed + if (this.executionSteps[i].status !== ExecutionStatus.Completed) { + return { + error: replaceCopy(Copy.StepMustBeCompletedInOrderError, [ + stepNumber.toString(), + `${i + 1}. ${this.executionSteps[i].description}` + ]) + }; + } + } + + currentStep.status = ExecutionStatus.Completed; + + const nextStep = this.executionSteps[stepIndex + 1]; + if (nextStep) { + nextStep.status = ExecutionStatus.Active; + return { + message: replaceCopy(Copy.StepCompletedWithNextStep, [ + stepNumber.toString(), + (stepNumber + 1).toString(), + nextStep.description + ]) + }; + } else { + return { + message: replaceCopy(Copy.AllStepsCompleted, [ + stepNumber.toString() + ]) + }; + } + } + + public toFunctionResponse(): object { + if (this.executionSteps.length === 0) { + return { + error: Copy.PlanningFailedError + }; + } + + const firstStep = this.executionSteps[0]; + + return { + plan: this.executionSteps.map((step, index) => `${index + 1}. ${step.description}`), + firstStep: { + step: firstStep.step + 1, + description: firstStep.description, + instruction: firstStep.instruction, + ...(firstStep.context && { context: firstStep.context }) + } + }; + } + +} \ No newline at end of file diff --git a/Types/ExecutionStep.ts b/Types/ExecutionStep.ts new file mode 100644 index 0000000..563107f --- /dev/null +++ b/Types/ExecutionStep.ts @@ -0,0 +1,19 @@ +import { ExecutionStatus } from "Enums/ExecutionStatus"; + +export class ExecutionStep { + + public step: number; + public status: ExecutionStatus; + public description: string; + public instruction: string; + public context?: string; + + public constructor(step: number, description: string, instruction: string, context?: string) { + this.step = step; + this.description = description; + this.instruction = instruction; + this.context = context; + this.status = ExecutionStatus.Pending; + } + +} \ No newline at end of file