feat: implement bash_execute tool for BUS-1466

- Add bash_execute tool in packages/ai/src/tools/file-tools/bash-execute-tool.ts
- Support both single commands and arrays of commands
- Use Node's child_process with proper timeout and error handling
- Capture stdout, stderr, and exit codes in structured format
- Add unit tests for schema validation
- Export tool in file-tools index and main tools index

Implements BUS-1466: TypeScript-based bash execution tool with graceful error handling

Co-Authored-By: Dallin Bentley <dallinbentley98@gmail.com>
This commit is contained in:
Devin AI 2025-07-21 08:02:11 +00:00
parent 951e142c6f
commit 973cdedc88
4 changed files with 185 additions and 0 deletions

View File

@ -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);
});
});

View File

@ -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<typeof inputSchema>,
runtimeContext?: RuntimeContext
): Promise<z.infer<typeof outputSchema>> => {
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);
}
});

View File

@ -0,0 +1 @@
export { bashExecute } from './bash-execute-tool';

View File

@ -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';