diff --git a/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-functions.ts b/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-functions.ts new file mode 100644 index 000000000..726fcac35 --- /dev/null +++ b/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-functions.ts @@ -0,0 +1,94 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +export interface FileDeleteResult { + success: boolean; + filePath: string; + error?: string; +} + +export interface FileDeleteParams { + path: string; +} + +async function deleteSingleFile(fileParams: FileDeleteParams): Promise { + try { + const { path: filePath } = fileParams; + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); + + try { + await fs.access(resolvedPath); + } catch { + return { + success: false, + filePath, + error: 'File not found', + }; + } + + await fs.unlink(resolvedPath); + + return { + success: true, + filePath, + }; + } catch (error) { + return { + success: false, + filePath: fileParams.path, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +export async function deleteFilesSafely( + fileParams: FileDeleteParams[] +): Promise { + const fileDeletePromises = fileParams.map((params) => deleteSingleFile(params)); + return Promise.all(fileDeletePromises); +} + +export function generateFileDeleteCode(fileParams: FileDeleteParams[]): string { + return ` +const fs = require('fs'); +const path = require('path'); + +function deleteSingleFile(fileParams) { + try { + const { path: filePath } = fileParams; + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); + + try { + fs.accessSync(resolvedPath); + } catch { + return { + success: false, + filePath, + error: 'File not found', + }; + } + + fs.unlinkSync(resolvedPath); + + return { + success: true, + filePath, + }; + } catch (error) { + return { + success: false, + filePath: fileParams.path, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +function deleteFilesConcurrently(fileParams) { + return fileParams.map((params) => deleteSingleFile(params)); +} + +const fileParams = ${JSON.stringify(fileParams)}; +const results = deleteFilesConcurrently(fileParams); +console.log(JSON.stringify(results)); + `.trim(); +} diff --git a/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-tool.test.ts b/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-tool.test.ts new file mode 100644 index 000000000..56e31305a --- /dev/null +++ b/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-tool.test.ts @@ -0,0 +1,210 @@ +import { RuntimeContext } from '@mastra/core/runtime-context'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { type SandboxContext, SandboxContextKey } from '../../../context/sandbox-context'; +import { deleteFiles } from './delete-files-tool'; + +vi.mock('@buster/sandbox', () => ({ + runTypescript: vi.fn(), +})); + +vi.mock('./delete-files-functions', () => ({ + generateFileDeleteCode: vi.fn(), + deleteFilesSafely: vi.fn(), +})); + +import { runTypescript } from '@buster/sandbox'; +import { deleteFilesSafely, generateFileDeleteCode } from './delete-files-functions'; + +const mockRunTypescript = vi.mocked(runTypescript); +const mockGenerateFileDeleteCode = vi.mocked(generateFileDeleteCode); +const mockDeleteFilesSafely = vi.mocked(deleteFilesSafely); + +describe('delete-files-tool', () => { + let runtimeContext: RuntimeContext; + + beforeEach(() => { + vi.clearAllMocks(); + runtimeContext = new RuntimeContext(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('deleteFiles tool', () => { + it('should have correct tool configuration', () => { + expect(deleteFiles.id).toBe('delete_files'); + expect(deleteFiles.description).toContain('Deletes files at the specified paths'); + expect(deleteFiles.inputSchema).toBeDefined(); + expect(deleteFiles.outputSchema).toBeDefined(); + }); + + it('should validate input schema correctly', () => { + const validInput = { + files: [ + { path: '/test/file1.txt' }, + { path: '/test/file2.txt' }, + ], + }; + + expect(() => deleteFiles.inputSchema.parse(validInput)).not.toThrow(); + }); + + it('should reject invalid input schema', () => { + const invalidInput = { + files: [ + { }, + ], + }; + + expect(() => deleteFiles.inputSchema.parse(invalidInput)).toThrow(); + }); + + it('should execute with sandbox when available', async () => { + const mockSandbox = { process: { codeRun: vi.fn() } }; + runtimeContext.set(SandboxContextKey.Sandbox, mockSandbox as any); + + const input = { + files: [{ path: '/test/file.txt' }], + }; + + const mockCode = 'generated typescript code'; + const mockSandboxResult = { + result: JSON.stringify([{ success: true, filePath: '/test/file.txt' }]), + exitCode: 0, + stderr: '', + }; + + mockGenerateFileDeleteCode.mockReturnValue(mockCode); + mockRunTypescript.mockResolvedValue(mockSandboxResult); + + const result = await deleteFiles.execute({ + context: input, + runtimeContext, + }); + + expect(mockGenerateFileDeleteCode).toHaveBeenCalledWith(input.files); + expect(mockRunTypescript).toHaveBeenCalledWith(mockSandbox, mockCode); + expect(result.successes).toEqual(['/test/file.txt']); + expect(result.failures).toEqual([]); + }); + + it('should fallback to local execution when sandbox not available', async () => { + const input = { + files: [{ path: '/test/file.txt' }], + }; + + const mockLocalResult = [{ success: true, filePath: '/test/file.txt' }]; + + mockDeleteFilesSafely.mockResolvedValue(mockLocalResult); + + const result = await deleteFiles.execute({ + context: input, + runtimeContext, + }); + + expect(mockDeleteFilesSafely).toHaveBeenCalledWith(input.files); + expect(result.successes).toEqual(['/test/file.txt']); + expect(result.failures).toEqual([]); + }); + + it('should handle sandbox execution errors', async () => { + const mockSandbox = { process: { codeRun: vi.fn() } }; + runtimeContext.set(SandboxContextKey.Sandbox, mockSandbox as any); + + const input = { + files: [{ path: '/test/file.txt' }], + }; + + const mockCode = 'generated typescript code'; + const mockSandboxResult = { + result: 'error output', + exitCode: 1, + stderr: 'Execution failed', + }; + + mockGenerateFileDeleteCode.mockReturnValue(mockCode); + mockRunTypescript.mockResolvedValue(mockSandboxResult); + + const result = await deleteFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.successes).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toEqual({ + path: '/test/file.txt', + error: 'Execution error: Sandbox execution failed: Execution failed', + }); + }); + + it('should handle mixed success and error results', async () => { + const input = { + files: [ + { path: '/test/file1.txt' }, + { path: '/test/file2.txt' }, + ], + }; + + const mockLocalResult = [ + { success: true, filePath: '/test/file1.txt' }, + { success: false, filePath: '/test/file2.txt', error: 'Permission denied' }, + ]; + + mockDeleteFilesSafely.mockResolvedValue(mockLocalResult); + + const result = await deleteFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.successes).toEqual(['/test/file1.txt']); + expect(result.failures).toEqual([{ + path: '/test/file2.txt', + error: 'Permission denied', + }]); + }); + + it('should handle empty files array', async () => { + const input = { files: [] }; + + const result = await deleteFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.successes).toEqual([]); + expect(result.failures).toEqual([]); + }); + + it('should handle JSON parse errors from sandbox', async () => { + const mockSandbox = { process: { codeRun: vi.fn() } }; + runtimeContext.set(SandboxContextKey.Sandbox, mockSandbox as any); + + const input = { + files: [{ path: '/test/file.txt' }], + }; + + const mockCode = 'generated typescript code'; + const mockSandboxResult = { + result: 'invalid json output', + exitCode: 0, + stderr: '', + }; + + mockGenerateFileDeleteCode.mockReturnValue(mockCode); + mockRunTypescript.mockResolvedValue(mockSandboxResult); + + const result = await deleteFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.successes).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error).toContain('Failed to parse sandbox output'); + }); + }); +}); diff --git a/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-tool.ts b/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-tool.ts new file mode 100644 index 000000000..c4de10e4c --- /dev/null +++ b/packages/ai/src/tools/file-tools/delete-files-tool/delete-files-tool.ts @@ -0,0 +1,130 @@ +import { runTypescript } from '@buster/sandbox'; +import type { RuntimeContext } from '@mastra/core/runtime-context'; +import { createTool } from '@mastra/core/tools'; +import { wrapTraced } from 'braintrust'; +import { z } from 'zod'; +import { type SandboxContext, SandboxContextKey } from '../../../context/sandbox-context'; + +const deleteFilesInputSchema = z.object({ + files: z.array( + z.object({ + path: z.string().describe('File path to delete (absolute or relative)'), + }) + ).describe('Array of file deletion operations to perform'), +}); + +const deleteFilesOutputSchema = z.object({ + successes: z.array(z.string()), + failures: z.array( + z.object({ + path: z.string(), + error: z.string(), + }) + ), +}); + +const deleteFilesExecution = wrapTraced( + async ( + params: z.infer, + runtimeContext: RuntimeContext + ): Promise> => { + const { files } = params; + + if (!files || files.length === 0) { + return { successes: [], failures: [] }; + } + + try { + const sandbox = runtimeContext.get(SandboxContextKey.Sandbox); + + if (sandbox) { + const { generateFileDeleteCode } = await import('./delete-files-functions'); + const code = generateFileDeleteCode(files); + const result = await runTypescript(sandbox, code); + + if (result.exitCode !== 0) { + console.error('Sandbox execution failed. Exit code:', result.exitCode); + console.error('Stderr:', result.stderr); + console.error('Stdout:', result.result); + throw new Error(`Sandbox execution failed: ${result.stderr || 'Unknown error'}`); + } + + let fileResults: Array<{ + success: boolean; + filePath: string; + error?: string; + }>; + try { + fileResults = JSON.parse(result.result.trim()); + } catch (parseError) { + console.error('Failed to parse sandbox output:', result.result); + throw new Error( + `Failed to parse sandbox output: ${parseError instanceof Error ? parseError.message : 'Unknown parse error'}` + ); + } + + const successes: string[] = []; + const failures: Array<{ path: string; error: string }> = []; + + for (const fileResult of fileResults) { + if (fileResult.success) { + successes.push(fileResult.filePath); + } else { + failures.push({ + path: fileResult.filePath, + error: fileResult.error || 'Unknown error', + }); + } + } + + return { successes, failures }; + } + + const { deleteFilesSafely } = await import('./delete-files-functions'); + const fileResults = await deleteFilesSafely(files); + + const successes: string[] = []; + const failures: Array<{ path: string; error: string }> = []; + + for (const fileResult of fileResults) { + if (fileResult.success) { + successes.push(fileResult.filePath); + } else { + failures.push({ + path: fileResult.filePath, + error: fileResult.error || 'Unknown error', + }); + } + } + + return { successes, failures }; + } catch (error) { + return { + successes: [], + failures: files.map((file) => ({ + path: file.path, + error: `Execution error: ${error instanceof Error ? error.message : 'Unknown error'}`, + })), + }; + } + }, + { name: 'delete-files' } +); + +export const deleteFiles = createTool({ + id: 'delete_files', + description: `Deletes files at the specified paths. Supports both absolute and relative file paths. Handles errors gracefully by continuing to process other files even if some fail. Returns both successful deletions and failed operations with detailed error messages. Does not fail the entire operation when individual file deletions fail.`, + inputSchema: deleteFilesInputSchema, + outputSchema: deleteFilesOutputSchema, + execute: async ({ + context, + runtimeContext, + }: { + context: z.infer; + runtimeContext: RuntimeContext; + }) => { + return await deleteFilesExecution(context, runtimeContext); + }, +}); + +export default deleteFiles; diff --git a/packages/ai/src/tools/index.ts b/packages/ai/src/tools/index.ts index 472bc048d..4ba567bd0 100644 --- a/packages/ai/src/tools/index.ts +++ b/packages/ai/src/tools/index.ts @@ -12,3 +12,4 @@ export { createTodoList } from './planning-thinking-tools/create-todo-item-tool' export { editFiles } from './file-tools/edit-files-tool/edit-files-tool'; export { readFiles } from './file-tools/read-files-tool/read-files-tool'; export { createFiles } from './file-tools/create-files-tool/create-file-tool'; +export { deleteFiles } from './file-tools/delete-files-tool/delete-files-tool';