Skip to content

Commit 80e26c1

Browse files
authored
feat(benchmark): add benchmark job run, status (#142)
## Description rli bmj run - Run a benchmark job with an agent - --agent <agent> - Agent to use (claude-code, codex, opencode, goose, gemini-cli) - --model <model> - Model name for the agent - --benchmark <id-or-name> - Benchmark ID or name (searches both user and public benchmarks) - --scenarios <ids...> - Alternative: list of scenario IDs - -n, --job-name <name> - Job name - --env-vars, --secrets, --timeout, orchestrator options rli bmj status <id> - Get benchmark job status and results - -w, --wait - Wait for job completion (polls every 10s, up to 1 hour) - Displays results table with pass/fail percentages per agent/model Features 1. Auto-upsert secrets: Automatically creates BMJ_* secrets from environment variables - E.g., ANTHROPIC_API_KEY → BMJ_ANTHROPIC_API_KEY - Skips creation if secret already exists - Logs all secret operations 2. Agent configurations with automatic env var handling: | Agent | Env Vars | Required | |-------------|---------------------------------------------------|-----------| | claude-code | ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN | Yes (any) | | codex | OPENAI_API_KEY | Yes | | opencode | ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY | No | | goose | ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY | No | | gemini-cli | GEMINI_API_KEY, GOOGLE_API_KEY | Yes (any) | 3. Benchmark resolution: Searches both list and listPublic endpoints when resolving benchmark names 4. Default orchestrator config: n_concurrent_trials=10, n_attempts=1, timeout_multiplier=1.0, quiet=false 5. Default agent timeout: 1800 seconds (30 minutes) ## Type of Change <!-- Mark the relevant option with an 'x' --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Test updates ## Related Issues <!-- Link to related issues using #issue-number --> Closes # ## Changes Made <!-- Describe the changes in detail --> ## Testing <!-- Describe how you tested your changes --> - [x] I have tested locally - [ ] I have added/updated tests - [ ] All existing tests pass ## Checklist - [ ] My code follows the code style of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published ## Screenshots (if applicable) <!-- Add screenshots to help explain your changes --> ## Additional Notes <!-- Any additional information that reviewers should know -->
1 parent 4579d91 commit 80e26c1

5 files changed

Lines changed: 692 additions & 0 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,13 @@ rli mcp start # Start the MCP server
181181
rli mcp install # Install Runloop MCP server configurat...
182182
```
183183

184+
### Benchmark-job Commands (alias: `bmj`)
185+
186+
```bash
187+
rli benchmark-job run # Run a benchmark job with an agent
188+
rli benchmark-job status <id> # Get benchmark job status and results
189+
```
190+
184191

185192
## MCP Server (AI Integration)
186193

src/commands/benchmark-job/run.ts

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/**
2+
* Run benchmark job command
3+
*/
4+
5+
import chalk from "chalk";
6+
import { createBenchmarkJob } from "../../services/benchmarkJobService.js";
7+
import {
8+
listBenchmarks,
9+
listPublicBenchmarks,
10+
} from "../../services/benchmarkService.js";
11+
import { getClient } from "../../utils/client.js";
12+
import { output, outputError } from "../../utils/output.js";
13+
14+
// Secret name prefix for benchmark job secrets
15+
const SECRET_PREFIX = "BMJ_";
16+
17+
// Supported agents and their automatic environment variables (mapped to BMJ_* secrets)
18+
// - automaticEnvVars: env vars that will be auto-populated from secrets or environment
19+
// - requiresAny: if true, at least one must be set; if false, just try to auto-populate
20+
const SUPPORTED_AGENTS = {
21+
"claude-code": {
22+
automaticEnvVars: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"],
23+
requiresAny: true, // At least one of these is required
24+
},
25+
codex: {
26+
automaticEnvVars: ["OPENAI_API_KEY"],
27+
requiresAny: true,
28+
},
29+
opencode: {
30+
automaticEnvVars: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"],
31+
requiresAny: false, // Try to auto-populate, but user may configure differently
32+
},
33+
goose: {
34+
automaticEnvVars: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"],
35+
requiresAny: false, // Try to auto-populate, but user may configure differently
36+
},
37+
"gemini-cli": {
38+
automaticEnvVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
39+
requiresAny: true, // At least one of these is required
40+
},
41+
} as const;
42+
43+
type SupportedAgent = keyof typeof SUPPORTED_AGENTS;
44+
45+
interface RunOptions {
46+
agent: string;
47+
model: string;
48+
benchmark?: string;
49+
scenarios?: string[];
50+
jobName?: string;
51+
envVars?: string[];
52+
secrets?: string[];
53+
timeout?: string;
54+
nAttempts?: string;
55+
nConcurrentTrials?: string;
56+
timeoutMultiplier?: string;
57+
output?: string;
58+
}
59+
60+
// Parse environment variables from KEY=value format
61+
function parseEnvVars(envVars: string[]): Record<string, string> {
62+
const result: Record<string, string> = {};
63+
for (const envVar of envVars) {
64+
const eqIndex = envVar.indexOf("=");
65+
if (eqIndex === -1) {
66+
throw new Error(
67+
`Invalid environment variable format: ${envVar}. Expected KEY=value`,
68+
);
69+
}
70+
const key = envVar.substring(0, eqIndex);
71+
const value = envVar.substring(eqIndex + 1);
72+
result[key] = value;
73+
}
74+
return result;
75+
}
76+
77+
// Parse secrets from ENV_VAR=SECRET_NAME format
78+
function parseSecrets(secrets: string[]): Record<string, string> {
79+
const result: Record<string, string> = {};
80+
for (const secret of secrets) {
81+
const eqIndex = secret.indexOf("=");
82+
if (eqIndex === -1) {
83+
throw new Error(
84+
`Invalid secret format: ${secret}. Expected ENV_VAR=SECRET_NAME`,
85+
);
86+
}
87+
const envVarName = secret.substring(0, eqIndex);
88+
const secretName = secret.substring(eqIndex + 1);
89+
result[envVarName] = secretName;
90+
}
91+
return result;
92+
}
93+
94+
// Validate agent is supported
95+
function validateAgent(agent: string): asserts agent is SupportedAgent {
96+
if (!(agent in SUPPORTED_AGENTS)) {
97+
const supportedList = Object.keys(SUPPORTED_AGENTS).join(", ");
98+
throw new Error(
99+
`Unsupported agent: ${agent}. Supported agents: ${supportedList}`,
100+
);
101+
}
102+
}
103+
104+
// Check if a secret exists by name
105+
async function secretExists(secretName: string): Promise<boolean> {
106+
const client = getClient();
107+
// TODO: Fetch by name when API exposed.
108+
const result = await client.secrets.list({ limit: 5000 });
109+
return result.secrets?.some((s) => s.name === secretName) ?? false;
110+
}
111+
112+
// Create a secret
113+
async function createSecret(name: string, value: string): Promise<void> {
114+
const client = getClient();
115+
await client.secrets.create({ name, value });
116+
}
117+
118+
// Ensure agent secrets exist, creating them from env vars if needed
119+
// Returns the secrets mapping (ENV_VAR -> BMJ_ENV_VAR)
120+
async function ensureAgentSecrets(
121+
agent: SupportedAgent,
122+
): Promise<Record<string, string>> {
123+
const agentConfig = SUPPORTED_AGENTS[agent];
124+
const secrets: Record<string, string> = {};
125+
126+
for (const varName of agentConfig.automaticEnvVars) {
127+
const secretName = `${SECRET_PREFIX}${varName}`;
128+
const envValue = process.env[varName];
129+
130+
// Check if secret exists
131+
const exists = await secretExists(secretName);
132+
133+
if (exists) {
134+
console.log(chalk.dim(`Secret ${secretName} exists`));
135+
secrets[varName] = secretName;
136+
} else if (envValue) {
137+
// Create secret from env var
138+
console.log(
139+
chalk.cyan(`Creating secret ${secretName} from ${varName} env var`),
140+
);
141+
await createSecret(secretName, envValue);
142+
secrets[varName] = secretName;
143+
} else {
144+
// No secret and no env var - skip (will be validated later if required)
145+
console.log(
146+
chalk.yellow(
147+
`Secret ${secretName} not found and ${varName} not set in environment`,
148+
),
149+
);
150+
}
151+
}
152+
153+
return secrets;
154+
}
155+
156+
// Resolve benchmark name to ID if needed
157+
async function resolveBenchmarkId(benchmarkIdOrName: string): Promise<string> {
158+
// If it looks like an ID (starts with bm_ or similar), return as-is
159+
if (
160+
benchmarkIdOrName.startsWith("bm_") ||
161+
benchmarkIdOrName.startsWith("bmk_")
162+
) {
163+
return benchmarkIdOrName;
164+
}
165+
166+
// Search both user benchmarks and public benchmarks
167+
const [userResult, publicResult] = await Promise.all([
168+
listBenchmarks({
169+
limit: 100,
170+
search: benchmarkIdOrName,
171+
}),
172+
listPublicBenchmarks({
173+
limit: 100,
174+
search: benchmarkIdOrName,
175+
}),
176+
]);
177+
178+
// Combine results
179+
const allBenchmarks = [...userResult.benchmarks, ...publicResult.benchmarks];
180+
181+
// Look for exact name match
182+
const exactMatch = allBenchmarks.find((b) => b.name === benchmarkIdOrName);
183+
184+
if (exactMatch) {
185+
return exactMatch.id;
186+
}
187+
188+
if (allBenchmarks.length === 0) {
189+
throw new Error(`No benchmark found with name: ${benchmarkIdOrName}`);
190+
}
191+
192+
// If no exact match but we have results, suggest them
193+
const suggestions = allBenchmarks
194+
.slice(0, 5)
195+
.map((b) => ` - ${b.name} (${b.id})`)
196+
.join("\n");
197+
throw new Error(
198+
`No exact match for benchmark "${benchmarkIdOrName}". Did you mean:\n${suggestions}`,
199+
);
200+
}
201+
202+
export async function runBenchmarkJob(options: RunOptions) {
203+
try {
204+
// Validate agent
205+
validateAgent(options.agent);
206+
const agent = options.agent as SupportedAgent;
207+
208+
// Parse provided env vars and secrets
209+
const providedEnvVars = options.envVars
210+
? parseEnvVars(options.envVars)
211+
: {};
212+
const providedSecrets = options.secrets
213+
? parseSecrets(options.secrets)
214+
: {};
215+
216+
// Ensure agent secrets exist (auto-create from env vars if needed)
217+
// Maps ENV_VAR -> BMJ_ENV_VAR (e.g., ANTHROPIC_API_KEY -> BMJ_ANTHROPIC_API_KEY)
218+
const agentSecrets = await ensureAgentSecrets(agent);
219+
220+
// Validate that at least one secret is available (only if requiresAny is true)
221+
const agentConfig = SUPPORTED_AGENTS[agent];
222+
if (agentConfig.requiresAny) {
223+
const hasAny = agentConfig.automaticEnvVars.some(
224+
(varName) => agentSecrets[varName],
225+
);
226+
if (!hasAny) {
227+
throw new Error(
228+
`Agent ${agent} requires at least one of: ${agentConfig.automaticEnvVars.join(", ")}. ` +
229+
`Create secrets (${agentConfig.automaticEnvVars.map((v) => `${SECRET_PREFIX}${v}`).join(", ")}) ` +
230+
`or set environment variables.`,
231+
);
232+
}
233+
}
234+
// If requiresAny is false, we just use whatever secrets were auto-populated
235+
// User may be configuring credentials via other means (e.g., --secrets flag)
236+
237+
// Combine agent secrets with user-provided secrets
238+
const secrets = {
239+
...agentSecrets,
240+
...providedSecrets,
241+
};
242+
243+
// Validate that either benchmark or scenarios is provided, but not both
244+
if (!options.benchmark && !options.scenarios) {
245+
throw new Error("Either --benchmark or --scenarios must be specified");
246+
}
247+
if (options.benchmark && options.scenarios) {
248+
throw new Error("Cannot specify both --benchmark and --scenarios");
249+
}
250+
251+
// Resolve benchmark ID if name was provided
252+
let benchmarkId: string | undefined;
253+
if (options.benchmark) {
254+
benchmarkId = await resolveBenchmarkId(options.benchmark);
255+
}
256+
257+
// Build orchestrator config with defaults
258+
const orchestratorConfig = {
259+
nConcurrentTrials: options.nConcurrentTrials
260+
? parseInt(options.nConcurrentTrials, 10)
261+
: 10,
262+
nAttempts: options.nAttempts ? parseInt(options.nAttempts, 10) : 1,
263+
timeoutMultiplier: options.timeoutMultiplier
264+
? parseFloat(options.timeoutMultiplier)
265+
: 1.0,
266+
quiet: false,
267+
};
268+
269+
// Create the benchmark job
270+
const job = await createBenchmarkJob({
271+
name: options.jobName,
272+
benchmarkId,
273+
scenarioIds: options.scenarios,
274+
agentConfigs: [
275+
{
276+
name: agent,
277+
modelName: options.model,
278+
timeoutSeconds: options.timeout
279+
? parseInt(options.timeout, 10)
280+
: 1800,
281+
environmentVariables:
282+
Object.keys(providedEnvVars).length > 0
283+
? providedEnvVars
284+
: undefined,
285+
secrets,
286+
},
287+
],
288+
orchestratorConfig,
289+
});
290+
291+
// Output result
292+
if (!options.output || options.output === "text") {
293+
console.log(job.id);
294+
} else {
295+
output(job, { format: options.output, defaultFormat: "json" });
296+
}
297+
} catch (error) {
298+
outputError("Failed to run benchmark job", error);
299+
}
300+
}

0 commit comments

Comments
 (0)