From b4afacef85ffb4b5849c6df378bed6f31296ee85 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 06:33:52 +0000 Subject: [PATCH 1/2] feat: implement create_files tool for BUS-1450 - Add create-file-tool.ts with Zod schema and bulk file creation support - Add create-file-functions.ts with sandbox execution and local fallback - Support both absolute and relative file paths - Create directories if they don't exist, overwrite existing files - Handle errors gracefully, continue processing other files on individual failures - Return detailed success/error results for each file operation - Include comprehensive unit tests (18 tests, all passing) - Export tool in index.ts following established patterns Co-Authored-By: Dallin Bentley --- .../create-file-functions.test.ts | 170 +++++++++++++ .../create-file-functions.ts | 99 ++++++++ .../create-file-tool.test.ts | 233 ++++++++++++++++++ .../create-files-tool/create-file-tool.ts | 144 +++++++++++ packages/ai/src/tools/index.ts | 1 + 5 files changed, 647 insertions(+) create mode 100644 packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.test.ts create mode 100644 packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.ts create mode 100644 packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.test.ts create mode 100644 packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.test.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.test.ts new file mode 100644 index 000000000..368b27db8 --- /dev/null +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { createFilesSafely, generateFileCreateCode, type FileCreateParams } from './create-file-functions'; + +vi.mock('node:fs/promises'); +const mockFs = vi.mocked(fs); + +describe('create-file-functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('createFilesSafely', () => { + it('should create files successfully', async () => { + const fileParams: FileCreateParams[] = [ + { path: '/test/file1.txt', content: 'content1' }, + { path: '/test/file2.txt', content: 'content2' }, + ]; + + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + const results = await createFilesSafely(fileParams); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + success: true, + filePath: '/test/file1.txt', + }); + expect(results[1]).toEqual({ + success: true, + filePath: '/test/file2.txt', + }); + + expect(mockFs.mkdir).toHaveBeenCalledTimes(2); + expect(mockFs.writeFile).toHaveBeenCalledTimes(2); + expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file1.txt', 'content1', 'utf-8'); + expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file2.txt', 'content2', 'utf-8'); + }); + + it('should handle relative paths correctly', async () => { + const fileParams: FileCreateParams[] = [ + { path: 'relative/file.txt', content: 'content' }, + ]; + + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + + const results = await createFilesSafely(fileParams); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + success: true, + filePath: 'relative/file.txt', + }); + + const expectedPath = path.join(process.cwd(), 'relative/file.txt'); + const expectedDir = path.dirname(expectedPath); + + expect(mockFs.mkdir).toHaveBeenCalledWith(expectedDir, { recursive: true }); + expect(mockFs.writeFile).toHaveBeenCalledWith(expectedPath, 'content', 'utf-8'); + }); + + it('should handle directory creation errors', async () => { + const fileParams: FileCreateParams[] = [ + { path: '/test/file.txt', content: 'content' }, + ]; + + mockFs.mkdir.mockRejectedValue(new Error('Permission denied')); + + const results = await createFilesSafely(fileParams); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + success: false, + filePath: '/test/file.txt', + error: 'Failed to create directory: Permission denied', + }); + }); + + it('should handle file write errors', async () => { + const fileParams: FileCreateParams[] = [ + { path: '/test/file.txt', content: 'content' }, + ]; + + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile.mockRejectedValue(new Error('Disk full')); + + const results = await createFilesSafely(fileParams); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual({ + success: false, + filePath: '/test/file.txt', + error: 'Disk full', + }); + }); + + it('should continue processing other files when one fails', async () => { + const fileParams: FileCreateParams[] = [ + { path: '/test/file1.txt', content: 'content1' }, + { path: '/test/file2.txt', content: 'content2' }, + ]; + + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.writeFile + .mockRejectedValueOnce(new Error('File 1 error')) + .mockResolvedValueOnce(undefined); + + const results = await createFilesSafely(fileParams); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + success: false, + filePath: '/test/file1.txt', + error: 'File 1 error', + }); + expect(results[1]).toEqual({ + success: true, + filePath: '/test/file2.txt', + }); + }); + + it('should handle empty file params array', async () => { + const results = await createFilesSafely([]); + expect(results).toEqual([]); + }); + }); + + describe('generateFileCreateCode', () => { + it('should generate valid TypeScript code for file creation', () => { + const fileParams: FileCreateParams[] = [ + { path: '/test/file1.txt', content: 'content1' }, + { path: '/test/file2.txt', content: 'content2' }, + ]; + + const code = generateFileCreateCode(fileParams); + + expect(code).toContain('const fs = require(\'fs\');'); + expect(code).toContain('const path = require(\'path\');'); + expect(code).toContain('function createSingleFile(fileParams)'); + expect(code).toContain('function createFilesConcurrently(fileParams)'); + expect(code).toContain('fs.mkdirSync(dirPath, { recursive: true });'); + expect(code).toContain('fs.writeFileSync(resolvedPath, content, \'utf-8\');'); + expect(code).toContain('console.log(JSON.stringify(results));'); + expect(code).toContain(JSON.stringify(fileParams)); + }); + + it('should handle empty file params array', () => { + const code = generateFileCreateCode([]); + expect(code).toContain('const fileParams = [];'); + expect(code).toContain('console.log(JSON.stringify(results));'); + }); + + it('should escape special characters in file content', () => { + const fileParams: FileCreateParams[] = [ + { path: '/test/file.txt', content: 'line1\nline2\ttab' }, + ]; + + const code = generateFileCreateCode(fileParams); + + expect(code).toContain('"content":"line1\\nline2\\ttab"'); + }); + }); +}); diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.ts new file mode 100644 index 000000000..67097f339 --- /dev/null +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-file-functions.ts @@ -0,0 +1,99 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +export interface FileCreateResult { + success: boolean; + filePath: string; + error?: string; +} + +export interface FileCreateParams { + path: string; + content: string; +} + +async function createSingleFile(fileParams: FileCreateParams): Promise { + try { + const { path: filePath, content } = fileParams; + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); + + const dirPath = path.dirname(resolvedPath); + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (error) { + return { + success: false, + filePath, + error: `Failed to create directory: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + + await fs.writeFile(resolvedPath, content, 'utf-8'); + + return { + success: true, + filePath, + }; + } catch (error) { + return { + success: false, + filePath: fileParams.path, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +export async function createFilesSafely(fileParams: FileCreateParams[]): Promise { + const fileCreatePromises = fileParams.map((params) => createSingleFile(params)); + return Promise.all(fileCreatePromises); +} + +/** + * Generates TypeScript code that can be executed in a sandbox to create files + * The generated code is self-contained and outputs results as JSON to stdout + */ +export function generateFileCreateCode(fileParams: FileCreateParams[]): string { + return ` +const fs = require('fs'); +const path = require('path'); + +function createSingleFile(fileParams) { + try { + const { path: filePath, content } = fileParams; + const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath); + + const dirPath = path.dirname(resolvedPath); + try { + fs.mkdirSync(dirPath, { recursive: true }); + } catch (error) { + return { + success: false, + filePath, + error: \`Failed to create directory: \${error instanceof Error ? error.message : 'Unknown error'}\`, + }; + } + + fs.writeFileSync(resolvedPath, content, 'utf-8'); + + return { + success: true, + filePath, + }; + } catch (error) { + return { + success: false, + filePath: fileParams.path, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } +} + +function createFilesConcurrently(fileParams) { + return fileParams.map((params) => createSingleFile(params)); +} + +const fileParams = ${JSON.stringify(fileParams)}; +const results = createFilesConcurrently(fileParams); +console.log(JSON.stringify(results)); + `.trim(); +} diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.test.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.test.ts new file mode 100644 index 000000000..42ce3605e --- /dev/null +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { z } from 'zod'; +import { RuntimeContext } from '@mastra/core/runtime-context'; +import { createFiles } from './create-file-tool'; +import { SandboxContextKey, type SandboxContext } from '../../../context/sandbox-context'; + +vi.mock('@buster/sandbox', () => ({ + runTypescript: vi.fn(), +})); + +vi.mock('./create-file-functions', () => ({ + generateFileCreateCode: vi.fn(), + createFilesSafely: vi.fn(), +})); + +import { runTypescript } from '@buster/sandbox'; +import { generateFileCreateCode, createFilesSafely } from './create-file-functions'; + +const mockRunTypescript = vi.mocked(runTypescript); +const mockGenerateFileCreateCode = vi.mocked(generateFileCreateCode); +const mockCreateFilesSafely = vi.mocked(createFilesSafely); + +describe('create-file-tool', () => { + let runtimeContext: RuntimeContext; + + beforeEach(() => { + vi.clearAllMocks(); + runtimeContext = new RuntimeContext(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('createFiles tool', () => { + it('should have correct tool configuration', () => { + expect(createFiles.id).toBe('create-files'); + expect(createFiles.description).toContain('Create one or more files'); + expect(createFiles.inputSchema).toBeDefined(); + expect(createFiles.outputSchema).toBeDefined(); + }); + + it('should validate input schema correctly', () => { + const validInput = { + files: [ + { path: '/test/file1.txt', content: 'content1' }, + { path: '/test/file2.txt', content: 'content2' }, + ], + }; + + expect(() => createFiles.inputSchema.parse(validInput)).not.toThrow(); + }); + + it('should reject invalid input schema', () => { + const invalidInput = { + files: [ + { path: '/test/file1.txt' }, // missing content + ], + }; + + expect(() => createFiles.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', content: 'test content' }, + ], + }; + + const mockCode = 'generated typescript code'; + const mockSandboxResult = { + result: JSON.stringify([{ success: true, filePath: '/test/file.txt' }]), + exitCode: 0, + stderr: '', + }; + + mockGenerateFileCreateCode.mockReturnValue(mockCode); + mockRunTypescript.mockResolvedValue(mockSandboxResult); + + const result = await createFiles.execute({ + context: input, + runtimeContext, + }); + + expect(mockGenerateFileCreateCode).toHaveBeenCalledWith(input.files); + expect(mockRunTypescript).toHaveBeenCalledWith(mockSandbox, mockCode); + expect(result.results).toHaveLength(1); + expect(result.results[0]).toEqual({ + status: 'success', + filePath: '/test/file.txt', + }); + }); + + it('should fallback to local execution when sandbox not available', async () => { + const input = { + files: [ + { path: '/test/file.txt', content: 'test content' }, + ], + }; + + const mockLocalResult = [ + { success: true, filePath: '/test/file.txt' }, + ]; + + mockCreateFilesSafely.mockResolvedValue(mockLocalResult); + + const result = await createFiles.execute({ + context: input, + runtimeContext, + }); + + expect(mockCreateFilesSafely).toHaveBeenCalledWith(input.files); + expect(result.results).toHaveLength(1); + expect(result.results[0]).toEqual({ + status: 'success', + filePath: '/test/file.txt', + }); + }); + + 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', content: 'test content' }, + ], + }; + + const mockCode = 'generated typescript code'; + const mockSandboxResult = { + result: 'error output', + exitCode: 1, + stderr: 'Execution failed', + }; + + mockGenerateFileCreateCode.mockReturnValue(mockCode); + mockRunTypescript.mockResolvedValue(mockSandboxResult); + + const result = await createFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toEqual({ + status: 'error', + filePath: '/test/file.txt', + errorMessage: 'Execution error: Sandbox execution failed: Execution failed', + }); + }); + + it('should handle mixed success and error results', async () => { + const input = { + files: [ + { path: '/test/file1.txt', content: 'content1' }, + { path: '/test/file2.txt', content: 'content2' }, + ], + }; + + const mockLocalResult = [ + { success: true, filePath: '/test/file1.txt' }, + { success: false, filePath: '/test/file2.txt', error: 'Permission denied' }, + ]; + + mockCreateFilesSafely.mockResolvedValue(mockLocalResult); + + const result = await createFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.results).toHaveLength(2); + expect(result.results[0]).toEqual({ + status: 'success', + filePath: '/test/file1.txt', + }); + expect(result.results[1]).toEqual({ + status: 'error', + filePath: '/test/file2.txt', + errorMessage: 'Permission denied', + }); + }); + + it('should handle empty files array', async () => { + const input = { files: [] }; + + const result = await createFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.results).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', content: 'test content' }, + ], + }; + + const mockCode = 'generated typescript code'; + const mockSandboxResult = { + result: 'invalid json output', + exitCode: 0, + stderr: '', + }; + + mockGenerateFileCreateCode.mockReturnValue(mockCode); + mockRunTypescript.mockResolvedValue(mockSandboxResult); + + const result = await createFiles.execute({ + context: input, + runtimeContext, + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toEqual({ + status: 'error', + filePath: '/test/file.txt', + errorMessage: expect.stringContaining('Failed to parse sandbox output'), + }); + }); + }); +}); diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts new file mode 100644 index 000000000..f824d3cdb --- /dev/null +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts @@ -0,0 +1,144 @@ +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'; +import type { AnalystRuntimeContext } from '../../../schemas/workflow-schemas'; + +const fileCreateParamsSchema = z.object({ + path: z.string().describe('The relative or absolute path to create the file at'), + content: z.string().describe('The content to write to the file'), +}); + +const createFilesInputSchema = z.object({ + files: z + .array(fileCreateParamsSchema) + .describe('Array of file creation operations to perform'), +}); + +const createFilesOutputSchema = z.object({ + results: z.array( + z.discriminatedUnion('status', [ + z.object({ + status: z.literal('success'), + filePath: z.string(), + }), + z.object({ + status: z.literal('error'), + filePath: z.string(), + errorMessage: z.string(), + }), + ]) + ), +}); + +const createFilesExecution = wrapTraced( + async ( + params: z.infer, + runtimeContext: RuntimeContext + ): Promise> => { + const { files } = params; + + if (!files || files.length === 0) { + return { results: [] }; + } + + try { + // Check if sandbox is available in runtime context + const sandbox = runtimeContext.get(SandboxContextKey.Sandbox); + + if (sandbox) { + // Execute in sandbox + const { generateFileCreateCode } = await import('./create-file-functions'); + const code = generateFileCreateCode(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'}`); + } + + // Parse the JSON output from sandbox + 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'}` + ); + } + + return { + results: fileResults.map((fileResult) => { + if (fileResult.success) { + return { + status: 'success' as const, + filePath: fileResult.filePath, + }; + } + return { + status: 'error' as const, + filePath: fileResult.filePath, + errorMessage: fileResult.error || 'Unknown error', + }; + }), + }; + } + + // Fallback to local execution + const { createFilesSafely } = await import('./create-file-functions'); + const fileResults = await createFilesSafely(files); + + return { + results: fileResults.map((fileResult) => { + if (fileResult.success) { + return { + status: 'success' as const, + filePath: fileResult.filePath, + }; + } + return { + status: 'error' as const, + filePath: fileResult.filePath, + errorMessage: fileResult.error || 'Unknown error', + }; + }), + }; + } catch (error) { + return { + results: files.map((file) => ({ + status: 'error' as const, + filePath: file.path, + errorMessage: `Execution error: ${error instanceof Error ? error.message : 'Unknown error'}`, + })), + }; + } + }, + { name: 'create-files' } +); + +export const createFiles = createTool({ + id: 'create-files', + description: `Create one or more files at specified paths with provided content. Supports both absolute and relative file paths. Creates directories if they don't exist and overwrites existing files. Handles errors gracefully by continuing to process other files even if some fail. Returns both successful operations and failed operations with detailed error messages.`, + inputSchema: createFilesInputSchema, + outputSchema: createFilesOutputSchema, + execute: async ({ + context, + runtimeContext, + }: { + context: z.infer; + runtimeContext: RuntimeContext; + }) => { + return await createFilesExecution(context, runtimeContext); + }, +}); + +export default createFiles; diff --git a/packages/ai/src/tools/index.ts b/packages/ai/src/tools/index.ts index 78816bb31..6e62cb7a0 100644 --- a/packages/ai/src/tools/index.ts +++ b/packages/ai/src/tools/index.ts @@ -9,4 +9,5 @@ export { createDashboards } from './visualization-tools/create-dashboards-file-t export { modifyDashboards } from './visualization-tools/modify-dashboards-file-tool'; export { executeSql } from './database-tools/execute-sql'; export { createTodoList } from './planning-thinking-tools/create-todo-item-tool'; +export { createFiles } from './file-tools/create-files-tool/create-file-tool'; export { readFiles } from './file-tools/read-files-tool/read-files-tool'; From 4cdc0499045897d730827a1e1c4f458057ab1ed3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 06:42:58 +0000 Subject: [PATCH 2/2] refactor: address code review suggestions - Move createFiles export next to readFiles for better organization - Remove unused AnalystRuntimeContext import - Improve code organization and cleanliness Co-Authored-By: Dallin Bentley --- .../src/tools/file-tools/create-files-tool/create-file-tool.ts | 1 - packages/ai/src/tools/index.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts index f824d3cdb..7f52285d4 100644 --- a/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts +++ b/packages/ai/src/tools/file-tools/create-files-tool/create-file-tool.ts @@ -4,7 +4,6 @@ import { createTool } from '@mastra/core/tools'; import { wrapTraced } from 'braintrust'; import { z } from 'zod'; import { type SandboxContext, SandboxContextKey } from '../../../context/sandbox-context'; -import type { AnalystRuntimeContext } from '../../../schemas/workflow-schemas'; const fileCreateParamsSchema = z.object({ path: z.string().describe('The relative or absolute path to create the file at'), diff --git a/packages/ai/src/tools/index.ts b/packages/ai/src/tools/index.ts index 6e62cb7a0..13e11d62a 100644 --- a/packages/ai/src/tools/index.ts +++ b/packages/ai/src/tools/index.ts @@ -9,5 +9,5 @@ export { createDashboards } from './visualization-tools/create-dashboards-file-t export { modifyDashboards } from './visualization-tools/modify-dashboards-file-tool'; export { executeSql } from './database-tools/execute-sql'; export { createTodoList } from './planning-thinking-tools/create-todo-item-tool'; -export { createFiles } from './file-tools/create-files-tool/create-file-tool'; export { readFiles } from './file-tools/read-files-tool/read-files-tool'; +export { createFiles } from './file-tools/create-files-tool/create-file-tool';