feat: add move_vault_folder tool and standardize schema naming

- Add MoveVaultFolder tool with supporting schema and enum
- Rename schemas from *Schema to *ArgsSchema for consistency
- Fix VaultService.move() to handle folder destinations properly
- Update AIToolResponse to use AIToolResponsePayload wrapper
- Update error message for non-existent move sources
- Add MoveVaultFolder import and tool registration
This commit is contained in:
Andrew Beal 2026-04-09 22:20:37 +01:00
parent 16c9107606
commit 57fb5d3be3
10 changed files with 100 additions and 23 deletions

View file

@ -20,6 +20,7 @@ import type { MistralStreamChunk, MistralToolDefinition, MistralMessage, Mistral
import type { MistralFileService } from "./MistralFileService";
import { Copy, replaceCopy } from "Enums/Copy";
import { MistralAgent } from "./MistralAgent";
import { AIToolResponsePayload } from "AIClasses/ToolDefinitions/AIToolResponsePayload";
export class Mistral extends BaseAIClass {
@ -97,7 +98,7 @@ export class Mistral extends BaseAIClass {
}
const query = (toolCall.arguments as Record<string, string>).query ?? "";
const result = await this.agent.search(query);
return new AIToolResponse(toolCall.name, { result }, toolCall.toolId);
return new AIToolResponse(toolCall.name, new AIToolResponsePayload({ result }), toolCall.toolId);
}
private async buildMessages(conversation: Conversation): Promise<MistralMessage[]> {

View file

@ -38,24 +38,30 @@ export const MoveVaultFilesArgsSchema = z.object({
user_message: z.string()
});
export const CreateVaultFolderSchema = z.object({
export const CreateVaultFolderArgsSchema = z.object({
path: z.string(),
user_message: z.string()
});
export const DeleteVaultFolderSchema = z.object({
export const DeleteVaultFolderArgsSchema = z.object({
path: z.string(),
user_message: z.string(),
confirm_deletion: z.boolean()
});
export const MoveVaultFolderArgsSchema = z.object({
source_path: z.string(),
destination_path: z.string(),
user_message: z.string()
});
export const ListVaultFilesArgsSchema = z.object({
path: z.string(),
recursive: z.boolean(),
user_message: z.string()
});
export const GetWebViewerContentSchema = z.object({
export const GetWebViewerContentArgsSchema = z.object({
url_hint: z.string().optional(),
format: z.enum(["text", "screenshot"]),
user_message: z.string()
@ -145,10 +151,11 @@ export type WriteVaultFileArgs = z.infer<typeof WriteVaultFileArgsSchema>;
export type PatchVaultFileArgs = z.infer<typeof PatchVaultFileArgsSchema>;
export type DeleteVaultFilesArgs = z.infer<typeof DeleteVaultFilesArgsSchema>;
export type MoveVaultFilesArgs = z.infer<typeof MoveVaultFilesArgsSchema>;
export type CreateVaultFolder = z.infer<typeof CreateVaultFolderSchema>;
export type DeleteVaultFolder = z.infer<typeof DeleteVaultFolderSchema>;
export type CreateVaultFolderArgs = z.infer<typeof CreateVaultFolderArgsSchema>;
export type DeleteVaultFolderArgs = z.infer<typeof DeleteVaultFolderArgsSchema>;
export type MoveVaultFolderArgs = z.infer<typeof MoveVaultFolderArgsSchema>;
export type ListVaultFilesArgs = z.infer<typeof ListVaultFilesArgsSchema>;
export type GetWebViewerContent = z.infer<typeof GetWebViewerContentSchema>;
export type GetWebViewerContentArgs = z.infer<typeof GetWebViewerContentArgsSchema>;
export type ExecuteWorkflowArgs = z.infer<typeof ExecuteWorkflowArgsSchema>;
export type AskUserQuestionPlanningArgs = z.infer<typeof AskUserQuestionPlanningArgsSchema>;
export type AskUserQuestionExecutionArgs = z.infer<typeof AskUserQuestionExecutionArgsSchema>;

View file

@ -21,6 +21,7 @@ import { UpdateMemories } from "./Tools/UpdateMemories";
import { CreateVaultFolder } from "./Tools/CreateVaultFolder";
import { GetWebViewerContent } from "./Tools/GetWebViewerContent";
import { DeleteVaultFolder } from "./Tools/DeleteVaultFolder";
import { MoveVaultFolder } from "./Tools/MoveVaultFolder";
export abstract class AIToolDefinitions {
@ -28,7 +29,7 @@ export abstract class AIToolDefinitions {
// 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, GetWebViewerContent,
WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder];
WriteVaultFile, PatchVaultFile, DeleteVaultFiles, MoveVaultFiles, CreateVaultFolder, DeleteVaultFolder, MoveVaultFolder];
public static agentDefinitions(destructive: boolean, planningMode: boolean, memories: boolean, updateMemories: boolean): IAIToolDefinition[] {
this.isGated = false;
@ -59,7 +60,8 @@ export abstract class AIToolDefinitions {
DeleteVaultFiles,
MoveVaultFiles,
CreateVaultFolder,
DeleteVaultFolder
DeleteVaultFolder,
MoveVaultFolder
]);
}

View file

@ -0,0 +1,36 @@
import { AITool } from "Enums/AITool";
import type { IAIToolDefinition } from "../IAIToolDefinition";
export const MoveVaultFolder: IAIToolDefinition = {
name: AITool.MoveVaultFolder,
description: `Moves or renames a folder within the vault to a new location.
This operation moves the folder and all its contents, preserving the internal structure.
Call this function:
- When reorganizing vault structure and moving a folder to a new parent directory
- When renaming a folder for better organization
- When consolidating related folders under a common parent
Do NOT use this function:
- Before reading folder contents to confirm you're moving the correct folder (especially for large operations)
- When moving individual files
- When the destination folder already exists at that path`,
parameters: {
type: "object",
properties: {
source_path: {
type: "string",
description: "The current path of the folder to move. Must be exact and point to an existing folder within the vault. Example: 'projects/old-name'"
},
destination_path: {
type: "string",
description: "The destination path for the folder (including the folder name). Supports renaming by providing a different folder name. Example: 'projects/new-name' or 'archive/projects/old-name'. Ensure the parent directory exists."
},
user_message: {
type: "string",
description: "A short message to be displayed to the user explaining why this folder is being moved. Examples: 'Moving your projects folder to the archive' (archiving), 'Renaming folder to match new naming convention' (renaming), or 'Reorganising your vault structure' (reorganizing)."
}
},
required: ["source_path", "destination_path", "user_message"]
}
}

View file

@ -7,6 +7,7 @@ export enum AITool {
MoveVaultFiles = "move_vault_files",
CreateVaultFolder = "create_vault_folder",
DeleteVaultFolder = "delete_vault_folder",
MoveVaultFolder = "move_vault_folder",
ListVaultFiles = "list_vault_files",
ReadMemories = "read_memories",
UpdateMemories = "update_memories",

View file

@ -29,9 +29,10 @@ import {
PatchVaultFileArgsSchema,
ReadMemoriesArgsSchema,
UpdateMemoriesArgsSchema,
CreateVaultFolderSchema,
GetWebViewerContentSchema,
DeleteVaultFolderSchema
CreateVaultFolderArgsSchema,
GetWebViewerContentArgsSchema,
DeleteVaultFolderArgsSchema,
MoveVaultFolderArgsSchema
} from "AIClasses/Schemas/AIToolSchemas";
export class AIToolService {
@ -133,7 +134,7 @@ export class AIToolService {
}
case AITool.CreateVaultFolder: {
const parseResult = CreateVaultFolderSchema.safeParse(toolCall.arguments);
const parseResult = CreateVaultFolderArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
@ -145,7 +146,7 @@ export class AIToolService {
}
case AITool.DeleteVaultFolder: {
const parseResult = DeleteVaultFolderSchema.safeParse(toolCall.arguments);
const parseResult = DeleteVaultFolderArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
@ -155,6 +156,18 @@ export class AIToolService {
}
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);
@ -169,7 +182,7 @@ export class AIToolService {
}
case AITool.GetWebViewerContent: {
const parseResult = GetWebViewerContentSchema.safeParse(toolCall.arguments);
const parseResult = GetWebViewerContentArgsSchema.safeParse(toolCall.arguments);
if (!parseResult.success) {
return new AIToolResponse(
toolCall.name,
@ -377,6 +390,14 @@ export class AIToolService {
: 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 => ({

View file

@ -239,15 +239,22 @@ export class VaultService {
const file = this.getAbstractFileByPath(sourcePath, allowAccessToPluginRoot);
if (file === null) {
return Exception.new(`File does not exist: ${sourcePath}`);
return Exception.new(`Move failed as source does not exist: ${sourcePath}`);
}
const isFile = file instanceof TFile;
if (this.isExclusion(destinationPath, allowAccessToPluginRoot)) {
return Exception.new(`Failed to rename "${sourcePath}" to "${destinationPath}", permission denied.`)
}
try {
await this.createDirectories(destinationPath, allowAccessToPluginRoot)
if (isFile) {
await this.createDirectories(destinationPath, allowAccessToPluginRoot);
} else {
const parentPath = destinationPath.substring(0, destinationPath.lastIndexOf("/"));
if (parentPath) await this.createDirectories(parentPath, allowAccessToPluginRoot);
}
await this.fileManager.renameFile(file, destinationPath);
} catch (error) {
Exception.log(error);

View file

@ -345,16 +345,17 @@ describe('FileSystemService', () => {
expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, true, true);
});
it('should delete folder successfully (supports both files and folders)', async () => {
it('should delete folder successfully', async () => {
const mockFolder = createMockFolder('folder');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFolder);
mockVaultService.isExclusion = vi.fn().mockReturnValue(false);
mockVaultService.delete = vi.fn().mockResolvedValue(undefined);
const result = await fileSystemService.deleteFile('folder');
const result = await fileSystemService.deleteFolder('folder');
expect(result).toBeUndefined();
expect(mockVaultService.delete).toHaveBeenCalledWith(mockFolder, false, true);
expect(mockVaultService.delete).toHaveBeenCalledWith(mockFolder, false);
});
});

View file

@ -4,6 +4,7 @@ import { RegisterSingleton, DeregisterAllServices } from '../../Services/Depende
import { Services } from '../../Services/Services';
import { AITool } from '../../Enums/AITool';
import { AIToolResponse } from '../../AIClasses/ToolDefinitions/AIToolResponse';
import { AIToolResponsePayload } from 'AIClasses/ToolDefinitions/AIToolResponsePayload';
/**
* UNIT TESTS - OrchestrationAgent
@ -36,7 +37,7 @@ describe('OrchestrationAgent - Unit Tests', () => {
// Mock AIToolService
mockAIToolService = {
performAITool: vi.fn().mockResolvedValue(
new AIToolResponse(AITool.SearchVaultFiles, { results: [] }, 'test-tool-id')
new AIToolResponse(AITool.SearchVaultFiles, new AIToolResponsePayload({ results: [] }), 'test-tool-id')
)
};
RegisterSingleton(Services.AIToolService, mockAIToolService);

View file

@ -665,7 +665,7 @@ describe('VaultService - Integration Tests', () => {
const result = await vaultService.move('Vaultkeeper AI/test.md', 'dest.md', false);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('File does not exist');
expect((result as Error).message).toContain('Move failed as source does not exist');
expect(mockFileManager.renameFile).not.toHaveBeenCalled();
});
@ -675,7 +675,7 @@ describe('VaultService - Integration Tests', () => {
const result = await vaultService.move('nonexistent.md', 'dest.md');
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('File does not exist');
expect((result as Error).message).toContain('Move failed as source does not exist');
expect(mockFileManager.renameFile).not.toHaveBeenCalled();
});