Files
tkmind_go/evals/open-model-gym/suite/src/runner.ts
T
2026-02-09 06:08:46 +00:00

1505 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { mkdirSync, writeFileSync, rmSync, readdirSync, existsSync, copyFileSync } from "node:fs";
import { join, basename, dirname } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
import { parse, stringify } from "yaml";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import type { Scenario, TestResult, TestRun, Turn } from "./types.js";
import { validateAll } from "./validator.js";
// =============================================================================
// Types
// =============================================================================
type RunnerType = "goose" | "opencode" | "pi";
interface ModelConfig {
name: string;
provider: string;
model: string;
}
interface RunnerConfig {
name: string;
type: RunnerType;
bin: string;
extensions?: string[]; // goose-specific
stdio?: string[]; // MCP servers
}
interface MatrixEntry {
scenario: string;
models?: string[]; // omit = all models
runners?: string[]; // omit = all runners
}
interface SuiteConfig {
models: ModelConfig[];
runners: RunnerConfig[];
matrix?: MatrixEntry[];
}
// A test pair: scenario × model × runner
interface TestPair {
scenario: Scenario;
model: ModelConfig;
runner: RunnerConfig;
}
interface TestResultWithLog extends TestResult {
logFile: string;
runnerName: string;
toolCalls: number;
turns: number;
cached?: boolean;
}
// =============================================================================
// Cache Types
// =============================================================================
interface CacheInputs {
scenarioHash: string;
modelKey: string;
runnerHash: string;
binaryHash: string;
mcpHarnessHash: string;
}
interface CacheEntry {
timestamp: string;
inputs: CacheInputs;
result: {
status: "passed" | "failed";
validations: Array<{ rule: any; passed: boolean; message?: string }>;
duration: number;
toolCalls: number;
turns: number;
errors?: string[];
};
logFile: string;
}
interface CacheIndex {
version: number;
entries: Record<string, CacheEntry>;
}
// =============================================================================
// Cache Utilities
// =============================================================================
const CACHE_DIR = join(import.meta.dirname, "../.cache");
const CACHE_INDEX_PATH = join(CACHE_DIR, "index.json");
const CACHE_LOGS_DIR = join(CACHE_DIR, "logs");
const CACHE_VERSION = 1;
function sha256(data: string | Buffer): string {
return createHash("sha256").update(data).digest("hex").slice(0, 16);
}
function loadCache(): CacheIndex {
try {
if (existsSync(CACHE_INDEX_PATH)) {
const data = JSON.parse(readFileSync(CACHE_INDEX_PATH, "utf-8"));
if (data.version === CACHE_VERSION) {
return data;
}
console.log("Cache version mismatch, starting fresh");
}
} catch (e) {
console.log("Cache corrupted, starting fresh");
}
return { version: CACHE_VERSION, entries: {} };
}
function saveCache(cache: CacheIndex): void {
mkdirSync(CACHE_DIR, { recursive: true });
writeFileSync(CACHE_INDEX_PATH, JSON.stringify(cache, null, 2));
}
function getBinaryHash(binName: string): string {
try {
const binaryPath = execSync(`which ${binName}`, { encoding: "utf-8" }).trim();
const binaryContent = readFileSync(binaryPath);
return sha256(binaryContent);
} catch (e) {
// Fallback to version string if we can't read the binary
try {
const version = execSync(`${binName} --version 2>/dev/null || echo "unknown"`, { encoding: "utf-8" }).trim();
return sha256(version);
} catch {
return "unknown";
}
}
}
function getMcpHarnessHash(): string {
const mcpHarnessPath = join(import.meta.dirname, "../../mcp-harness/dist/index.js");
try {
if (existsSync(mcpHarnessPath)) {
return sha256(readFileSync(mcpHarnessPath));
}
} catch (e) {
// Ignore
}
return "no-mcp-harness";
}
function computeCacheKey(pair: TestPair, binaryHashes: Map<string, string>, mcpHarnessHash: string): { key: string; inputs: CacheInputs } {
// Hash scenario content (name + prompt/turns + setup + validate)
const scenarioContent = stringify({
name: pair.scenario.name,
prompt: pair.scenario.prompt,
turns: pair.scenario.turns,
setup: pair.scenario.setup,
validate: pair.scenario.validate,
});
const scenarioHash = sha256(scenarioContent);
// Model key
const modelKey = `${pair.model.provider}/${pair.model.model}`;
// Hash runner config
const runnerContent = JSON.stringify({
name: pair.runner.name,
type: pair.runner.type,
extensions: pair.runner.extensions ?? [],
stdio: pair.runner.stdio ?? [],
});
const runnerHash = sha256(runnerContent);
// Binary hash (cached per binary name)
const binaryHash = binaryHashes.get(pair.runner.bin) ?? "unknown";
const inputs: CacheInputs = {
scenarioHash,
modelKey,
runnerHash,
binaryHash,
mcpHarnessHash,
};
// Combine all into single key
const key = sha256(scenarioHash + modelKey + runnerHash + binaryHash + mcpHarnessHash);
return { key, inputs };
}
function getCachedResult(
cache: CacheIndex,
cacheKey: string,
pair: TestPair,
logsDir: string
): TestResultWithLog | null {
const entry = cache.entries[cacheKey];
if (!entry) return null;
// Verify the cached log file exists
const cachedLogPath = join(CACHE_LOGS_DIR, entry.logFile);
if (!existsSync(cachedLogPath)) {
console.log(` Cache log missing, will re-run`);
delete cache.entries[cacheKey];
return null;
}
// Copy cached log to current logs directory
const testId = `${pair.scenario.name}_${pair.model.name}_${pair.runner.name}`.replace(/[\/\\:]/g, "_");
const logFile = join(logsDir, `${testId}_cached.log`);
mkdirSync(logsDir, { recursive: true });
copyFileSync(cachedLogPath, logFile);
// Reconstruct result
const config = {
provider: pair.model.provider,
model: pair.model.model,
extensions: pair.runner.extensions,
stdio: pair.runner.stdio,
};
const run: TestRun = {
scenario: pair.scenario,
config,
workdir: "", // Not relevant for cached results
startTime: new Date(entry.timestamp),
endTime: new Date(new Date(entry.timestamp).getTime() + entry.result.duration),
status: entry.result.status,
errors: entry.result.errors,
};
return {
run,
validations: entry.result.validations,
logFile,
runnerName: pair.runner.name,
toolCalls: entry.result.toolCalls,
turns: entry.result.turns,
cached: true,
};
}
function storeCacheResult(
cache: CacheIndex,
cacheKey: string,
inputs: CacheInputs,
result: TestResultWithLog
): void {
// Copy log to cache directory
const logFileName = `${cacheKey}.log`;
const cachedLogPath = join(CACHE_LOGS_DIR, logFileName);
mkdirSync(CACHE_LOGS_DIR, { recursive: true });
try {
copyFileSync(result.logFile, cachedLogPath);
} catch (e) {
console.log(` Warning: Could not cache log file`);
return;
}
cache.entries[cacheKey] = {
timestamp: new Date().toISOString(),
inputs,
result: {
status: result.run.status as "passed" | "failed",
validations: result.validations,
duration: result.run.endTime && result.run.startTime
? result.run.endTime.getTime() - result.run.startTime.getTime()
: 0,
toolCalls: result.toolCalls,
turns: result.turns,
errors: result.run.errors,
},
logFile: logFileName,
};
saveCache(cache);
}
function clearCache(): void {
if (existsSync(CACHE_DIR)) {
rmSync(CACHE_DIR, { recursive: true, force: true });
console.log("Cache cleared");
} else {
console.log("No cache to clear");
}
}
// =============================================================================
// Goose Runner
// =============================================================================
const PLATFORM_EXTENSIONS = new Set([
"todo", "skills", "code_execution", "extensionmanager",
"chatrecall", "apps", "imagegenerator"
]);
// Isolated goose config directory
const GOOSE_ROOT = join(import.meta.dirname, "../.goose-root");
const GOOSE_CONFIG_DIR = join(GOOSE_ROOT, "config");
function generateGooseConfig(model: ModelConfig, runner: RunnerConfig): object {
const extensions: Record<string, object> = {};
// Add extensions (detect platform vs builtin)
for (const ext of runner.extensions ?? []) {
if (PLATFORM_EXTENSIONS.has(ext)) {
extensions[ext] = {
enabled: true,
type: "platform",
name: ext,
bundled: true,
};
} else {
extensions[ext] = {
enabled: true,
type: "builtin",
name: ext,
timeout: 300,
bundled: true,
};
}
}
// Add stdio MCP servers
for (const extCmd of runner.stdio ?? []) {
const parts = extCmd.split(" ");
const cmd = parts[0];
const args = parts.slice(1);
const name = basename(args[args.length - 1] || cmd).replace(/\.[^.]+$/, "");
extensions[name] = {
enabled: true,
type: "stdio",
name,
cmd,
args,
timeout: 300,
};
}
return {
extensions,
GOOSE_PROVIDER: model.provider,
GOOSE_MODEL: model.model,
GOOSE_TELEMETRY_ENABLED: false,
};
}
async function runGooseAgent(
model: ModelConfig,
runner: RunnerConfig,
prompt: string,
workdir: string,
sessionName?: string, // If provided, use/continue this session
resume: boolean = false // If true, resume existing session (for turn 2+)
): Promise<string> {
const promptFile = join(workdir, ".goose-prompt.txt");
writeFileSync(promptFile, prompt);
// Write goose config
mkdirSync(GOOSE_CONFIG_DIR, { recursive: true });
const gooseConfig = generateGooseConfig(model, runner);
writeFileSync(join(GOOSE_CONFIG_DIR, "config.yaml"), stringify(gooseConfig));
let cmd: string;
if (sessionName) {
if (resume) {
cmd = `${runner.bin} run -i "${promptFile}" --name "${sessionName}" --resume`;
console.log(` Running: ${runner.bin} run -i <prompt> --name "${sessionName}" --resume`);
} else {
// First turn: create new session with this name
cmd = `${runner.bin} run -i "${promptFile}" --name "${sessionName}"`;
console.log(` Running: ${runner.bin} run -i <prompt> --name "${sessionName}"`);
}
} else {
cmd = `${runner.bin} run -i "${promptFile}" --no-session`;
console.log(` Running: ${runner.bin} run -i <prompt> --no-session`);
}
const output = execSync(cmd, {
cwd: workdir,
env: {
...process.env,
GOOSE_PATH_ROOT: GOOSE_ROOT,
MCP_HARNESS_LOG: join(workdir, "tool-calls.log"),
},
timeout: 5 * 60 * 1000,
encoding: "utf-8",
});
return output;
}
// =============================================================================
// OpenCode Runner
// =============================================================================
// Isolated opencode config directory
const OPENCODE_ROOT = join(import.meta.dirname, "../.opencode-root");
function generateOpenCodeConfig(model: ModelConfig, runner: RunnerConfig, workdir: string): object {
const mcp: Record<string, object> = {};
// Add stdio MCP servers
for (const extCmd of runner.stdio ?? []) {
const parts = extCmd.split(" ");
const cmd = parts[0];
const args = parts.slice(1);
const name = basename(args[args.length - 1] || cmd).replace(/\.[^.]+$/, "");
mcp[name] = {
type: "local",
command: [cmd, ...args],
enabled: true,
environment: {
MCP_HARNESS_LOG: join(workdir, "tool-calls.log"),
},
};
}
const config: Record<string, any> = {
$schema: "https://opencode.ai/config.json",
mcp,
};
// Handle ollama as a custom provider (OpenCode doesn't have built-in ollama support)
if (model.provider === "ollama") {
config.model = `ollama/${model.model}`;
config.provider = {
ollama: {
npm: "@ai-sdk/openai-compatible",
name: "Ollama (local)",
options: {
baseURL: "http://localhost:11434/v1",
},
models: {
[model.model]: {
name: model.name,
},
},
},
};
} else {
// Standard providers (anthropic, openai, etc.)
config.model = `${model.provider}/${model.model}`;
}
return config;
}
async function runOpenCodeAgent(
model: ModelConfig,
runner: RunnerConfig,
prompt: string,
workdir: string,
resume: boolean = false
): Promise<string> {
// Write opencode.json config to workdir
const openCodeConfig = generateOpenCodeConfig(model, runner, workdir);
writeFileSync(join(workdir, "opencode.json"), JSON.stringify(openCodeConfig, null, 2));
// Write prompt to file (use cat to avoid shell escaping issues)
const promptFile = join(workdir, ".opencode-prompt.txt");
writeFileSync(promptFile, prompt);
// Ensure isolated config directory exists
mkdirSync(OPENCODE_ROOT, { recursive: true });
// Use --continue on turn 2+ to continue last session
const continueFlag = resume ? "--continue " : "";
const cmd = `${runner.bin} run ${continueFlag}"$(cat "${promptFile}")"`;
console.log(` Running: ${runner.bin} run ${continueFlag}"<prompt>"`);
const output = execSync(cmd, {
cwd: workdir,
env: {
...process.env,
XDG_CONFIG_HOME: OPENCODE_ROOT,
XDG_DATA_HOME: OPENCODE_ROOT,
},
timeout: 5 * 60 * 1000,
encoding: "utf-8",
shell: "/bin/bash",
});
return output;
}
// =============================================================================
// Pi Runner
// =============================================================================
// Pi takes --provider and --model as CLI arguments
// MCP support via pi-mcp-adapter: `pi install npm:pi-mcp-adapter`
// Isolated Pi config directory (like Goose/OpenCode)
const PI_CONFIG_DIR = join(import.meta.dirname, "../.pi-root");
// User's real Pi config (for copying auth.json)
const PI_USER_CONFIG = join(homedir(), ".pi", "agent");
/**
* Generate models.json for Pi with the test model.
* For ollama models, we need to define them since Pi doesn't have built-in ollama support.
*/
function generatePiModelsConfig(model: ModelConfig): object {
// Only generate config for ollama provider (others are built-in)
if (model.provider !== "ollama") {
return { providers: {} };
}
return {
providers: {
ollama: {
baseUrl: "http://localhost:11434/v1",
api: "openai-completions",
apiKey: "ollama", // Ollama doesn't need a real key
models: [
{
id: model.model,
name: model.name,
reasoning: false,
input: ["text"],
contextWindow: 128000,
maxTokens: 32768,
compat: {
supportsUsageInStreaming: false,
maxTokensField: "max_tokens",
supportsDeveloperRole: false
}
}
]
}
}
};
}
async function runPiAgent(
model: ModelConfig,
runner: RunnerConfig,
prompt: string,
workdir: string,
sessionName?: string, // If provided, use/continue this session (for multi-turn)
resume: boolean = false // If true, continue existing session (for turn 2+)
): Promise<string> {
// Write prompt to file (use cat to avoid shell escaping issues)
const promptFile = join(workdir, ".pi-prompt.txt");
writeFileSync(promptFile, prompt);
// Set up isolated Pi config directory
mkdirSync(PI_CONFIG_DIR, { recursive: true });
// Generate models.json with the test model (for ollama)
const modelsConfig = generatePiModelsConfig(model);
writeFileSync(join(PI_CONFIG_DIR, "models.json"), JSON.stringify(modelsConfig, null, 2));
// Copy auth.json from user's config (for API keys)
const userAuthPath = join(PI_USER_CONFIG, "auth.json");
if (existsSync(userAuthPath)) {
copyFileSync(userAuthPath, join(PI_CONFIG_DIR, "auth.json"));
}
// Copy settings.json from user's config (for installed packages like pi-mcp-adapter)
const userSettingsPath = join(PI_USER_CONFIG, "settings.json");
if (existsSync(userSettingsPath)) {
copyFileSync(userSettingsPath, join(PI_CONFIG_DIR, "settings.json"));
}
// If runner has stdio MCP servers, write .pi/mcp.json to the workdir (project config)
// pi-mcp-adapter checks for .pi/mcp.json in cwd, which overrides global config
let hasMcp = false;
if (runner.stdio?.length) {
const mcpConfig: {
mcpServers: Record<string, {
command: string;
args: string[];
lifecycle: string;
env: Record<string, string>;
}>;
settings: { toolPrefix: string };
} = {
mcpServers: {},
settings: {
toolPrefix: "none" // No prefix - use raw tool names
}
// Proxy mode: LLM uses mcp({ search: "..." }) to discover tools on-demand
// This scales better with many MCP tools vs directTools which burns context
};
// Add each stdio server from runner config
runner.stdio.forEach((extCmd, i) => {
const parts = extCmd.split(" ");
const serverName = `harness${i > 0 ? i : ''}`;
mcpConfig.mcpServers[serverName] = {
command: parts[0],
args: parts.slice(1),
lifecycle: "eager", // Connect at startup for tests
env: {
MCP_HARNESS_LOG: join(workdir, "tool-calls.log")
}
};
});
// Write .pi/mcp.json to workdir (project-local config that pi-mcp-adapter finds)
const piConfigDir = join(workdir, ".pi");
mkdirSync(piConfigDir, { recursive: true });
writeFileSync(join(piConfigDir, "mcp.json"), JSON.stringify(mcpConfig, null, 2));
hasMcp = true;
}
// Build base command with provider/model
// -p = non-interactive (print mode)
let cmd = `${runner.bin} -p --provider ${model.provider} --model "${model.model}"`;
// Session handling for multi-turn
if (sessionName) {
const sessionPath = join(workdir, `.pi-session-${sessionName}.jsonl`);
if (resume) {
// Turn 2+: continue the existing session
cmd += ` --continue --session "${sessionPath}"`;
} else {
// Turn 1: create a new session file
cmd += ` --session "${sessionPath}"`;
}
} else {
// Single-turn: don't save session
cmd += ` --no-session`;
}
cmd += ` "$(cat "${promptFile}")"`;
// Build log message
const sessionInfo = sessionName
? (resume ? ` --continue --session <session>` : ` --session <session>`)
: ` --no-session`;
console.log(` Running: ${runner.bin} -p${sessionInfo} --provider ${model.provider} --model "${model.model}"${hasMcp ? ' (mcp)' : ''} "<prompt>"`);
const output = execSync(cmd, {
cwd: workdir,
env: {
...process.env,
PI_CODING_AGENT_DIR: PI_CONFIG_DIR, // Use isolated config dir
MCP_HARNESS_LOG: join(workdir, "tool-calls.log"),
},
timeout: 5 * 60 * 1000,
encoding: "utf-8",
shell: "/bin/bash",
});
return output;
}
// =============================================================================
// Unified Runner
// =============================================================================
interface AgentResult {
output: string;
sessionId?: string; // For multi-turn (goose, pi)
}
async function runAgent(
model: ModelConfig,
runner: RunnerConfig,
prompt: string,
workdir: string,
sessionId?: string, // For multi-turn (goose, pi)
resume: boolean = false // For multi-turn: true on turn 2+
): Promise<AgentResult> {
if (runner.type === "opencode") {
const output = await runOpenCodeAgent(model, runner, prompt, workdir, resume);
return { output };
}
if (runner.type === "pi") {
const output = await runPiAgent(model, runner, prompt, workdir, sessionId, resume);
return { output, sessionId };
}
const output = await runGooseAgent(model, runner, prompt, workdir, sessionId, resume);
return { output, sessionId };
}
// =============================================================================
// Scenario & Config Loading
// =============================================================================
function loadScenario(path: string): Scenario {
const content = readFileSync(path, "utf-8");
return parse(content) as Scenario;
}
function loadAllScenarios(dir: string): Scenario[] {
const files = readdirSync(dir).filter((f) => f.endsWith(".yaml"));
return files.map((f) => loadScenario(join(dir, f)));
}
function loadConfig(configPath: string): SuiteConfig {
const content = readFileSync(configPath, "utf-8");
const config = parse(content) as SuiteConfig;
const configDir = join(configPath, "..");
// Resolve relative paths in stdio for all runners
for (const runner of config.runners) {
if (runner.stdio) {
runner.stdio = runner.stdio.map((ext) => {
const parts = ext.split(" ");
const cmd = parts[0];
const args = parts.slice(1).map((arg) => {
if (!arg.startsWith("/") && (arg.includes("/") || arg.startsWith("."))) {
return join(configDir, arg);
}
return arg;
});
return [cmd, ...args].join(" ");
});
}
}
return config;
}
function setupWorkdir(scenario: Scenario, workdir: string): void {
rmSync(workdir, { recursive: true, force: true });
mkdirSync(workdir, { recursive: true });
if (scenario.setup) {
for (const [path, content] of Object.entries(scenario.setup)) {
const fullPath = join(workdir, path);
mkdirSync(join(fullPath, ".."), { recursive: true });
writeFileSync(fullPath, content);
}
}
}
// =============================================================================
// Log Metrics Parsing
// =============================================================================
function parseLogMetrics(logContent: string, workdir?: string): { toolCalls: number; turns: number } {
// First, try to read tool-calls.log from MCP harness (most accurate)
let mcpToolCalls = 0;
if (workdir) {
try {
const toolCallsLog = readFileSync(join(workdir, "tool-calls.log"), "utf-8");
// Each line is a JSON object representing one tool call
mcpToolCalls = toolCallsLog.trim().split("\n").filter(line => line.trim()).length;
} catch (e) {
// tool-calls.log doesn't exist, fall back to log parsing
}
}
// Goose format: ─── tool_name | extension ───
const gooseToolCalls = (logContent.match(/─── .+ \| .+ ───/g) || []).length;
// OpenCode format: TURN N
const opencodeTurns = (logContent.match(/^TURN \d+$/gm) || []).length;
// Total tool calls = MCP harness calls + Goose built-in tool calls
const toolCalls = mcpToolCalls + gooseToolCalls;
// For OpenCode, use explicit TURN markers
const turns = opencodeTurns > 0 ? opencodeTurns : Math.ceil(toolCalls / 3); // Estimate ~3 tool calls per turn
return { toolCalls, turns };
}
// =============================================================================
// Test Execution
// =============================================================================
function buildTestPairs(config: SuiteConfig, scenarios: Scenario[]): TestPair[] {
const modelsByName = new Map(config.models.map((m) => [m.name, m]));
const runnersByName = new Map(config.runners.map((r) => [r.name, r]));
const scenariosByName = new Map(scenarios.map((s) => [s.name, s]));
const pairs: TestPair[] = [];
if (config.matrix?.length) {
for (const entry of config.matrix) {
// Validate scenario name
const scenario = scenariosByName.get(entry.scenario);
if (!scenario) {
throw new Error(`Unknown scenario "${entry.scenario}" in matrix. Available: ${[...scenariosByName.keys()].join(", ")}`);
}
// Validate model names
if (entry.models) {
for (const name of entry.models) {
if (!modelsByName.has(name)) {
throw new Error(`Unknown model "${name}" in matrix entry for scenario "${entry.scenario}". Available: ${[...modelsByName.keys()].join(", ")}`);
}
}
}
// Validate runner names
if (entry.runners) {
for (const name of entry.runners) {
if (!runnersByName.has(name)) {
throw new Error(`Unknown runner "${name}" in matrix entry for scenario "${entry.scenario}". Available: ${[...runnersByName.keys()].join(", ")}`);
}
}
}
const models = entry.models
? entry.models.map((n) => modelsByName.get(n)).filter(Boolean) as ModelConfig[]
: config.models;
const runners = entry.runners
? entry.runners.map((n) => runnersByName.get(n)).filter(Boolean) as RunnerConfig[]
: config.runners;
for (const model of models) {
for (const runner of runners) {
pairs.push({ scenario, model, runner });
}
}
}
return pairs;
}
// No matrix: all scenarios × all models × all runners
for (const scenario of scenarios) {
for (const model of config.models) {
for (const runner of config.runners) {
pairs.push({ scenario, model, runner });
}
}
}
return pairs;
}
function scoreResult(result: TestResultWithLog): number {
if (result.run.status === "failed" && result.run.errors?.length) {
return -1;
}
const passedCount = result.validations.filter((v) => v.passed).length;
const statusBonus = result.run.status === "passed" ? 1000 : 0;
return statusBonus + passedCount;
}
async function runScenario(
pair: TestPair,
baseWorkdir: string,
logsDir: string,
attempt: number = 1
): Promise<TestResultWithLog> {
const { scenario, model, runner } = pair;
const testId = `${scenario.name}_${model.name}_${runner.name}`.replace(/[\/\\:]/g, "_");
const workdir = join(baseWorkdir, testId);
const logFile = join(logsDir, `${testId}_attempt${attempt}.log`);
console.log(`\n▶ ${scenario.name} [${model.provider}/${model.model}] (${runner.name})`);
setupWorkdir(scenario, workdir);
mkdirSync(logsDir, { recursive: true });
// Create a minimal config for TestRun compatibility
const config = {
provider: model.provider,
model: model.model,
extensions: runner.extensions,
stdio: runner.stdio,
};
const run: TestRun = {
scenario,
config,
workdir,
startTime: new Date(),
status: "running",
};
// Determine if this is a multi-turn or single-turn scenario
const turns = scenario.turns ?? [
{ prompt: scenario.prompt!, validate: scenario.validate ?? [] }
];
const isMultiTurn = turns.length > 1;
// For goose/pi: generate session ID upfront
// For opencode: capture session ID from first turn's output
let sessionId: string | undefined = isMultiTurn && (runner.type === "goose" || runner.type === "pi")
? `test_${testId}_${Date.now()}`
: undefined;
let output = "";
const allValidations: Array<{ rule: any; passed: boolean; message?: string }> = [];
try {
for (let turnIndex = 0; turnIndex < turns.length; turnIndex++) {
const turn = turns[turnIndex];
const turnLabel = isMultiTurn ? ` [turn ${turnIndex + 1}/${turns.length}]` : "";
console.log(` Running${turnLabel}...`);
// Run the agent (with session for multi-turn)
const resume = turnIndex > 0; // Resume session on turn 2+
const result = await runAgent(model, runner, turn.prompt, workdir, sessionId, resume);
// Capture session ID from first turn (for opencode)
if (turnIndex === 0 && result.sessionId) {
sessionId = result.sessionId;
}
output += `\n${'='.repeat(60)}\nTURN ${turnIndex + 1}\n${'='.repeat(60)}\n${result.output}`;
// Validate this turn
const turnValidations = validateAll(turn.validate, workdir);
for (const v of turnValidations) {
allValidations.push({
rule: v.rule,
passed: v.result.passed,
message: v.result.message,
});
}
// If any validation failed, stop early
const turnPassed = turnValidations.every((v) => v.result.passed);
if (!turnPassed) {
console.log(` Turn ${turnIndex + 1} failed validation`);
break;
}
}
run.endTime = new Date();
const allPassed = allValidations.every((v) => v.passed);
writeFileSync(logFile, output);
const metrics = parseLogMetrics(output, workdir);
return {
run: { ...run, status: allPassed ? "passed" : "failed" },
validations: allValidations,
logFile,
runnerName: runner.name,
toolCalls: metrics.toolCalls,
turns: metrics.turns,
};
} catch (err) {
const errorOutput = output + "\n\nERROR:\n" + String(err);
writeFileSync(logFile, errorOutput);
return {
run: {
...run,
status: "failed",
endTime: new Date(),
errors: [String(err)],
},
validations: allValidations,
logFile,
runnerName: runner.name,
toolCalls: parseLogMetrics(errorOutput, workdir).toolCalls,
turns: parseLogMetrics(errorOutput, workdir).turns,
};
}
}
// =============================================================================
// Reporting
// =============================================================================
function pairKey(pair: TestPair): string {
return `${pair.model.name}::${pair.runner.name}`;
}
function resultKey(result: TestResultWithLog): string {
return `${result.run.config.provider}/${result.run.config.model}::${result.runnerName}`;
}
interface ReportOptions {
isRunning?: boolean;
allPairs?: TestPair[];
}
function generateHtmlReport(
results: TestResultWithLog[],
outputPath: string,
options: ReportOptions = {}
): void {
const { isRunning = false, allPairs = [] } = options;
// Read and embed gym.png as base64
const rootDir = join(outputPath, "..");
let gymBase64 = "";
try {
const gymPath = join(rootDir, "gym.png");
gymBase64 = readFileSync(gymPath).toString("base64");
} catch (e) {
// gym.png not found, will use external reference
}
// Collect all logs for embedding
const logsData: Record<string, string> = {};
for (const r of results) {
if (r.logFile) {
try {
logsData[basename(r.logFile)] = readFileSync(r.logFile, "utf-8");
} catch (e) { /* ignore missing logs */ }
}
}
// Calculate max duration for scaling bars
const maxDuration = Math.max(...results.map(r => {
if (!r.run.endTime || !r.run.startTime) return 0;
return (r.run.endTime.getTime() - r.run.startTime.getTime()) / 1000;
}), 1);
const maxToolCalls = Math.max(...results.map(r => r.toolCalls || 0), 1);
// Get all scenarios (columns)
const scenarios = allPairs.length
? [...new Set(allPairs.map((p) => p.scenario.name))]
: [...new Set(results.map((r) => r.run.scenario.name))];
// Rows are model × runner combinations
const rowKeys = allPairs.length
? [...new Set(allPairs.map(pairKey))]
: [...new Set(results.map((r) => `${r.run.config.provider}/${r.run.config.model}::${r.runnerName}`))];
// Map row key -> pair info
const rowsByKey = new Map<string, { model: ModelConfig; runner: RunnerConfig }>();
for (const pair of allPairs) {
rowsByKey.set(pairKey(pair), { model: pair.model, runner: pair.runner });
}
// Group rows by model for rowspan display
const modelKey = (m: ModelConfig) => `${m.provider}/${m.model}`;
const modelGroups = new Map<string, string[]>(); // modelKey -> rowKeys[]
for (const key of rowKeys) {
const row = rowsByKey.get(key);
if (!row) continue;
const mk = modelKey(row.model);
if (!modelGroups.has(mk)) modelGroups.set(mk, []);
modelGroups.get(mk)!.push(key);
}
// Build set of valid (scenario, rowKey) combinations from the matrix
const validCells = new Set<string>();
for (const pair of allPairs) {
validCells.add(`${pair.scenario.name}::${pairKey(pair)}`);
}
const getResult = (scenario: string, rowKey: string) => {
const [modelPart, runnerName] = rowKey.split("::");
return results.find(
(r) =>
r.run.scenario.name === scenario &&
`${r.run.config.provider}/${r.run.config.model}` === `${rowsByKey.get(rowKey)?.model.provider}/${rowsByKey.get(rowKey)?.model.model}` &&
r.runnerName === runnerName
);
};
const passed = results.filter((r) => r.run.status === "passed").length;
const failed = results.filter((r) => r.run.status === "failed").length;
const total = allPairs.length || results.length;
const pending = total - results.length;
const runnerNames = [...new Set(allPairs.map((p) => p.runner.name))];
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${isRunning ? "Running..." : "Results"} - Agent Gym Workout</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; }
h1 { color: #58a6ff; margin-bottom: 0.5rem; }
.summary { color: #8b949e; margin-bottom: 2rem; font-size: 1.1rem; }
.summary .passed { color: #3fb950; }
.summary .failed { color: #f85149; }
table { width: 100%; border-collapse: collapse; background: #161b22; border-radius: 8px; overflow: hidden; }
th, td { padding: 1rem; text-align: left; border-bottom: 1px solid #30363d; }
th { background: #21262d; color: #58a6ff; font-weight: 600; }
th:first-child { position: sticky; left: 0; background: #21262d; z-index: 1; }
td:first-child { position: sticky; left: 0; background: #161b22; font-weight: 500; }
.cell { display: flex; align-items: center; gap: 0.5rem; }
.status { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.status.passed { background: #238636; }
.status.failed { background: #da3633; }
.status.pending { background: #6e7681; }
.status.na { background: transparent; border: 1px dashed #30363d; color: #484f58; }
.status.running { background: #9e6a03; animation: pulse 1.5s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.cached-badge { background: #388bfd33; color: #58a6ff; font-size: 0.65rem; padding: 0.1rem 0.3rem; border-radius: 3px; margin-left: 0.25rem; }
.duration { color: #8b949e; font-size: 0.85rem; }
.log-link { color: #58a6ff; font-size: 0.75rem; text-decoration: none; }
.log-link:hover { text-decoration: underline; }
.duration-bar { height: 4px; background: #30363d; border-radius: 2px; margin-top: 4px; overflow: hidden; }
.duration-bar-fill { height: 100%; background: #58a6ff; border-radius: 2px; }
.tool-bar { height: 4px; background: #30363d; border-radius: 2px; margin-top: 2px; overflow: hidden; }
.tool-bar-fill { height: 100%; background: #d29922; border-radius: 2px; }
.metrics { display: flex; gap: 0.5rem; font-size: 0.7rem; color: #8b949e; margin-top: 2px; }
.metrics .tool-calls { color: #d29922; }
.metrics .turns { color: #a371f7; }
.details { font-size: 0.8rem; max-width: 300px; }
.validation { display: flex; align-items: center; gap: 0.25rem; margin-top: 0.25rem; }
.validation.pass { color: #3fb950; }
.validation.fail { color: #f85149; }
.validation-icon { font-size: 0.7rem; }
.row-header { font-size: 0.75rem; }
.row-header .model { display: block; color: #c9d1d9; font-weight: 600; }
.row-header .runner { color: #58a6ff; }
.row-header .runner-type { color: #8b949e; font-size: 0.7rem; }
.model-cell { vertical-align: middle; background: #1c2128 !important; border-right: 2px solid #30363d; }
.model-group-start { border-top: 2px solid #30363d; }
.runner-info { color: #6e7681; font-size: 0.85rem; margin-bottom: 1.5rem; }
.runner-info code { background: #21262d; padding: 0.2rem 0.4rem; border-radius: 4px; font-family: monospace; }
.timestamp { color: #6e7681; font-size: 0.9rem; margin-top: 2rem; }
.header { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem; }
.header img { height: 48px; width: auto; }
.header h1 { margin: 0; }
.download-btn { background: #238636; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.9rem; margin-left: auto; }
.download-btn:hover { background: #2ea043; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 100; }
.modal.open { display: flex; align-items: center; justify-content: center; }
.modal-content { background: #161b22; border: 1px solid #30363d; border-radius: 8px; width: 90%; max-width: 1200px; max-height: 90%; display: flex; flex-direction: column; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #30363d; }
.modal-header h2 { margin: 0; color: #58a6ff; font-size: 1rem; }
.modal-close { background: none; border: none; color: #8b949e; font-size: 1.5rem; cursor: pointer; }
.modal-close:hover { color: #c9d1d9; }
.modal-body { padding: 1rem; overflow: auto; flex: 1; }
.modal-body pre { margin: 0; white-space: pre-wrap; word-wrap: break-word; font-family: monospace; font-size: 0.85rem; color: #c9d1d9; }
</style>
</head>
<body data-running="${isRunning}">
<div class="header"><img src="${gymBase64 ? `data:image/png;base64,${gymBase64}` : 'gym.png'}" alt="Agent Gym"><h1>Agent Gym Workout${isRunning ? " (Running...)" : ""}</h1>${!isRunning ? '<button class="download-btn" onclick="downloadReport()">📥 Download Full Report</button>' : ''}</div>
<p class="summary">
<span class="passed">${passed} passed</span> /
<span class="failed">${failed} failed</span>${pending > 0 ? ` / <span style="color:#9e6a03">${pending} pending</span>` : ""} /
${total} total
</p>
<p class="runner-info">Agent Configurations: ${runnerNames.map(n => `<code>${n}</code>`).join(", ")}</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Agent Configuration</th>
${scenarios.map((s) => `<th>${s}</th>`).join("")}
</tr>
</thead>
<tbody>
${[...modelGroups.entries()].map(([mk, keys]) => {
return keys.map((key, idx) => {
const row = rowsByKey.get(key);
if (!row) return "";
const { model, runner } = row;
const isFirst = idx === 0;
const rowspan = keys.length;
return `
<tr class="${isFirst ? 'model-group-start' : ''}">
${isFirst ? `<td class="model-cell" rowspan="${rowspan}"><div class="row-header">
<span class="model">${model.provider}/${model.model}</span>
</div></td>` : ''}
<td><div class="row-header">
<span class="runner">${runner.name}</span>
<span class="runner-type">(${runner.type})</span>
</div></td>
${scenarios.map((scenario) => {
const r = getResult(scenario, key);
if (!r) {
// Check if this combination is in the matrix
const cellKey = `${scenario}::${key}`;
const isInMatrix = validCells.has(cellKey);
if (!isInMatrix) return `<td><div class="cell"><span class="status na">—</span></div></td>`;
return `<td><div class="cell"><span class="status pending">⋯</span></div></td>`;
}
if (r.run.status === "running") {
return `<td><div class="cell"><span class="status running">...</span></div></td>`;
}
const duration = r.run.endTime
? ((r.run.endTime.getTime() - r.run.startTime.getTime()) / 1000).toFixed(1)
: "-";
const logPath = r.logFile ? `logs/${basename(r.logFile)}` : "";
const validationHtml = r.validations.map((v) => {
const icon = v.passed ? "✓" : "✗";
const cls = v.passed ? "pass" : "fail";
const ruleLabel = (v.rule as any).name
? (v.rule as any).name
: v.rule.type === "tool_called"
? `tool_called: ${(v.rule as any).tool}`
: v.rule.type + (("path" in v.rule) ? `: ${(v.rule as any).path}` : "");
return `<div class="validation ${cls}"><span class="validation-icon">${icon}</span> ${ruleLabel}</div>`;
}).join("");
return `<td>
<div class="cell">
<span class="status ${r.run.status}">${r.run.status === "passed" ? "✓" : "✗"}</span>
${r.cached ? '<span class="cached-badge">cached</span>' : ''}
<span class="duration">${duration}s</span>
${logPath ? `<a class="log-link" href="${logPath}" onclick="event.preventDefault();showLog('${basename(r.logFile!)}')">log</a>` : ""}
</div>
<div class="duration-bar"><div class="duration-bar-fill" style="width: ${Math.round((parseFloat(duration) / maxDuration) * 100)}%"></div></div>
<div class="tool-bar"><div class="tool-bar-fill" style="width: ${Math.round(((r.toolCalls || 0) / maxToolCalls) * 100)}%"></div></div>
<div class="metrics">
<span class="tool-calls" title="Tool calls">🔧 ${r.toolCalls || 0}</span>
<span class="turns" title="Turns">↻ ${r.turns || 0}</span>
</div>
<div class="details">${validationHtml}</div>
</td>`;
}).join("")}
</tr>`;
}).join("");
}).join("")}
</tbody>
</table>
<p class="timestamp">Generated: ${new Date().toISOString()}</p>
<!-- Log Modal -->
<div id="logModal" class="modal" onclick="if(event.target===this)closeModal()">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Log</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<pre id="modalLog"></pre>
</div>
</div>
</div>
<script>
const LOGS = ${JSON.stringify(logsData)};
function showLog(logName) {
const log = LOGS[logName];
if (!log) return;
document.getElementById('modalTitle').textContent = logName;
document.getElementById('modalLog').textContent = log;
document.getElementById('logModal').classList.add('open');
}
function closeModal() {
document.getElementById('logModal').classList.remove('open');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
function downloadReport() {
const html = document.documentElement.outerHTML;
const blob = new Blob(['<!DOCTYPE html>' + html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'agent-gym-report-' + new Date().toISOString().slice(0,10) + '.html';
a.click();
URL.revokeObjectURL(url);
}
// Smart refresh - fetch new page and swap body content
${isRunning ? `
(function() {
const REFRESH_INTERVAL = 2000;
async function smartRefresh() {
// Don't refresh if modal is open
if (document.getElementById('logModal').classList.contains('open')) {
setTimeout(smartRefresh, REFRESH_INTERVAL);
return;
}
try {
const response = await fetch(location.href + '?t=' + Date.now(), { cache: 'no-store' });
const html = await response.text();
// Parse the new HTML
const parser = new DOMParser();
const newDoc = parser.parseFromString(html, 'text/html');
// Check if still running via data attribute
const stillRunning = newDoc.body.dataset.running === 'true';
// Replace entire body content but preserve scroll position
const scrollY = window.scrollY;
document.body.innerHTML = newDoc.body.innerHTML;
document.body.dataset.running = newDoc.body.dataset.running;
document.title = newDoc.title;
window.scrollTo(0, scrollY);
// Re-extract LOGS from the new script
const newScript = newDoc.querySelector('script');
if (newScript) {
const scriptText = newScript.textContent;
const startMarker = 'const LOGS = ';
const startIdx = scriptText.indexOf(startMarker);
if (startIdx !== -1) {
const jsonStart = startIdx + startMarker.length;
let depth = 0;
let jsonEnd = jsonStart;
for (let i = jsonStart; i < scriptText.length; i++) {
if (scriptText[i] === '{') depth++;
else if (scriptText[i] === '}') {
depth--;
if (depth === 0) {
jsonEnd = i + 1;
break;
}
}
}
try {
window.LOGS = JSON.parse(scriptText.slice(jsonStart, jsonEnd));
} catch (e) {}
}
}
if (stillRunning) {
setTimeout(smartRefresh, REFRESH_INTERVAL);
}
} catch (e) {
setTimeout(smartRefresh, REFRESH_INTERVAL * 2);
}
}
setTimeout(smartRefresh, REFRESH_INTERVAL);
})();
` : ''}
</script>
</body>
</html>`;
writeFileSync(outputPath, html);
console.log(`\n📊 Report saved to: ${outputPath}`);
}
function printResults(results: TestResultWithLog[]): void {
console.log("\n" + "=".repeat(60));
console.log("RESULTS");
console.log("=".repeat(60));
for (const result of results) {
const icon = result.run.status === "passed" ? "✓" : "✗";
const { scenario, config } = result.run;
console.log(
`${icon} ${scenario.name} [${config.provider}/${config.model}] (${result.runnerName}) - ${result.run.status.toUpperCase()}`
);
for (const v of result.validations) {
if (!v.passed) {
console.log(` ✗ ${v.message}`);
}
}
}
const passed = results.filter((r) => r.run.status === "passed").length;
console.log(`\n${passed}/${results.length} tests passed`);
}
// =============================================================================
// Main
// =============================================================================
async function main() {
// CLI --clear-cache: clear cache and exit
if (process.argv.includes("--clear-cache")) {
clearCache();
return;
}
const rootDir = join(import.meta.dirname, "../..");
const configPath = join(rootDir, "config.yaml");
const scenariosDir = join(import.meta.dirname, "../scenarios");
const workdir = join(import.meta.dirname, "../.workdir");
const logsDir = join(rootDir, "logs");
const reportPath = join(rootDir, "report.html");
const config = loadConfig(configPath);
let scenarios = loadAllScenarios(scenariosDir);
// CLI --scenario= filter
const scenarioFilter = process.argv.find((a) => a.startsWith("--scenario="))?.split("=")[1];
if (scenarioFilter) {
const filters = scenarioFilter.split(",");
scenarios = scenarios.filter((s) => filters.some((f) => s.name.includes(f)));
}
// CLI --model= filter
const modelFilter = process.argv.find((a) => a.startsWith("--model="))?.split("=")[1];
if (modelFilter) {
const filters = modelFilter.split(",");
config.models = config.models.filter((m) => filters.some((f) => m.name.includes(f)));
}
// CLI --runner= filter
const runnerFilter = process.argv.find((a) => a.startsWith("--runner="))?.split("=")[1];
if (runnerFilter) {
const filters = runnerFilter.split(",");
config.runners = config.runners.filter((r) => filters.some((f) => r.name.includes(f)));
}
const pairs = buildTestPairs(config, scenarios);
// Sort pairs by model name so same models run together (keeps model loaded in memory)
pairs.sort((a, b) => a.model.name.localeCompare(b.model.name));
// Show model grouping
const modelOrder = [...new Set(pairs.map(p => p.model.name))];
console.log(`\nExecution order (grouped by model for efficiency):`);
for (const m of modelOrder) {
const count = pairs.filter(p => p.model.name === m).length;
console.log(` ${m}: ${count} tests`);
}
// CLI --run-count=N (default 1)
const runCountArg = process.argv.find((a) => a.startsWith("--run-count="))?.split("=")[1];
const RUN_COUNT = runCountArg ? parseInt(runCountArg, 10) : 1;
// CLI --no-cache: skip cache lookup (still stores results)
const noCache = process.argv.includes("--no-cache");
// Load cache and precompute hashes
const cache = loadCache();
const binaryHashes = new Map<string, string>();
for (const runner of config.runners) {
if (!binaryHashes.has(runner.bin)) {
console.log(`Computing hash for ${runner.bin}...`);
binaryHashes.set(runner.bin, getBinaryHash(runner.bin));
}
}
const mcpHarnessHash = getMcpHarnessHash();
console.log(`Models: ${config.models.map((m) => m.name).join(", ")}`);
console.log(`Runners: ${config.runners.map((r) => r.name).join(", ")}`);
console.log(`Running ${pairs.length} test pairs (${RUN_COUNT}x each, worst result kept)`);
console.log(`Cache: ${noCache ? "disabled" : "enabled"} (${Object.keys(cache.entries).length} entries)`);
const results: TestResultWithLog[] = [];
// CLI --no-open to skip opening browser
const noOpen = process.argv.includes("--no-open");
let cacheHits = 0;
let cacheMisses = 0;
let browserOpened = false;
for (const pair of pairs) {
// Check cache first
const { key: cacheKey, inputs: cacheInputs } = computeCacheKey(pair, binaryHashes, mcpHarnessHash);
if (!noCache) {
const cachedResult = getCachedResult(cache, cacheKey, pair, logsDir);
if (cachedResult) {
console.log(`\n${cachedResult.run.status === "passed" ? "✓" : "✗"} ${pair.scenario.name} [${pair.model.name}] (${pair.runner.name}) [CACHED]`);
results.push(cachedResult);
cacheHits++;
continue;
}
}
// First cache miss - generate report with cached results so far and open browser
if (!browserOpened) {
generateHtmlReport(results, reportPath, { isRunning: true, allPairs: pairs });
if (!noOpen) {
execSync(`open "${reportPath}"`);
}
browserOpened = true;
}
cacheMisses++;
let worstResult: TestResultWithLog | null = null;
for (let attempt = 1; attempt <= RUN_COUNT; attempt++) {
console.log(` Attempt ${attempt}/${RUN_COUNT} [${pair.runner.name}]`);
const result = await runScenario(pair, workdir, logsDir, attempt);
if (!worstResult) {
worstResult = result;
} else {
const prevScore = scoreResult(worstResult);
const currScore = scoreResult(result);
if (currScore < prevScore) {
worstResult = result;
}
}
if (result.run.status === "failed") {
break;
}
}
// Store in cache
storeCacheResult(cache, cacheKey, cacheInputs, worstResult!);
results.push(worstResult!);
generateHtmlReport(results, reportPath, { isRunning: true, allPairs: pairs });
}
generateHtmlReport(results, reportPath, { isRunning: false, allPairs: pairs });
// If everything was cached, open browser now with final report
if (!browserOpened && !noOpen) {
execSync(`open "${reportPath}"`);
}
printResults(results);
console.log(`\nCache summary: ${cacheHits} hits, ${cacheMisses} misses`);
}
main().catch(console.error);