refactor: restructure AI prompt and agent architecture for multi-agent planning support

- Move prompts from AIClasses to AIPrompts directory
- Replace centralized IPrompt injection with direct property setters on IAIClass
- Remove allowDestructiveActions parameter from streamRequest methods
- Add toolDefinitions, systemPrompt, and userInstruction properties to IAIClass
- Refactor AIFunctionDefinitions to static methods with agent-specific tool sets
- Add planning agent function definitions (CreatePlan, Replan, CompleteStep, SubmitPlan)
- Create AIControllerService to handle agent orchestration
- Add execution plan related copy strings and replacement utility
- Update all AI providers (Claude, Gemini, OpenAI) to use new architecture
This commit is contained in:
Andrew Beal 2025-12-30 19:07:00 +00:00
parent 989fab565d
commit 2de8109a74
38 changed files with 1355 additions and 323 deletions

View file

@ -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<IPrompt>(Services.IPrompt);
this.abortService = Resolve<AbortService>(Services.AbortService);
this.aiFileService = Resolve<IAIFileService>(Services.IAIFileService);
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
this.streamingService = Resolve<StreamingService>(Services.StreamingService);
this.aiFunctionDefinitions = Resolve<AIFunctionDefinitions>(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<IStreamChunk, void, unknown>;
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<IStreamChunk, void, unknown>;
public abstract formatBinaryFiles(attachments: Attachment[]): string;
@ -123,13 +140,6 @@ export abstract class BaseAIClass implements IAIClass {
};
}
protected async buildSystemPrompt(): Promise<string> {
return [
this.aiPrompt.systemInstruction(),
await this.aiPrompt.userInstruction()
].filter(s => s).join("\n\n");
}
protected async processAttachments<T>(
attachments: Attachment[],
formatBinaryFiles: (attachments: Attachment[]) => string

View file

@ -35,9 +35,8 @@ export class Claude extends BaseAIClass {
super(AIProvider.Claude);
}
public async* streamRequest(
conversation: Conversation, allowDestructiveActions: boolean
): AsyncGenerator<IStreamChunk, void, unknown> {
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
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,

View file

@ -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";

View file

@ -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');
}
}

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -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: {

View file

@ -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: {

View file

@ -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: {

View file

@ -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",

View file

@ -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 themdo 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 themdo 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: {

View file

@ -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"]
}
}

View file

@ -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: {

View file

@ -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"]
}
};

View file

@ -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: {

View file

@ -76,9 +76,7 @@ export class Gemini extends BaseAIClass {
super(AIProvider.Gemini);
}
public async* streamRequest(
conversation: Conversation, allowDestructiveActions: boolean
): AsyncGenerator<IStreamChunk, void, unknown> {
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
// 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
}
]
},

View file

@ -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";

View file

@ -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<IStreamChunk, void, unknown>;
set systemPrompt(systemPrompt: string);
set userInstruction(userInstruction: string);
set toolDefinitions(toolDefinitions: IAIFunctionDefinition[]);
streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown>;
formatBinaryFiles(attachments: Attachment[]): string;
}

View file

@ -28,24 +28,20 @@ export class OpenAI extends BaseAIClass {
super(AIProvider.OpenAI);
}
public async* streamRequest(
conversation: Conversation, allowDestructiveActions: boolean
): AsyncGenerator<IStreamChunk, void, unknown> {
public async* streamRequest(conversation: Conversation): AsyncGenerator<IStreamChunk, void, unknown> {
// 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),

View file

@ -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";

View file

@ -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<typeof SearchVaultFilesArgsSchema>;
export type ReadVaultFilesArgs = z.infer<typeof ReadVaultFilesArgsSchema>;
@ -54,3 +80,7 @@ export type PatchVaultFileArgs = z.infer<typeof PatchVaultFileArgsSchema>;
export type DeleteVaultFilesArgs = z.infer<typeof DeleteVaultFilesArgsSchema>;
export type MoveVaultFilesArgs = z.infer<typeof MoveVaultFilesArgsSchema>;
export type ListVaultFilesArgs = z.infer<typeof ListVaultFilesArgsSchema>;
export type CreatePlanArgs = z.infer<typeof CreatePlanArgsSchema>;
export type ReplanArgs = z.infer<typeof ReplanArgsSchema>;
export type CompleteStepArgs = z.infer<typeof CompleteStepArgsSchema>;
export type SubmitPlanArgs = z.infer<typeof SubmitPlanArgsSchema>;

View file

@ -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<string>;
}
@ -23,6 +25,10 @@ export class AIPrompt implements IPrompt {
return SystemInstruction;
}
public planningInstruction(): string {
return PlanningAgentSystemPrompt;
}
public async userInstruction(): Promise<string> {
const result = await this.fileSystemService.readFile(this.settingsService.settings.userInstruction, true);
return result instanceof Error ? "" : result;

View file

@ -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 approachlearn 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 explorationalways search first
Ignoring failure modesplan for things going wrong
Over-complex plans for simple tasksmatch complexity to need
Micromanaging execution instead of providing actionable guidance
Missing wiki-link opportunitiesalways 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()}
`;

View file

@ -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** specificallywhat 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 strategiesnever 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 strategiesnever give up after the first attempt. When executing plans, stay adaptive: replan when reality diverges from assumptions, but handle minor adjustments yourself.
`;

View file

@ -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;
}

View file

@ -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>
{context}
</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;
}

5
Enums/ExecutionStatus.ts Normal file
View file

@ -0,0 +1,5 @@
export enum ExecutionStatus {
Pending = "pending",
Active = "active",
Completed = "completed"
}

View file

@ -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();
}

60
Helpers/ResponseHelper.ts Normal file
View file

@ -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;
}

View file

@ -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<IPrompt>(Services.IPrompt);
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
}
public resolveAIProvider() {
this.ai = Resolve<IAIClass>(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<boolean> {
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<ExecutionPlan> {
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<void> {
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
]);
}
}

View file

@ -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
);

View file

@ -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();
}

View file

@ -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<AIControllerService>(Services.AIControllerService);
this.conversationService = Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService);
this.aiFunctionService = Resolve<AIFunctionService>(Services.AIFunctionService);
this.namingService = Resolve<ConversationNamingService>(Services.ConversationNamingService);
this.workSpaceService = Resolve<WorkSpaceService>(Services.WorkSpaceService);
this.eventService = Resolve<EventService>(Services.EventService);
@ -51,10 +48,6 @@ export class ChatService {
public onNameChanged: ((name: string) => void) | undefined = undefined;
public resolveAIProvider() {
this.ai = Resolve<IAIClass>(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;
}
}

View file

@ -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<VaultkeeperAIPlugin>(Services.VaultkeeperAIPlugin, plugin);
@ -59,8 +59,8 @@ export function RegisterDependencies() {
RegisterSingleton<ConversationNamingService>(Services.ConversationNamingService, new ConversationNamingService());
RegisterSingleton<IPrompt>(Services.IPrompt, new AIPrompt());
RegisterSingleton<AIFunctionDefinitions>(Services.AIFunctionDefinitions, new AIFunctionDefinitions());
RegisterSingleton<AIFunctionService>(Services.AIFunctionService, new AIFunctionService());
RegisterSingleton<AIControllerService>(Services.AIControllerService, new AIControllerService());
RegisterSingleton<StreamingService>(Services.StreamingService, new StreamingService());
RegisterSingleton<ChatService>(Services.ChatService, new ChatService());
@ -91,7 +91,7 @@ export function RegisterAiProvider() {
RegisterSingleton<IConversationNamingService>(Services.IConversationNamingService, new OpenAIConversationNamingService());
}
Resolve<ChatService>(Services.ChatService).resolveAIProvider();
Resolve<AIControllerService>(Services.AIControllerService).resolveAIProvider();
Resolve<ConversationNamingService>(Services.ConversationNamingService).resolveNamingProvider();
Resolve<ConversationFileSystemService>(Services.ConversationFileSystemService).resolveAIFileService();
}

View file

@ -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");

109
Types/ExecutionPlan.ts Normal file
View file

@ -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 })
}
};
}
}

19
Types/ExecutionStep.ts Normal file
View file

@ -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;
}
}