andy-stack_vaultkeeper-ai/Services/AIServices/AIToolService.ts
Andrew Beal 9cf8f1dbd7 refactor: move replaceCopy helper to Helpers module and add tag merge utilities
Move replaceCopy function from Copy enum to Helpers module to improve
separation of concerns. Add splitFrontmatter and mergeTagsIntoFrontmatter
utilities for YAML frontmatter manipulation. Remove unused Beautify prompt
and QuickActionsService. Add timeout support to Semaphore.wait(). Remove
unused Event.QuickActionsSettingsChanged. Update all imports across AI
classes, prompts, services, and tests.
2026-05-29 22:01:02 +01:00

458 lines
No EOL
23 KiB
TypeScript

import { Resolve } from "../DependencyService";
import { Services } from "../Services";
import type { FileSystemService } from "../FileSystemService";
import { AITool, fromString } from "Enums/AITool";
import { AIToolResponse } from "AIClasses/ToolDefinitions/AIToolResponse";
import { AIToolCall } from "AIClasses/AIToolCall";
import type { ISearchMatch } from "../../Types/SearchTypes";
import { AbortService } from "../AbortService";
import { normalizePath, TAbstractFile, TFile } from "obsidian";
import { Exception } from "Helpers/Exception";
import { Copy } from "Enums/Copy";
import { pathExtname, replaceCopy } from "Helpers/Helpers";
import type { MemoriesService } from "Services/MemoriesService";
import type { SettingsService } from "Services/SettingsService";
import type { WebViewerService } from "Services/WebViewerService";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
import { Attachment } from "Conversations/Attachment";
import { isDocumentMimeType, MimeType } from "Enums/MimeType";
import { isTextFile, toFileType } from "Enums/FileType";
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
import { StringTools } from "Helpers/StringTools";
import { AIToolDefinitions } from "AIClasses/ToolDefinitions/AIToolDefinitions";
import { chatModeAllowsEdits } from "Enums/ChatMode";
import {
SearchVaultFilesArgsSchema,
ReadVaultFilesArgsSchema,
WriteVaultFileArgsSchema,
DeleteVaultFilesArgsSchema,
MoveVaultFilesArgsSchema,
ListVaultFilesArgsSchema,
PatchVaultFileArgsSchema,
ReadMemoriesArgsSchema,
UpdateMemoriesArgsSchema,
CreateVaultFolderArgsSchema,
GetWebViewerContentArgsSchema,
DeleteVaultFolderArgsSchema,
MoveVaultFolderArgsSchema
} from "AIClasses/Schemas/AIToolSchemas";
export class AIToolService {
private readonly fileSystemService: FileSystemService;
private readonly memoriesService: MemoriesService;
private readonly settingsService: SettingsService;
private readonly webViewerService: WebViewerService;
private readonly abortService: AbortService;
private lastToolReadMemories: boolean = false;
public constructor() {
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
this.memoriesService = Resolve<MemoriesService>(Services.MemoriesService);
this.settingsService = Resolve<SettingsService>(Services.SettingsService);
this.webViewerService = Resolve<WebViewerService>(Services.WebViewerService);
this.abortService = Resolve<AbortService>(Services.AbortService);
}
public async performAITool(toolCall: AIToolCall): Promise<AIToolResponse> {
// This can happen if the agent hallucinates a legitimate tool call it doesn't currently have access to
if (!chatModeAllowsEdits(this.settingsService.settings.chatMode) && AIToolDefinitions.requiresEditModeEnabled(toolCall.name)) {
Exception.log(`Invalid tool call: ${toolCall.name}. Agent has hallucinated a legitimate tool call that requires edit mode`);
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid tool call: ${toolCall.name}. The user has not allowed edits to be made.` }),
toolCall.toolId
);
}
if (toolCall.name !== AITool.ReadMemories && toolCall.name !== AITool.UpdateMemories) {
this.lastToolReadMemories = false;
}
return await this.abortService.abortableOperation(async () => {
switch (toolCall.name) {
case AITool.SearchVaultFiles: {
const parseResult = SearchVaultFilesArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.SearchVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.searchVaultFiles(parseResult.data.search_terms), toolCall.toolId);
}
case AITool.ReadVaultFiles: {
const parseResult = ReadVaultFilesArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.ReadVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.readVaultFiles(parseResult.data.file_paths), toolCall.toolId);
}
case AITool.WriteVaultFile: {
const parseResult = WriteVaultFileArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.WriteVaultFile}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.writeVaultFile(parseResult.data.file_path, parseResult.data.content), toolCall.toolId);
}
case AITool.PatchVaultFile: {
const parseResult = PatchVaultFileArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.PatchVaultFile}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.patchVaultFile(parseResult.data.file_path, parseResult.data.oldContent, parseResult.data.newContent), toolCall.toolId);
}
case AITool.DeleteVaultFiles: {
const parseResult = DeleteVaultFilesArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.DeleteVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.deleteVaultFiles(parseResult.data.file_paths, parseResult.data.confirm_deletion), toolCall.toolId);
}
case AITool.MoveVaultFiles: {
const parseResult = MoveVaultFilesArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.MoveVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.moveVaultFiles(parseResult.data.source_paths, parseResult.data.destination_paths), toolCall.toolId);
}
case AITool.CreateVaultFolder: {
const parseResult = CreateVaultFolderArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.CreateVaultFolder}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.createVaultFolder(parseResult.data.path), toolCall.toolId);
}
case AITool.DeleteVaultFolder: {
const parseResult = DeleteVaultFolderArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.DeleteVaultFolder}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.deleteVaultFolder(parseResult.data.path, parseResult.data.confirm_deletion), toolCall.toolId);
}
case AITool.MoveVaultFolder: {
const parseResult = MoveVaultFolderArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.MoveVaultFolder}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.moveVaultFolder(parseResult.data.source_path, parseResult.data.destination_path), toolCall.toolId);
}
case AITool.ListVaultFiles: {
const parseResult = ListVaultFilesArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.ListVaultFiles}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.listVaultFiles(parseResult.data.path, parseResult.data.recursive), toolCall.toolId);
}
case AITool.GetWebViewerContent: {
const parseResult = GetWebViewerContentArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.GetWebViewerContent}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.getWebViewerContent(parseResult.data.format, parseResult.data.url_hint), toolCall.toolId);
}
case AITool.ReadMemories: {
const parseResult = ReadMemoriesArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.ReadMemories}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.readMemories(), toolCall.toolId);
}
case AITool.UpdateMemories: {
const parseResult = UpdateMemoriesArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Invalid arguments for ${AITool.UpdateMemories}: ${parseResult.error.message}` }),
toolCall.toolId
);
}
return new AIToolResponse(toolCall.name, await this.updateMemories(parseResult.data.content), toolCall.toolId);
}
// This is only used by gemini
case AITool.RequestWebSearch:
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({}), toolCall.toolId)
// multi-agent functions are handled elsewhere - this shouldn't really ever get hit
case AITool.ExecuteWorkflow:
case AITool.ContinuePlanExecution:
case AITool.SubmitPlan:
case AITool.AskUserQuestionPlanning:
case AITool.AskUserQuestionExecution:
case AITool.CompleteTask:
case AITool.CompleteStep:
case AITool.SkipStep:
case AITool.ReviseStep:
case AITool.RevisePlan:
case AITool.CompletePlan:
case AITool.CancelPlan: {
Exception.log(`Multi-agent function ${toolCall.name} should not be handled by AIToolService`);
return new AIToolResponse(
toolCall.name,
new AIToolResponsePayload({ error: `Failed to execute ${toolCall.name}.` }),
toolCall.toolId
);
}
case AITool.Unknown:
default: {
const toolCallName = fromString(toolCall.name);
const error = `Unknown function request ${toolCallName}`
Exception.log(error);
return new AIToolResponse(
toolCallName,
new AIToolResponsePayload({ error: error }),
toolCall.toolId
);
}
}
});
}
private async searchVaultFiles(searchTerms: string[]): Promise<AIToolResponsePayload> {
const results: { searchTerm: string, results: object[] }[] = [];
for (const searchTerm of searchTerms) {
const matches: ISearchMatch[] = await this.fileSystemService.searchVaultFiles(searchTerm);
results.push({
searchTerm: searchTerm,
results: matches.map(match => ({
path: match.file.path,
snippets: match.snippets.map((snippet) => ({
text: snippet.text,
pageNumber: snippet.pageNumber,
matchPosition: snippet.matchIndex
}))
}))
});
}
return new AIToolResponsePayload(results);
}
private async readVaultFiles(filePaths: string[]): Promise<AIToolResponsePayload> {
const results = await Promise.all(
filePaths.map(async (filePath) => {
const result = await this.fileSystemService.readFilePath(filePath);
if (result instanceof Error) {
return { path: filePath, error: result.message };
}
return {
type: pathExtname(filePath),
path: filePath,
contents: result
};
})
);
const errorResults = results.filter(result => result.error);
const successResults = results.filter(result => !result.error && result.type && result.contents !== undefined) as
Array<{ type: string; path: string; contents: string }>;
const textResults = successResults.filter(result => isTextFile(result.type));
const binaryResults = successResults.filter(result => !isTextFile(result.type));
const attachments = binaryResults.map(file => {
const fileName = file.path.split('/').pop() || file.path;
let mimeType = FileTypeToMimeType[toFileType(file.type)];
if (isDocumentMimeType(mimeType)) {
mimeType = MimeType.TEXT_PLAIN;
return new Attachment(fileName, mimeType, StringTools.toBase64(file.contents));
}
return new Attachment(fileName, mimeType, file.contents);
});
const response = textResults.length > 0 || errorResults.length > 0
? {
results: [...textResults, ...errorResults],
...(binaryResults.length > 0 && { message: "The contents of the files are included below." })
}
: {
message: "Files retrieved successfully. The contents of the files are included below.",
count: binaryResults.length
};
return new AIToolResponsePayload(response, attachments);
}
private async writeVaultFile(filePath: string, content: string): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.writeToFilePath(normalizePath(filePath), content);
if (result instanceof Error) {
return new AIToolResponsePayload({ success: false, error: result.message });
}
return new AIToolResponsePayload({ success: true });
}
private async patchVaultFile(filePath: string, oldContent: string[], newContent: string[]): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.patchFileAtPath(normalizePath(filePath), oldContent, newContent);
if (result instanceof Error) {
return new AIToolResponsePayload({ success: false, error: result.message });
}
return new AIToolResponsePayload({ success: true });
}
private async deleteVaultFiles(filePaths: string[], confirmation: boolean): Promise<AIToolResponsePayload> {
if (!confirmation) {
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
}
const results = await Promise.all(filePaths.map(async filePath => {
const result = await this.fileSystemService.deleteFile(filePath);
if (result instanceof Error) {
return { path: filePath, success: false, error: result.message };
}
return { path: filePath, success: true };
}));
return new AIToolResponsePayload({ results });
}
private async moveVaultFiles(sourcePaths: string[], destinationPaths: string[]): Promise<AIToolResponsePayload> {
if (sourcePaths.length !== destinationPaths.length) {
return new AIToolResponsePayload({ error: "Source paths array length does not equal destination paths array length" });
}
const results = await Promise.all(sourcePaths.map(async (sourcePath, index) => {
const destinationPath = destinationPaths[index];
const result = await this.fileSystemService.moveFile(sourcePath, destinationPath);
if (result instanceof Error) {
return { path: destinationPath, success: false, error: result.message };
}
return { path: destinationPath, success: true };
}));
return new AIToolResponsePayload({ results });
}
private async createVaultFolder(path: string): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.createFolder(path);
if (result instanceof Error) {
return new AIToolResponsePayload({ path: path, success: false, error: result.message });
}
return new AIToolResponsePayload({ path: path, success: true });
}
private async deleteVaultFolder(path: string, confirmation: boolean): Promise<AIToolResponsePayload> {
if (!confirmation) {
return new AIToolResponsePayload({ error: "Confirmation was false, no action taken" });
}
const result = await this.fileSystemService.deleteFolder(path);
return result instanceof Error
? new AIToolResponsePayload({ path: path, success: false, error: result.message })
: new AIToolResponsePayload({ path: path, success: true });
}
private async moveVaultFolder(sourcePath: string, destinationPath: string): Promise<AIToolResponsePayload> {
const result = await this.fileSystemService.moveFile(sourcePath, destinationPath);
if (result instanceof Error) {
return new AIToolResponsePayload({ path: destinationPath, success: false, error: result.message });
}
return new AIToolResponsePayload({ path: destinationPath, success: true });
}
private async listVaultFiles(path: string, recursive: boolean): Promise<AIToolResponsePayload> {
const files: TAbstractFile[] = await this.fileSystemService.listDirectoryContents(path, recursive);
return new AIToolResponsePayload(files.map(file => ({
type: file instanceof TFile ? "file" : "directory",
path: file.path
})));
}
private async getWebViewerContent(format: "text" | "screenshot", urlHint?: string): Promise<AIToolResponsePayload> {
const result = format === "text"
? await this.webViewerService.getWebViewContent(urlHint)
: await this.webViewerService.takeScreenshot(urlHint, true);
if (urlHint && !result) {
return new AIToolResponsePayload({ error: replaceCopy(Copy.WebViewerNoMatchingUrl, [urlHint]) });
}
if (!result) {
return new AIToolResponsePayload({ error: Copy.WebViewerNoOpenView });
}
return format === "text"
? new AIToolResponsePayload({ content: result })
: new AIToolResponsePayload({ success: true }, [new Attachment("screenshot.png", MimeType.IMAGE_PNG, result)]);
}
private async readMemories(): Promise<AIToolResponsePayload> {
if (!this.settingsService.settings.enableMemories) {
return new AIToolResponsePayload({ error: Copy.MemoriesDisabledError });
}
this.lastToolReadMemories = true;
return new AIToolResponsePayload({ memories: await this.memoriesService.readMemories() });
}
private async updateMemories(content: string): Promise<AIToolResponsePayload> {
if (!this.settingsService.settings.allowUpdatingMemories) {
return new AIToolResponsePayload({ error: Copy.MemoriesUpdatingDisabledError });
}
if (!this.lastToolReadMemories) {
return new AIToolResponsePayload({ error: Copy.UpdateMemoriesWithoutReadError });
}
this.lastToolReadMemories = false;
return new AIToolResponsePayload({ result: await this.memoriesService.updateMemories(content) });
}
}