diff --git a/src/commands/run.ts b/src/commands/run.ts index 5db67c0..2e97a47 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -125,7 +125,7 @@ export const runCommand = new Command('run') runWithOpenAI(agentDir, manifest, { workspace: options.workspace }); break; case 'crewai': - runWithCrewAI(agentDir, manifest, { workspace: options.workspace }); + runWithCrewAI(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'openclaw': runWithOpenClaw(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); diff --git a/src/runners/crewai.test.ts b/src/runners/crewai.test.ts new file mode 100644 index 0000000..0c42b63 --- /dev/null +++ b/src/runners/crewai.test.ts @@ -0,0 +1,140 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadAgentManifest } from '../utils/loader.js'; +import { runWithCrewAI } from './crewai.js'; + +function makeAgentDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'opengap-crewai-test-')); + writeFileSync( + join(dir, 'agent.yaml'), + "spec_version: '0.1.0'\nname: crew-test\nversion: '0.1.0'\ndescription: A helpful test agent\n", + 'utf-8', + ); + return dir; +} + +test('runs CrewAI through its Python SDK and passes the user prompt safely', () => { + const agentDir = makeAgentDir(); + const manifest = loadAgentManifest(agentDir); + const previousExitCode = process.exitCode; + let pythonCommand = ''; + let scriptPath = ''; + let configPath = ''; + + const fakeSpawn = (command: string, args: readonly string[]) => { + pythonCommand = command; + scriptPath = args[0]; + configPath = args[1]; + + const script = readFileSync(scriptPath, 'utf-8'); + assert.match(script, /from crewai import Agent, Crew, Task/); + assert.match(script, /crew\.kickoff\(inputs=\{"request": config\["request"\]\}\)/); + assert.deepEqual(JSON.parse(readFileSync(configPath, 'utf-8')), { + agents: { + 'crew-test': { + role: 'A helpful test agent', + goal: 'A helpful test agent', + backstory: '', + verbose: true, + allow_delegation: false, + }, + }, + primary_agent: 'crew-test', + request: 'Answer "safely"\nKeep {braces}', + }); + + return { status: 0, error: undefined }; + }; + + try { + runWithCrewAI(agentDir, manifest, { + prompt: ' Answer "safely"\nKeep {braces} ', + workspace: agentDir, + }, fakeSpawn); + + assert.equal(pythonCommand, process.platform === 'win32' ? 'python' : 'python3'); + assert.ok(scriptPath.endsWith('.py')); + assert.ok(configPath.endsWith('.json')); + assert.equal(process.exitCode, 0); + assert.equal(existsSync(scriptPath), false); + assert.equal(existsSync(configPath), false); + } finally { + process.exitCode = previousExitCode; + rmSync(agentDir, { recursive: true, force: true }); + } +}); + +test('uses the agent description when no prompt is provided', () => { + const agentDir = makeAgentDir(); + const manifest = loadAgentManifest(agentDir); + const previousExitCode = process.exitCode; + let request = ''; + + const fakeSpawn = (command: string, args: readonly string[]) => { + request = JSON.parse(readFileSync(args[1], 'utf-8')).request; + return { status: 0, error: undefined }; + }; + + try { + runWithCrewAI(agentDir, manifest, {}, fakeSpawn); + + assert.equal(request, manifest.description); + assert.equal(process.exitCode, 0); + } finally { + process.exitCode = previousExitCode; + rmSync(agentDir, { recursive: true, force: true }); + } +}); + +test('returns failure status and cleans temporary files when Python cannot complete', () => { + const agentDir = makeAgentDir(); + const manifest = loadAgentManifest(agentDir); + const previousExitCode = process.exitCode; + let scriptPath = ''; + let configPath = ''; + + const fakeSpawn = (command: string, args: readonly string[]) => { + scriptPath = args[0]; + configPath = args[1]; + return { status: 7, error: undefined }; + }; + + try { + runWithCrewAI(agentDir, manifest, {}, fakeSpawn); + + assert.equal(process.exitCode, 7); + assert.equal(existsSync(scriptPath), false); + assert.equal(existsSync(configPath), false); + } finally { + process.exitCode = previousExitCode; + rmSync(agentDir, { recursive: true, force: true }); + } +}); + +test('sets a failure exit code and cleans temporary files when Python cannot start', () => { + const agentDir = makeAgentDir(); + const manifest = loadAgentManifest(agentDir); + const previousExitCode = process.exitCode; + let scriptPath = ''; + let configPath = ''; + + const fakeSpawn = (command: string, args: readonly string[]) => { + scriptPath = args[0]; + configPath = args[1]; + return { status: null, error: Object.assign(new Error('Python unavailable'), { code: 'ENOENT' }) }; + }; + + try { + runWithCrewAI(agentDir, manifest, {}, fakeSpawn); + + assert.equal(process.exitCode, 1); + assert.equal(existsSync(scriptPath), false); + assert.equal(existsSync(configPath), false); + } finally { + process.exitCode = previousExitCode; + rmSync(agentDir, { recursive: true, force: true }); + } +}); diff --git a/src/runners/crewai.ts b/src/runners/crewai.ts index c3dc2c4..d24c3a8 100644 --- a/src/runners/crewai.ts +++ b/src/runners/crewai.ts @@ -3,19 +3,66 @@ import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; +import yaml from 'js-yaml'; import { exportToCrewAI } from '../adapters/crewai.js'; import { AgentManifest } from '../utils/loader.js'; import { error, info } from '../utils/format.js'; export interface CrewAIRunOptions { + prompt?: string; workspace?: string; } -export function runWithCrewAI(agentDir: string, _manifest: AgentManifest, options: CrewAIRunOptions = {}): void { - const config = exportToCrewAI(agentDir); - const tmpFile = join(tmpdir(), `gitagent-${randomBytes(4).toString('hex')}.yaml`); +interface CrewAISpawnResult { + status: number | null; + error?: Error; +} + +interface CrewAISpawnOptions { + stdio: 'inherit'; + cwd: string; + env: NodeJS.ProcessEnv; +} + +type CrewAISpawn = ( + command: string, + args: readonly string[], + options: CrewAISpawnOptions, +) => CrewAISpawnResult; + +const crewAIScript = `import json +import sys + +from crewai import Agent, Crew, Task - writeFileSync(tmpFile, config, 'utf-8'); +with open(sys.argv[1], encoding="utf-8") as config_file: + config = json.load(config_file) + +agents = {name: Agent(**settings) for name, settings in config["agents"].items()} +task = Task( + description="Complete the following user request: {request}", + expected_output="A clear, complete response to the user's request.", + agent=agents[config["primary_agent"]], +) +crew = Crew(agents=list(agents.values()), tasks=[task], verbose=True) +result = crew.kickoff(inputs={"request": config["request"]}) +print(result) +`; + +interface CrewAIConfig { + agents: Record>; +} + +export function runWithCrewAI( + agentDir: string, + manifest: AgentManifest, + options: CrewAIRunOptions = {}, + spawn: CrewAISpawn = spawnSync, +): void { + const config = yaml.load(exportToCrewAI(agentDir)) as CrewAIConfig; + const request = options.prompt?.trim() || manifest.description; + const configPath = join(tmpdir(), `opengap-crewai-${randomBytes(8).toString('hex')}.json`); + const scriptPath = join(tmpdir(), `opengap-crewai-${randomBytes(8).toString('hex')}.py`); const runCwd = resolve(options.workspace ?? agentDir); @@ -23,7 +70,15 @@ export function runWithCrewAI(agentDir: string, _manifest: AgentManifest, option info(`Working directory: ${runCwd}`); try { - const result = spawnSync('crewai', ['kickoff', '--config', tmpFile], { + writeFileSync(configPath, JSON.stringify({ + agents: config.agents, + primary_agent: manifest.name, + request, + }), 'utf-8'); + writeFileSync(scriptPath, crewAIScript, 'utf-8'); + + const python = process.platform === 'win32' ? 'python' : 'python3'; + const result = spawn(python, [scriptPath, configPath], { stdio: 'inherit', cwd: runCwd, env: { ...process.env }, @@ -31,13 +86,14 @@ export function runWithCrewAI(agentDir: string, _manifest: AgentManifest, option if (result.error) { error(`Failed to run CrewAI: ${result.error.message}`); - info('Make sure the crewai CLI is installed: pip install crewai'); + info('Make sure the crewai Python package is installed in the active environment: pip install crewai'); process.exitCode = 1; return; } - process.exitCode = result.status ?? 0; + process.exitCode = result.status ?? 1; } finally { - try { unlinkSync(tmpFile); } catch { /* ignore */ } + try { unlinkSync(configPath); } catch { /* ignore */ } + try { unlinkSync(scriptPath); } catch { /* ignore */ } } } diff --git a/src/runners/git.ts b/src/runners/git.ts index f0fafec..f901a10 100644 --- a/src/runners/git.ts +++ b/src/runners/git.ts @@ -100,7 +100,7 @@ export async function runWithGit( runWithOpenAI(agentDir, manifest, { workspace: options.workspace }); break; case 'crewai': - runWithCrewAI(agentDir, manifest, { workspace: options.workspace }); + runWithCrewAI(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace }); break; case 'openclaw': runWithOpenClaw(agentDir, manifest, { prompt: options.prompt, workspace: options.workspace });