diff --git a/packages/ai/src/tools/file-tools/bash-execute-tool.test.ts b/packages/ai/src/tools/file-tools/bash-execute-tool.test.ts new file mode 100644 index 000000000..9823d3766 --- /dev/null +++ b/packages/ai/src/tools/file-tools/bash-execute-tool.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { bashExecute } from './bash-execute-tool'; + +describe('bash-execute-tool', () => { + it('should have correct tool configuration', () => { + expect(bashExecute.id).toBe('bash_execute'); + expect(bashExecute.description).toBe('Executes bash commands and captures stdout, stderr, and exit codes'); + expect(bashExecute.inputSchema).toBeDefined(); + expect(bashExecute.outputSchema).toBeDefined(); + expect(bashExecute.execute).toBeDefined(); + }); + + it('should validate input schema for single command', () => { + const singleCommandInput = { + commands: { + command: 'echo "hello"', + description: 'Test command', + timeout: 5000 + } + }; + + const result = bashExecute.inputSchema.safeParse(singleCommandInput); + expect(result.success).toBe(true); + }); + + it('should validate input schema for array of commands', () => { + const arrayCommandInput = { + commands: [ + { command: 'echo "hello"' }, + { command: 'echo "world"', timeout: 1000 } + ] + }; + + const result = bashExecute.inputSchema.safeParse(arrayCommandInput); + expect(result.success).toBe(true); + }); + + it('should validate output schema structure', () => { + const outputExample = { + results: [ + { + command: 'echo "test"', + stdout: 'test', + stderr: undefined, + exitCode: 0, + success: true, + error: undefined + } + ] + }; + + const result = bashExecute.outputSchema.safeParse(outputExample); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/ai/src/tools/file-tools/bash-execute-tool.ts b/packages/ai/src/tools/file-tools/bash-execute-tool.ts new file mode 100644 index 000000000..2a295640d --- /dev/null +++ b/packages/ai/src/tools/file-tools/bash-execute-tool.ts @@ -0,0 +1,128 @@ +import { createTool } from '@mastra/core'; +import type { RuntimeContext } from '@mastra/core/runtime-context'; +import { wrapTraced } from 'braintrust'; +import { z } from 'zod'; +import { spawn } from 'node:child_process'; + +const bashCommandSchema = z.object({ + command: z.string().describe('The bash command to execute'), + description: z.string().optional().describe('Description of what this command does'), + timeout: z.number().optional().describe('Timeout in milliseconds') +}); + +const inputSchema = z.object({ + commands: z.union([ + bashCommandSchema, + z.array(bashCommandSchema) + ]).describe('Single command or array of bash commands to execute') +}); + +const outputSchema = z.object({ + results: z.array(z.object({ + command: z.string(), + stdout: z.string(), + stderr: z.string().optional(), + exitCode: z.number(), + success: z.boolean(), + error: z.string().optional() + })) +}); + +async function executeSingleBashCommand( + command: string, + timeout?: number +): Promise<{ + stdout: string; + stderr: string; + exitCode: number; +}> { + return new Promise((resolve, reject) => { + const child = spawn('bash', ['-c', command], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + let timeoutId: NodeJS.Timeout | undefined; + + if (timeout) { + timeoutId = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error(`Command timed out after ${timeout}ms`)); + }, timeout); + } + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + resolve({ + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: code || 0 + }); + }); + + child.on('error', (error) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + reject(error); + }); + }); +} + +const executeBashCommands = wrapTraced( + async ( + input: z.infer, + runtimeContext?: RuntimeContext + ): Promise> => { + const commands = Array.isArray(input.commands) ? input.commands : [input.commands]; + const results = []; + + for (const cmd of commands) { + try { + const result = await executeSingleBashCommand(cmd.command, cmd.timeout); + + results.push({ + command: cmd.command, + stdout: result.stdout, + stderr: result.stderr || undefined, + exitCode: result.exitCode, + success: result.exitCode === 0, + error: result.exitCode !== 0 ? result.stderr || 'Command failed' : undefined + }); + } catch (error) { + results.push({ + command: cmd.command, + stdout: '', + stderr: undefined, + exitCode: 1, + success: false, + error: error instanceof Error ? error.message : 'Unknown execution error' + }); + } + } + + return { results }; + }, + { name: 'bash-execute-tool' } +); + +export const bashExecute = createTool({ + id: 'bash_execute', + description: 'Executes bash commands and captures stdout, stderr, and exit codes', + inputSchema, + outputSchema, + execute: async ({ context, runtimeContext }) => { + return await executeBashCommands(context, runtimeContext); + } +}); diff --git a/packages/ai/src/tools/file-tools/index.ts b/packages/ai/src/tools/file-tools/index.ts new file mode 100644 index 000000000..8525f1a62 --- /dev/null +++ b/packages/ai/src/tools/file-tools/index.ts @@ -0,0 +1 @@ +export { bashExecute } from './bash-execute-tool'; diff --git a/packages/ai/src/tools/index.ts b/packages/ai/src/tools/index.ts index 472bc048d..d7e7e62a0 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 { bashExecute } from './file-tools';