185 lines · 5.8 KB · TypeScript
RawDownload
/** * OpenHands runner - open-source AI coding agent *  * Uses `openhands` in headless mode with --always-approve for * non-interactive automated code changes. *  * Install: pip install openhands-ai * Auth: LLM_API_KEY env var (or ANTHROPIC_API_KEY / OPENAI_API_KEY) * Model: LLM_MODEL env var (e.g., anthropic/claude-sonnet-4-5-20250929) *  * WHY: OpenHands ranks #31 on Terminal-Bench 2.0 (51.9% with Claude Opus 4.5). * Fully open source, no model lock-in, supports any LiteLLM-compatible provider. */ import { spawn } from 'child_process';import { promisify } from 'util';import { execFile as execFileCallback } from 'child_process';import type { Runner, RunnerResult, RunnerOptions, RunnerStatus, RunnerErrorType } from './types.js';import { debug, debugPrompt, debugResponse } from '../logger.js';import { isValidModelName } from '../config.js'; const execFile = promisify(execFileCallback); export class OpenHandsRunner implements Runner {  name = 'openhands';  displayName = 'OpenHands';  installHint = 'pip install openhands-ai';   async isAvailable(): Promise<boolean> {    try {      await execFile('which', ['openhands']);      debug('Found OpenHands CLI');      return true;    } catch {      debug('OpenHands CLI not found');      return false;    }  }   async checkStatus(): Promise<RunnerStatus> {    const installed = await this.isAvailable();    if (!installed) {      return { installed: false, ready: false, error: 'Not installed (install: pip install openhands-ai)' };    }     let version: string | undefined;    try {      const { stdout, stderr } = await execFile('openhands', ['--version']);      version = (stdout || stderr).trim();    } catch {      // Version check might fail    }     // OpenHands uses LLM_API_KEY or falls back to provider-specific keys    const hasAnyKey = !!(      process.env.LLM_API_KEY ||      process.env.ANTHROPIC_API_KEY ||      process.env.OPENAI_API_KEY    );     if (!hasAnyKey) {      return {        installed: true,        ready: false,        version,        error: 'No API key set (LLM_API_KEY, ANTHROPIC_API_KEY, or OPENAI_API_KEY)',      };    }     return { installed: true, ready: true, version };  }   async run(workdir: string, prompt: string, options?: RunnerOptions): Promise<RunnerResult> {    if (!prompt || prompt.trim().length === 0) {      debug('Empty prompt - skipping openhands run');      return { success: false, output: '', error: 'No prompt provided (nothing to fix)' };    }     if (options?.model && !isValidModelName(options.model)) {      return { success: false, output: '', error: `Invalid model name: ${options.model}` };    }     const promptSlug = debugPrompt('openhands', prompt, { workdir, model: options?.model });     return new Promise((resolve) => {      // `openhands --headless --always-approve -t "prompt"` for non-interactive execution      const args: string[] = [        '--headless',        '--always-approve',      ];       // Pass task via -t flag      args.push('-t', prompt);       // Build env with model override if specified      // OpenHands uses LLM_MODEL env var (litellm format: provider/model)      const env: Record<string, string> = { ...process.env } as Record<string, string>;      if (options?.model) {        env.LLM_MODEL = options.model;      }       const modelInfo = options?.model ? ` (model: ${options.model})` : '';      console.log(`\nRunning: openhands${modelInfo} --headless --always-approve [prompt]\n`);      debug('OpenHands command', { workdir, model: options?.model, promptLength: prompt.length });       const child = spawn('openhands', args, {        cwd: workdir,        stdio: ['pipe', 'pipe', 'pipe'],        env,      });       child.stdin?.end();       let stdout = '';      let stderr = '';       child.stdout?.on('data', (data) => {        const str = data.toString();        stdout += str;        process.stdout.write(str);      });       child.stderr?.on('data', (data) => {        const str = data.toString();        stderr += str;        process.stderr.write(str);      });       child.on('close', (code) => {        debugResponse(promptSlug, 'openhands', stdout, { exitCode: code, stderrLength: stderr.length });         const combinedOutput = stdout + stderr;         if (/quota exceeded|rate.?limit|too many requests|billing|exceeded.*plan/i.test(combinedOutput)) {          resolve({            success: false,            output: stdout,            error: stderr || 'Quota or rate limit exceeded',            errorType: 'quota' as RunnerErrorType,          });          return;        }         if (/model.*not found|does not exist|unauthorized|authentication|invalid.*key|api.?key/i.test(combinedOutput)) {          resolve({            success: false,            output: stdout,            error: stderr || 'Model or authentication error',            errorType: 'auth' as RunnerErrorType,          });          return;        }         if (/permission.*denied|EACCES|cannot write/i.test(combinedOutput)) {          resolve({            success: false,            output: stdout,            error: stderr || 'Permission denied',            errorType: 'permission' as RunnerErrorType,          });          return;        }         if (code === 0) {          resolve({ success: true, output: stdout });        } else {          let errorType: RunnerErrorType = 'tool';          if (/auth|api.?key|unauthorized/i.test(combinedOutput)) {            errorType = 'auth';          }          resolve({            success: false,            output: stdout,            error: stderr || `Process exited with code ${code}`,            errorType,          });        }      });       child.on('error', (err) => {        resolve({ success: false, output: stdout, error: err.message, errorType: 'tool' });      });    });  }}