feat: add tool framework foundation (types, registry, executor, shell tool, model types, SOUL.md)
- Task 0: SOUL.md + loadSystemPrompt() in daemon - Task 1: Tool type definitions (Tool, ToolCall, ToolResult, etc.) - Task 2: ToolRegistry with Anthropic/OpenAI serialization - Task 3: ToolExecutor with hooks, timeout, truncation - Task 4: shell.exec builtin tool - Task 8: Model types updated for tool use (ToolDefinition, ModelToolCall, etc.) - Task 15: Model index exports for tool types
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shellExecTool } from './shell.js';
|
||||
import { tmpdir } from 'os';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('shell.exec tool', () => {
|
||||
it('has correct metadata', () => {
|
||||
expect(shellExecTool.name).toBe('shell.exec');
|
||||
expect(shellExecTool.inputSchema.required).toContain('command');
|
||||
});
|
||||
|
||||
it('runs a simple command', async () => {
|
||||
const result = await shellExecTool.execute({ command: 'echo hello' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output.trim()).toBe('hello');
|
||||
});
|
||||
|
||||
it('captures stderr on failure', async () => {
|
||||
const result = await shellExecTool.execute({ command: 'ls /nonexistent_dir_xyz' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('respects cwd parameter', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
writeFileSync(join(dir, 'test.txt'), 'content');
|
||||
try {
|
||||
const result = await shellExecTool.execute({ command: 'ls test.txt', cwd: dir });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output.trim()).toBe('test.txt');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('respects timeout parameter', async () => {
|
||||
const result = await shellExecTool.execute({ command: 'sleep 10', timeout: 200 });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timed out');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { execFile } from 'child_process';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
|
||||
interface ShellExecArgs {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export const shellExecTool: Tool = {
|
||||
name: 'shell.exec',
|
||||
description: 'Execute a shell command and return stdout/stderr. Use for running build commands, git operations, system tasks, etc.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: { type: 'string', description: 'The shell command to execute' },
|
||||
cwd: { type: 'string', description: 'Working directory (optional)' },
|
||||
timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000)' },
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
const args = rawArgs as ShellExecArgs;
|
||||
const timeout = args.timeout ?? 30_000;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile('bash', ['-c', args.command], {
|
||||
cwd: args.cwd,
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
if (error.killed || error.signal === 'SIGTERM') {
|
||||
resolve({ success: false, output: stdout, error: `Command timed out after ${timeout}ms` });
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
success: false,
|
||||
output: stdout,
|
||||
error: stderr || error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve({ success: true, output: stdout + (stderr ? `\nstderr: ${stderr}` : '') });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user