@sverka/arena 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,1079 +1,2 @@
1
- import { execSync, spawn } from "node:child_process";
2
- import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { join, resolve, sep } from "node:path";
4
- import { homedir, tmpdir } from "node:os";
5
- import { Readable, Writable } from "node:stream";
6
- import * as acp from "@agentclientprotocol/sdk";
7
- import { fileURLToPath } from "node:url";
8
- //#region src/acp.ts
9
- /**
10
- * Shared ACP (Agent Client Protocol) utilities — extracted from the Devin
11
- * adapter so they can be reused by other adapters and the benchmark runner.
12
- *
13
- * This module contains:
14
- * - {@link sanitizeEnv}: strip host-contaminating env vars before spawning an agent.
15
- * - {@link createPermissionHandler}: auto-approve permission requests.
16
- * - {@link runAcpSession}: drive a full ACP connect/initialize/prompt/collect loop.
17
- */
18
- /** Env-var prefixes to strip from the spawned process environment. */
19
- const SANITIZE_PREFIXES = [
20
- "GC_",
21
- "BEADS_",
22
- "BD_",
23
- "MCP_",
24
- "CLAUDECODE",
25
- "CLAUDE_CODE_",
26
- "CODEX_"
27
- ];
28
- /** Exact env-var names to strip from the spawned process environment. */
29
- const SANITIZE_EXACT = /* @__PURE__ */ new Set([
30
- "DEVIN_PERMISSION_MODE",
31
- "DEVIN_MODEL",
32
- "AGENTS_MD",
33
- "CLAUDE_PROJECT_MD"
34
- ]);
35
- /** Check if an env var name should be stripped from the agent environment. */
36
- function shouldSanitize(key) {
37
- if (SANITIZE_EXACT.has(key)) return true;
38
- return SANITIZE_PREFIXES.some((p) => key.startsWith(p));
39
- }
40
- /** Copy process.env into a clean record, dropping sanitized entries. */
41
- function copyCleanEnv() {
42
- const env = {};
43
- for (const [key, value] of Object.entries(process.env)) {
44
- if (value === void 0) continue;
45
- if (shouldSanitize(key)) continue;
46
- env[key] = value;
47
- }
48
- return env;
49
- }
50
- /** Apply model and permission settings to the env record. */
51
- function applyModelEnv(env, config) {
52
- const model = config.model;
53
- env.DEVIN_PERMISSION_MODE = config.permissionMode ?? "dangerous";
54
- env.DEVIN_MODEL = model.envVar ? env[model.envVar] ?? model.id : model.id;
55
- if (model.envVar) env[model.envVar] = env[model.envVar] ?? model.id;
56
- }
57
- /** Merge explicit env overrides from config (these win). */
58
- function mergeEnvOverrides(env, overrides) {
59
- if (!overrides) return;
60
- for (const [key, value] of Object.entries(overrides)) env[key] = value;
61
- }
62
- /**
63
- * Build a sanitized environment for an agent subprocess.
64
- *
65
- * Strips out host contamination: Gas City, Beads, MCP servers, project
66
- * skills, and other variables that would leak the host's configuration
67
- * into the benchmark. The agent must only see what we explicitly install.
68
- *
69
- * Sets `DEVIN_PERMISSION_MODE` and `DEVIN_MODEL` from the spawn config,
70
- * then merges any explicit `config.env` overrides (which win).
71
- */
72
- function sanitizeEnv(config) {
73
- const env = copyCleanEnv();
74
- applyModelEnv(env, config);
75
- mergeEnvOverrides(env, config.env);
76
- return env;
77
- }
78
- /**
79
- * Create a permission-request handler that auto-approves tool calls.
80
- *
81
- * Selects the first `allow_once` or `allow_always` option, falling back to
82
- * the first available option. This is the same handler used by both the
83
- * Devin adapter and the benchmark runner.
84
- */
85
- function createPermissionHandler() {
86
- return (ctx) => {
87
- const options = ctx.params.options;
88
- return { outcome: {
89
- outcome: "selected",
90
- optionId: options.find((o) => o.kind === "allow_once" || o.kind === "allow_always")?.optionId ?? options[0]?.optionId ?? ""
91
- } };
92
- };
93
- }
94
- /**
95
- * Process a single {@link acp.ActiveSessionMessage} from the session update
96
- * stream. Returns the final {@link PromptResponse} when the session reports
97
- * a stop, otherwise invokes the appropriate callback and returns `undefined`
98
- * so the caller can continue collecting.
99
- */
100
- function handleSessionUpdate(message, onToolCall, onToolUpdate) {
101
- if (message.kind === "stop") return message.response;
102
- const update = message.update;
103
- if (update.sessionUpdate === "tool_call") onToolCall?.(update);
104
- else if (update.sessionUpdate === "tool_call_update") onToolUpdate?.(update);
105
- }
106
- /**
107
- * Drive a single ACP session: send the prompt, race the collect loop
108
- * against a timeout, and return the final {@link PromptResponse}.
109
- */
110
- async function runSessionLoop(session, prompt, timeoutMs, onToolCall, onToolUpdate, onSession) {
111
- onSession?.(session.sessionId);
112
- const promptPromise = session.prompt(prompt);
113
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Agent timed out after ${timeoutMs}ms`)), timeoutMs));
114
- const collectPromise = (async () => {
115
- for (;;) {
116
- const result = handleSessionUpdate(await session.nextUpdate(), onToolCall, onToolUpdate);
117
- if (result) return result;
118
- }
119
- })();
120
- return Promise.race([
121
- promptPromise,
122
- collectPromise,
123
- timeoutPromise
124
- ]);
125
- }
126
- /**
127
- * Run a full ACP session against a spawned agent subprocess.
128
- *
129
- * Connects to the agent, initializes the protocol, creates a session in
130
- * `workspace`, sends `prompt`, and collects update events until the session
131
- * stops or `timeoutMs` elapses.
132
- *
133
- * The `onToolCall` and `onToolUpdate` callbacks let callers collect tool
134
- * calls and observations without duplicating the session loop logic. The
135
- * optional `onSession` callback reports the session ID once created. All
136
- * callbacks are optional.
137
- *
138
- * @returns The final {@link PromptResponse} from the agent.
139
- */
140
- async function runAcpSession(proc, workspace, prompt, timeoutMs, onToolCall, onToolUpdate, onSession) {
141
- if (!proc.stdin || !proc.stdout) throw new Error("Agent process missing stdin/stdout");
142
- const input = Writable.toWeb(proc.stdin);
143
- const output = Readable.toWeb(proc.stdout);
144
- const stream = acp.ndJsonStream(input, output);
145
- return acp.client({ name: "sverka-arena" }).onRequest(acp.methods.client.session.requestPermission, createPermissionHandler()).connectWith(stream, async (ctx) => {
146
- await ctx.request(acp.methods.agent.initialize, {
147
- protocolVersion: acp.PROTOCOL_VERSION,
148
- clientCapabilities: {}
149
- });
150
- return ctx.buildSession(workspace).withSession((session) => runSessionLoop(session, prompt, timeoutMs, onToolCall, onToolUpdate, onSession));
151
- });
152
- }
153
- //#endregion
154
- //#region src/adapters/devin.ts
155
- /**
156
- * Devin ACP adapter — spawns `devin acp` as a subprocess and drives an
157
- * Agent Client Protocol session to collect trace data + metrics.
158
- *
159
- * The adapter is abstract over models and plugins: it accepts any
160
- * {@link ModelConfig} and any set of {@link PluginConfig}s, installs the
161
- * enabled plugins into the workspace, and runs the prompt against the
162
- * spawned agent. After the session completes it reads the Devin CLI
163
- * transcript file to build full {@link TraceData}.
164
- */
165
- /** Default transcript directory: ~/.local/share/devin/cli/transcripts */
166
- function transcriptDir() {
167
- return join(homedir(), ".local", "share", "devin", "cli", "transcripts");
168
- }
169
- /** Read a transcript JSON file by session ID. Throws if not found. */
170
- async function readTranscript(sessionId, dir) {
171
- const path = join(dir ?? transcriptDir(), `${sessionId}.json`);
172
- const content = await readFile(path, "utf-8");
173
- return JSON.parse(content);
174
- }
175
- /** Count LLM inference calls in a transcript (agent steps with telemetry.operation === "inference"). */
176
- function countLlmCalls(transcript) {
177
- return transcript.steps.filter((s) => s.source === "agent" && s.extra?.telemetry?.operation === "inference").length;
178
- }
179
- /** Build a {@link TraceStep} from a raw transcript step. */
180
- function buildTraceStep(step) {
181
- const isLlmCall = step.source === "agent" && step.extra?.telemetry?.operation === "inference";
182
- return {
183
- stepId: step.step_id,
184
- timestamp: step.timestamp,
185
- source: step.source,
186
- message: step.message,
187
- isLlmCall
188
- };
189
- }
190
- /** Extract text content from a ToolCallContent array (best-effort). */
191
- function extractTextContent(contents) {
192
- if (!contents) return "";
193
- const parts = [];
194
- for (const c of contents) if (c.type === "content" && c.content.type === "text") parts.push(c.content.text);
195
- return parts.join("\n");
196
- }
197
- /** Extract token counts from a Usage object, defaulting to 0. */
198
- function extractUsageTokens(usage) {
199
- return {
200
- inputTokens: usage?.inputTokens ?? 0,
201
- outputTokens: usage?.outputTokens ?? 0,
202
- thoughtTokens: usage?.thoughtTokens ?? 0,
203
- totalTokens: usage?.totalTokens ?? 0
204
- };
205
- }
206
- /** Build {@link RunMetrics} from a PromptResponse + collected counts + timing. */
207
- function buildMetrics(promptResult, toolCallCount, llmCallCount, startTime) {
208
- return {
209
- ...extractUsageTokens(promptResult.usage),
210
- toolCallCount,
211
- llmCallCount,
212
- executionTimeMs: Date.now() - startTime,
213
- stopReason: promptResult.stopReason
214
- };
215
- }
216
- /**
217
- * Extract the agent's final text output from the prompt response or trace.
218
- *
219
- * Prefers the text content from the ACP `PromptResponse`, falling back to
220
- * the last agent message in the trace.
221
- */
222
- function extractAgentOutput(promptResult, trace) {
223
- for (let i = trace.steps.length - 1; i >= 0; i--) {
224
- const step = trace.steps[i];
225
- if (step?.source === "agent" && step.message.length > 0) return step.message;
226
- }
227
- return "";
228
- }
229
- /**
230
- * Install enabled plugins and remove disabled ones from the workspace.
231
- *
232
- * CRITICAL: This function first removes the entire `.agents/skills/`
233
- * directory to ensure no inherited skills from the host contaminate the
234
- * benchmark. Only explicitly enabled plugins are installed.
235
- *
236
- * Each enabled plugin's `path` is copied to `workspace/.agents/skills/<id>/`.
237
- */
238
- async function installPlugins(workspace, plugins) {
239
- const skillsDir = join(workspace, ".agents", "skills");
240
- await rm(skillsDir, {
241
- recursive: true,
242
- force: true
243
- });
244
- const enabledPlugins = plugins.filter((p) => p.enabled);
245
- if (enabledPlugins.length === 0) return;
246
- await mkdir(skillsDir, { recursive: true });
247
- for (const plugin of enabledPlugins) {
248
- if (plugin.id.includes("/") || plugin.id.includes("\\") || plugin.id.includes("..")) throw new Error(`Invalid plugin id: ${plugin.id}`);
249
- const dest = join(skillsDir, plugin.id);
250
- await mkdir(dest, { recursive: true });
251
- await cp(plugin.path, dest, { recursive: true });
252
- }
253
- }
254
- /**
255
- * Devin ACP adapter — spawns `devin acp` as a subprocess.
256
- *
257
- * Usage:
258
- * ```ts
259
- * const adapter = new DevinAdapter();
260
- * const proc = adapter.spawn({ model, workspace, plugins });
261
- * const result = await proc.run("Fix the bug", 120_000);
262
- * proc.kill();
263
- * ```
264
- */
265
- var DevinAdapter = class {
266
- id = "devin";
267
- spawn(config) {
268
- const proc = spawnDevin(config);
269
- let killed = false;
270
- return {
271
- run: (prompt, timeoutMs) => runDevinSession(proc, config, prompt, timeoutMs),
272
- kill: () => {
273
- if (killed) return;
274
- killed = true;
275
- proc.kill("SIGTERM");
276
- }
277
- };
278
- }
279
- };
280
- /** Resolve the full path to the devin binary to avoid PATH-based lookup. */
281
- function resolveDevinBinary() {
282
- try {
283
- return execSync("which devin", {
284
- encoding: "utf-8",
285
- stdio: [
286
- "pipe",
287
- "pipe",
288
- "pipe"
289
- ]
290
- }).trim();
291
- } catch {
292
- return "devin";
293
- }
294
- }
295
- /** Spawn `devin acp --model <model.id>` as a subprocess in the workspace. */
296
- function spawnDevin(config) {
297
- const env = sanitizeEnv(config);
298
- const devinBin = resolveDevinBinary();
299
- return spawn(devinBin, [
300
- "acp",
301
- "--model",
302
- config.model.id
303
- ], {
304
- cwd: config.workspace,
305
- stdio: [
306
- "pipe",
307
- "pipe",
308
- "inherit"
309
- ],
310
- env
311
- });
312
- }
313
- /** Create a {@link ToolCollector} with callbacks that populate shared state. */
314
- function createToolCollector() {
315
- const collectedToolCalls = [];
316
- const observationsByCallId = /* @__PURE__ */ new Map();
317
- let toolCallCount = 0;
318
- const onToolCall = (update) => {
319
- toolCallCount++;
320
- const name = update.name ?? update.title ?? "unknown";
321
- const args = update.rawInput ?? {};
322
- collectedToolCalls.push({
323
- functionName: name,
324
- arguments: args,
325
- toolCallId: update.toolCallId
326
- });
327
- };
328
- const onToolUpdate = (update) => {
329
- const text = extractTextContent(update.content ?? void 0);
330
- if (text) observationsByCallId.set(update.toolCallId, {
331
- sourceCallId: update.toolCallId,
332
- content: text
333
- });
334
- };
335
- return {
336
- collectedToolCalls,
337
- observationsByCallId,
338
- get toolCallCount() {
339
- return toolCallCount;
340
- },
341
- onToolCall,
342
- onToolUpdate
343
- };
344
- }
345
- /** Build a {@link RunResult} for a failed session (best-effort trace from collected data). */
346
- function buildErrorResult(model, pluginIds, toolCallCount, sessionId, startTime, error) {
347
- const metrics = buildMetrics({
348
- stopReason: "cancelled",
349
- usage: null
350
- }, toolCallCount, 0, startTime);
351
- const trace = {
352
- sessionId,
353
- model: model.id,
354
- steps: [],
355
- finalMetrics: {
356
- totalPromptTokens: 0,
357
- totalCompletionTokens: 0,
358
- totalCachedTokens: 0,
359
- totalSteps: 0
360
- }
361
- };
362
- return {
363
- taskId: "",
364
- modelId: model.id,
365
- pluginIds,
366
- metrics,
367
- trace,
368
- output: "",
369
- checkResults: [],
370
- verdicts: [],
371
- success: false,
372
- error: error instanceof Error ? error.message : String(error)
373
- };
374
- }
375
- /** Run a full ACP session: initialize, create session, prompt, collect events, read transcript. */
376
- async function runDevinSession(proc, config, prompt, timeoutMs) {
377
- const startTime = Date.now();
378
- const model = config.model;
379
- const pluginIds = config.plugins.filter((p) => p.enabled).map((p) => p.id);
380
- const collector = createToolCollector();
381
- let sessionId = "";
382
- try {
383
- const promptResult = await runAcpSession(proc, config.workspace, prompt, timeoutMs, collector.onToolCall, collector.onToolUpdate, (id) => {
384
- sessionId = id;
385
- });
386
- const trace = await buildTrace(sessionId, model, collector.collectedToolCalls, collector.observationsByCallId);
387
- const llmCallCount = trace.steps.filter((s) => s.isLlmCall).length;
388
- const metrics = buildMetrics(promptResult, collector.toolCallCount, llmCallCount, startTime);
389
- const agentOutput = extractAgentOutput(promptResult, trace);
390
- return {
391
- taskId: "",
392
- modelId: model.id,
393
- pluginIds,
394
- metrics,
395
- trace,
396
- output: agentOutput,
397
- checkResults: [],
398
- verdicts: [],
399
- success: metrics.stopReason === "end_turn"
400
- };
401
- } catch (error) {
402
- return buildErrorResult(model, pluginIds, collector.toolCallCount, sessionId, startTime, error);
403
- }
404
- }
405
- /**
406
- * Build {@link TraceData} from the Devin transcript, enriched with
407
- * ACP-collected tool calls. Returns `undefined` when no transcript is
408
- * available so the caller can fall back to {@link buildTraceFallback}.
409
- */
410
- async function buildTraceFromTranscript(sessionId, collectedToolCalls, observationsByCallId) {
411
- if (!sessionId) return void 0;
412
- try {
413
- const transcript = await readTranscript(sessionId);
414
- const steps = transcript.steps.map(buildTraceStep);
415
- attachToolCallsToSteps(steps, collectedToolCalls, observationsByCallId);
416
- return {
417
- sessionId: transcript.session_id,
418
- model: transcript.agent.model_name,
419
- steps,
420
- finalMetrics: {
421
- totalPromptTokens: transcript.final_metrics.total_prompt_tokens,
422
- totalCompletionTokens: transcript.final_metrics.total_completion_tokens,
423
- totalCachedTokens: transcript.final_metrics.total_cached_tokens,
424
- totalSteps: transcript.final_metrics.total_steps
425
- }
426
- };
427
- } catch {
428
- return;
429
- }
430
- }
431
- /** Attach the next tool call + observation to an agent step. Returns true if attached. */
432
- function attachNextToolCall(step, collectedToolCalls, observationsByCallId, toolIdx) {
433
- if (toolIdx >= collectedToolCalls.length) return {
434
- attached: false,
435
- nextIdx: toolIdx
436
- };
437
- const tc = collectedToolCalls[toolIdx];
438
- if (!tc) return {
439
- attached: false,
440
- nextIdx: toolIdx
441
- };
442
- const calls = [tc];
443
- const obs = observationsByCallId.get(tc.toolCallId);
444
- const observations = obs ? [obs] : [];
445
- step.toolCalls = calls;
446
- if (observations.length > 0) step.observations = observations;
447
- return {
448
- attached: true,
449
- nextIdx: toolIdx + 1
450
- };
451
- }
452
- /** Attach ACP-collected tool calls/observations to agent steps in order. */
453
- function attachToolCallsToSteps(steps, collectedToolCalls, observationsByCallId) {
454
- let toolIdx = 0;
455
- for (const step of steps) {
456
- if (step.source !== "agent") continue;
457
- const result = attachNextToolCall(step, collectedToolCalls, observationsByCallId, toolIdx);
458
- if (result.attached) toolIdx = result.nextIdx;
459
- }
460
- }
461
- /** Build a minimal {@link TraceData} from ACP-collected data only. */
462
- function buildTraceFallback(sessionId, model, collectedToolCalls, observationsByCallId) {
463
- const steps = collectedToolCalls.map((tc, i) => {
464
- const obs = observationsByCallId.get(tc.toolCallId);
465
- const step = {
466
- stepId: i,
467
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
468
- source: "agent",
469
- message: tc.functionName,
470
- toolCalls: [tc],
471
- isLlmCall: false
472
- };
473
- if (obs) step.observations = [obs];
474
- return step;
475
- });
476
- return {
477
- sessionId,
478
- model: model.id,
479
- steps,
480
- finalMetrics: {
481
- totalPromptTokens: 0,
482
- totalCompletionTokens: 0,
483
- totalCachedTokens: 0,
484
- totalSteps: steps.length
485
- }
486
- };
487
- }
488
- /** Build {@link TraceData} from the Devin transcript, falling back to ACP-only data. */
489
- async function buildTrace(sessionId, model, collectedToolCalls, observationsByCallId) {
490
- return await buildTraceFromTranscript(sessionId, collectedToolCalls, observationsByCallId) ?? buildTraceFallback(sessionId, model, collectedToolCalls, observationsByCallId);
491
- }
492
- //#endregion
493
- //#region src/judge.ts
494
- /** Default system prompt for the judge. */
495
- const DEFAULT_JUDGE_PROMPT = "You are a blind evaluator. You will receive a task prompt and an agent's response. Score the response 0-100 based on how well it completes the task. Be strict but fair. Output JSON: {\"score\": number, \"passed\": boolean, \"reasoning\": string, \"issues\": string[]}. A score of 70+ is passing.";
496
- /** Default timeout for a single judge run (ms). */
497
- const DEFAULT_JUDGE_TIMEOUT_MS = 12e4;
498
- /**
499
- * Build the judge prompt for a single run.
500
- *
501
- * Includes the task prompt, success criteria, and the agent's output. Does
502
- * NOT include which plugins were active unless `config.revealPlugins` is
503
- * true. Asks the judge for a JSON response with score, passed, reasoning,
504
- * and issues.
505
- */
506
- function buildJudgePrompt(task, runOutput, config, pluginIds = []) {
507
- const lines = [
508
- config.systemPrompt ?? "You are a blind evaluator. You will receive a task prompt and an agent's response. Score the response 0-100 based on how well it completes the task. Be strict but fair. Output JSON: {\"score\": number, \"passed\": boolean, \"reasoning\": string, \"issues\": string[]}. A score of 70+ is passing.",
509
- "",
510
- "## Task Prompt",
511
- task.prompt,
512
- ""
513
- ];
514
- if (task.successCriteria) lines.push("## Success Criteria", task.successCriteria, "");
515
- if (config.revealPlugins && task.id) {
516
- lines.push("## Plugins Active During Run");
517
- if (pluginIds.length > 0) lines.push(pluginIds.join(", "));
518
- else lines.push("(no plugins)");
519
- lines.push("");
520
- }
521
- lines.push("## Agent Response");
522
- lines.push(runOutput || "(no output)");
523
- lines.push("");
524
- lines.push("## Instructions\nEvaluate the agent's response against the task prompt and success criteria. Respond with ONLY a JSON object: {\"score\": number, \"passed\": boolean, \"reasoning\": string, \"issues\": string[]}. A score of 70 or above is passing. Do not include any text outside the JSON.");
525
- return lines.join("\n");
526
- }
527
- /**
528
- * Parse a judge response into a {@link JudgeVerdict}.
529
- *
530
- * Extracts JSON from the response, handling markdown code fences
531
- * (` ```json ... ``` `). Falls back to `score=0, passed=false` when the
532
- * response cannot be parsed.
533
- */
534
- function parseJudgeResponse(response, runIndex, run) {
535
- const jsonText = extractJson(response);
536
- const fallback = {
537
- runIndex,
538
- score: 0,
539
- passed: false,
540
- reasoning: response.trim() || "Failed to parse judge response",
541
- issues: [],
542
- taskId: run.taskId,
543
- modelId: run.modelId,
544
- pluginIds: run.pluginIds
545
- };
546
- if (jsonText === null) return fallback;
547
- let parsed;
548
- try {
549
- parsed = JSON.parse(jsonText);
550
- } catch {
551
- return fallback;
552
- }
553
- if (typeof parsed !== "object" || parsed === null) return fallback;
554
- const obj = parsed;
555
- const score = typeof obj.score === "number" ? obj.score : 0;
556
- return {
557
- runIndex,
558
- score,
559
- passed: typeof obj.passed === "boolean" ? obj.passed : score >= 70,
560
- reasoning: typeof obj.reasoning === "string" ? obj.reasoning : "",
561
- issues: Array.isArray(obj.issues) ? obj.issues.filter((i) => typeof i === "string") : [],
562
- taskId: run.taskId,
563
- modelId: run.modelId,
564
- pluginIds: run.pluginIds
565
- };
566
- }
567
- /**
568
- * Judge a single run — spawn the judge agent, send the prompt, collect the
569
- * response, parse it into a {@link JudgeVerdict}, and kill the agent.
570
- */
571
- async function judgeRun(run, task, config, runIndex) {
572
- const prompt = buildJudgePrompt(task, run.output, config, run.pluginIds);
573
- const proc = config.agent.spawn({
574
- model: config.model,
575
- workspace: process.cwd(),
576
- plugins: [],
577
- permissionMode: "dangerous"
578
- });
579
- try {
580
- return parseJudgeResponse((await proc.run(prompt, DEFAULT_JUDGE_TIMEOUT_MS)).output, runIndex, run);
581
- } catch (error) {
582
- return {
583
- runIndex,
584
- score: 0,
585
- passed: false,
586
- reasoning: error instanceof Error ? error.message : String(error),
587
- issues: [],
588
- taskId: run.taskId,
589
- modelId: run.modelId,
590
- pluginIds: run.pluginIds
591
- };
592
- } finally {
593
- proc.kill();
594
- }
595
- }
596
- /**
597
- * Judge all runs in a list, pairing each run with its matching task.
598
- *
599
- * Supports `config.repetitions` — the judge is run N times per run for
600
- * confidence. All verdicts are returned (one per repetition).
601
- */
602
- async function judgeAllRuns(results, tasks, config) {
603
- const taskById = /* @__PURE__ */ new Map();
604
- for (const task of tasks) taskById.set(task.id, task);
605
- const repetitions = config.repetitions ?? 1;
606
- const verdicts = [];
607
- for (let i = 0; i < results.length; i++) {
608
- const run = results[i];
609
- if (!run) continue;
610
- const task = taskById.get(run.taskId);
611
- if (!task) continue;
612
- for (let rep = 0; rep < repetitions; rep++) {
613
- const verdict = await judgeRun(run, task, config, i);
614
- verdicts.push(verdict);
615
- }
616
- }
617
- return verdicts;
618
- }
619
- /**
620
- * Compare two plugin combos for a task (for {@link ComboComparison}).
621
- *
622
- * Computes deltas as `candidate - baseline`. Determines whether the
623
- * candidate is better:
624
- * - Lower tokens / toolCalls / llmCalls / time = better
625
- * - Higher judgeScore = better
626
- *
627
- * The judge score for each run is the average of its verdicts' scores
628
- * (0 when there are no verdicts).
629
- */
630
- function compareCombos(baseline, candidate) {
631
- const baselineScore = avgJudgeScore(baseline);
632
- const candidateScore = avgJudgeScore(candidate);
633
- const deltaTokens = candidate.metrics.totalTokens - baseline.metrics.totalTokens;
634
- const deltaToolCalls = candidate.metrics.toolCallCount - baseline.metrics.toolCallCount;
635
- const deltaLlmCalls = candidate.metrics.llmCallCount - baseline.metrics.llmCallCount;
636
- const deltaTimeMs = candidate.metrics.executionTimeMs - baseline.metrics.executionTimeMs;
637
- const deltaJudgeScore = candidateScore - baselineScore;
638
- const candidateBetter = deltaJudgeScore > 0 || deltaJudgeScore === 0 && deltaTokens <= 0 && deltaToolCalls <= 0 && deltaLlmCalls <= 0 && deltaTimeMs <= 0 && (deltaTokens < 0 || deltaToolCalls < 0 || deltaLlmCalls < 0 || deltaTimeMs < 0);
639
- return {
640
- baseline: comboLabel$1(baseline),
641
- candidate: comboLabel$1(candidate),
642
- deltaTokens,
643
- deltaToolCalls,
644
- deltaLlmCalls,
645
- deltaTimeMs,
646
- deltaJudgeScore,
647
- candidateBetter
648
- };
649
- }
650
- /**
651
- * Extract the first JSON object from a response string.
652
- *
653
- * Handles raw JSON and JSON wrapped in markdown code fences
654
- * (` ```json ... ``` ` or ` ``` ... ``` `). Returns `null` when no JSON
655
- * object can be located.
656
- */
657
- function extractJson(response) {
658
- const trimmed = response.trim();
659
- if (trimmed === "") return null;
660
- const fenced = extractFromFence(trimmed);
661
- if (fenced !== null) return fenced;
662
- const braced = extractFromBraces(trimmed);
663
- if (braced !== null) return braced;
664
- return null;
665
- }
666
- /** Extract JSON from inside a markdown code fence (```...```). */
667
- function extractFromFence(text) {
668
- const fenceStart = text.indexOf("```");
669
- if (fenceStart === -1) return null;
670
- const afterFence = text.slice(fenceStart + 3);
671
- let contentStart = 0;
672
- if (afterFence.startsWith("json")) contentStart = 4;
673
- while (contentStart < afterFence.length && /\s/.test(afterFence[contentStart])) contentStart++;
674
- const fenceEnd = afterFence.indexOf("```", contentStart);
675
- if (fenceEnd === -1) return null;
676
- const inner = afterFence.slice(contentStart, fenceEnd).trim();
677
- if (!looksLikeJson(inner)) return null;
678
- return inner;
679
- }
680
- /** Extract JSON by finding the first `{` and last `}` in the text. */
681
- function extractFromBraces(text) {
682
- const start = text.indexOf("{");
683
- const end = text.lastIndexOf("}");
684
- if (start === -1 || end === -1 || end <= start) return null;
685
- return text.slice(start, end + 1);
686
- }
687
- /** Quick check that a string starts with `{` (a JSON object). */
688
- function looksLikeJson(text) {
689
- return text.trim().startsWith("{");
690
- }
691
- /** Average judge score across a run's verdicts (0 when no verdicts). */
692
- function avgJudgeScore(run) {
693
- if (run.verdicts.length === 0) return 0;
694
- return run.verdicts.reduce((a, v) => a + v.score, 0) / run.verdicts.length;
695
- }
696
- /** Build a human-readable combo label for a run's plugin set. */
697
- function comboLabel$1(run) {
698
- if (run.pluginIds.length === 0) return "no-plugins";
699
- return run.pluginIds.join(",");
700
- }
701
- //#endregion
702
- //#region src/runner.ts
703
- /**
704
- * Matrix runner — executes the full arena matrix:
705
- *
706
- * task × model × plugin combination × repetition
707
- *
708
- * For each cell it spawns the agent, runs the prompt, collects the result,
709
- * and kills the agent. Results are aggregated into {@link ArenaResult}.
710
- */
711
- /** Resolve the arena package root (for fixture paths). */
712
- const PKG_ROOT = resolve(typeof __dirname !== "undefined" ? __dirname : fileURLToPath(new URL(".", import.meta.url)), "..");
713
- /**
714
- * Generate all plugin on/off combinations.
715
- *
716
- * For N plugins this produces 2^N combinations, each a full copy of the
717
- * plugins array with `enabled` set appropriately.
718
- *
719
- * - 0 plugins → `[[]]` (one empty combination)
720
- * - 1 plugin → `[[off], [on]]`
721
- * - 2 plugins → `[[off,off], [on,off], [off,on], [on,on]]`
722
- */
723
- function pluginCombinations(plugins) {
724
- if (plugins.length === 0) return [[]];
725
- const combos = [[]];
726
- for (const plugin of plugins) {
727
- const next = [];
728
- for (const combo of combos) {
729
- next.push([...combo, {
730
- ...plugin,
731
- enabled: false
732
- }]);
733
- next.push([...combo, {
734
- ...plugin,
735
- enabled: true
736
- }]);
737
- }
738
- combos.length = 0;
739
- combos.push(...next);
740
- }
741
- return combos;
742
- }
743
- /**
744
- * Run the full arena matrix: task × model × plugin combo × repetitions.
745
- *
746
- * Each cell spawns the configured agent adapter, installs the plugin
747
- * combination, runs the prompt, collects trace + metrics, and kills the
748
- * agent. Results are written to `config.outputDir/results.json` and the
749
- * full {@link ArenaResult} is returned.
750
- */
751
- async function runArena(config) {
752
- const repetitions = config.repetitions ?? 1;
753
- const combos = pluginCombinations(config.plugins);
754
- const results = await runMatrix(config, combos);
755
- if (config.judge) {
756
- const verdicts = await judgeAllRuns(results, config.tasks, config.judge);
757
- for (const verdict of verdicts) {
758
- const run = results[verdict.runIndex];
759
- if (run) run.verdicts.push(verdict);
760
- }
761
- }
762
- const aggregates = buildAggregates(results, config.models, combos);
763
- const analysis = computeAnalysis(results, config.tasks);
764
- const arenaResult = {
765
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
766
- config: {
767
- models: config.models.map((m) => m.id),
768
- plugins: config.plugins.map((p) => p.id),
769
- tasks: config.tasks.map((t) => t.id),
770
- repetitions,
771
- ...config.judge ? { judgeModel: config.judge.model.id } : {}
772
- },
773
- results,
774
- aggregates,
775
- analysis
776
- };
777
- await mkdir(config.outputDir, { recursive: true });
778
- await writeFile(join(config.outputDir, "results.json"), JSON.stringify(arenaResult, null, 2), "utf-8");
779
- return arenaResult;
780
- }
781
- /**
782
- * Run every cell of the task × model × combo × repetition matrix, collecting
783
- * results and logging per-cell progress to stderr.
784
- */
785
- async function runMatrix(config, combos) {
786
- const repetitions = config.repetitions ?? 1;
787
- const results = [];
788
- for (const task of config.tasks) for (const model of config.models) for (const combo of combos) for (let rep = 0; rep < repetitions; rep++) {
789
- const cellLabel = formatCell(task.id, model.id, combo, rep);
790
- process.stderr.write(`[arena] ${cellLabel} running...\n`);
791
- const result = await runCell(config.agent, task, model, combo, rep);
792
- results.push(result);
793
- process.stderr.write(`[arena] ${cellLabel} ${result.success ? "PASS" : "FAIL"} — ${result.metrics.totalTokens} tokens, ${result.metrics.toolCallCount} tools, ${result.metrics.llmCallCount} llm calls, ${result.metrics.executionTimeMs}ms\n`);
794
- }
795
- return results;
796
- }
797
- /**
798
- * Aggregate metrics per (model × plugin combo) cell.
799
- */
800
- function buildAggregates(results, models, combos) {
801
- const aggregates = [];
802
- for (const model of models) for (const combo of combos) {
803
- const pluginIds = combo.filter((p) => p.enabled).map((p) => p.id);
804
- const label = formatAggregateLabel(model.id, pluginIds);
805
- aggregates.push(aggregateResults(results, (r) => r.modelId === model.id && samePluginIds(r.pluginIds, pluginIds), label));
806
- }
807
- return aggregates;
808
- }
809
- /** Run a single matrix cell in an ISOLATED temp workspace. */
810
- async function runCell(agent, task, model, combo, rep) {
811
- const tempWorkspace = await createTempWorkspace(task, model, combo, rep);
812
- await installPlugins(tempWorkspace, combo);
813
- const result = await executeRun(agent, task, model, combo, tempWorkspace);
814
- await rm(tempWorkspace, {
815
- recursive: true,
816
- force: true
817
- });
818
- return result;
819
- }
820
- /**
821
- * Create an isolated temp workspace with a fresh copy of the fixture (if any).
822
- * Each run gets its own copy to prevent skill contamination from the host repo.
823
- */
824
- async function createTempWorkspace(task, model, combo, rep) {
825
- const tempWorkspace = join(tmpdir(), `arena-${task.id}-${model.id}-${combo.map((p) => p.id + (p.enabled ? "on" : "off")).join(",")}-r${rep}-${Date.now()}`);
826
- await mkdir(tempWorkspace, { recursive: true });
827
- if (task.fixture) {
828
- const fixturePath = resolve(PKG_ROOT, task.fixture);
829
- if (!fixturePath.startsWith(PKG_ROOT + sep) && fixturePath !== PKG_ROOT) throw new Error(`Fixture path escapes package root: ${task.fixture}`);
830
- await cp(fixturePath, tempWorkspace, { recursive: true });
831
- }
832
- return tempWorkspace;
833
- }
834
- /**
835
- * Spawn the agent, run the prompt, run deterministic checks, and return the
836
- * result. On error returns a zeroed {@link RunResult} via {@link errorResult}.
837
- */
838
- async function executeRun(agent, task, model, combo, tempWorkspace) {
839
- const proc = agent.spawn({
840
- model,
841
- workspace: tempWorkspace,
842
- plugins: combo,
843
- permissionMode: "dangerous"
844
- });
845
- try {
846
- const result = await proc.run(task.prompt, task.timeoutMs ?? 12e4);
847
- result.taskId = task.id;
848
- if (!result.verdicts) result.verdicts = [];
849
- if (!result.checkResults) result.checkResults = [];
850
- if (task.checks && task.checks.length > 0) {
851
- result.checkResults = await runChecks(tempWorkspace, task.checks);
852
- result.success = result.success && result.checkResults.every((c) => c.passed);
853
- }
854
- return result;
855
- } catch (error) {
856
- return errorResult(task, model, combo, error);
857
- } finally {
858
- proc.kill();
859
- }
860
- }
861
- /** Build a zeroed {@link RunResult} for a failed run. */
862
- function errorResult(task, model, combo, error) {
863
- return {
864
- taskId: task.id,
865
- modelId: model.id,
866
- pluginIds: combo.filter((p) => p.enabled).map((p) => p.id),
867
- metrics: {
868
- inputTokens: 0,
869
- outputTokens: 0,
870
- thoughtTokens: 0,
871
- totalTokens: 0,
872
- toolCallCount: 0,
873
- llmCallCount: 0,
874
- executionTimeMs: 0,
875
- stopReason: "cancelled"
876
- },
877
- trace: {
878
- sessionId: "",
879
- model: model.id,
880
- steps: [],
881
- finalMetrics: {
882
- totalPromptTokens: 0,
883
- totalCompletionTokens: 0,
884
- totalCachedTokens: 0,
885
- totalSteps: 0
886
- }
887
- },
888
- output: "",
889
- checkResults: [],
890
- verdicts: [],
891
- success: false,
892
- error: error instanceof Error ? error.message : String(error)
893
- };
894
- }
895
- /** Run deterministic checks in the workspace. */
896
- async function runChecks(workspace, checks) {
897
- if (!checks) return [];
898
- const results = [];
899
- for (const check of checks) try {
900
- const { output, exitCode } = await new Promise((resolve, reject) => {
901
- const proc = spawn("bash", ["-c", check.command], {
902
- cwd: workspace,
903
- stdio: [
904
- "pipe",
905
- "pipe",
906
- "pipe"
907
- ],
908
- env: {
909
- ...process.env,
910
- CI: "true"
911
- }
912
- });
913
- let stdout = "";
914
- proc.stdout?.on("data", (d) => {
915
- stdout += d.toString();
916
- });
917
- proc.stderr?.on("data", (d) => {
918
- stdout += d.toString();
919
- });
920
- proc.on("close", (code) => {
921
- resolve({
922
- output: stdout,
923
- exitCode: code
924
- });
925
- });
926
- proc.on("error", reject);
927
- });
928
- results.push({
929
- checkId: check.id,
930
- passed: exitCode === 0,
931
- output: output.trim(),
932
- exitCode
933
- });
934
- } catch (error) {
935
- results.push({
936
- checkId: check.id,
937
- passed: false,
938
- output: error instanceof Error ? error.message : String(error),
939
- exitCode: -1
940
- });
941
- }
942
- return results;
943
- }
944
- /**
945
- * Aggregate metrics for a group of runs selected by `filter`.
946
- *
947
- * Computes averages across all matching runs. Returns zeroed metrics when
948
- * no runs match. Includes judge-related fields (`avgJudgeScore`,
949
- * `judgePassCount`) and a `label` identifying the aggregate group.
950
- */
951
- function aggregateResults(results, filter, label = "") {
952
- const filtered = results.filter(filter);
953
- const n = filtered.length;
954
- if (n === 0) return {
955
- totalRuns: 0,
956
- successCount: 0,
957
- avgInputTokens: 0,
958
- avgOutputTokens: 0,
959
- avgTotalTokens: 0,
960
- avgToolCalls: 0,
961
- avgLlmCalls: 0,
962
- avgExecutionTimeMs: 0,
963
- avgJudgeScore: 0,
964
- judgePassCount: 0,
965
- label
966
- };
967
- const sum = (f) => filtered.reduce((a, r) => a + f(r), 0);
968
- const round = (v) => Math.round(v);
969
- const round2 = (v) => Math.round(v * 100) / 100;
970
- const judgedRuns = filtered.filter((r) => r.verdicts.length > 0);
971
- const avgJudgeScore = judgedRuns.length === 0 ? 0 : round2(judgedRuns.reduce((a, r) => a + (r.verdicts[0]?.score ?? 0), 0) / judgedRuns.length);
972
- const judgePassCount = filtered.filter((r) => r.verdicts[0]?.passed === true).length;
973
- return {
974
- totalRuns: n,
975
- successCount: filtered.filter((r) => r.success).length,
976
- avgInputTokens: round(sum((r) => r.metrics.inputTokens) / n),
977
- avgOutputTokens: round(sum((r) => r.metrics.outputTokens) / n),
978
- avgTotalTokens: round(sum((r) => r.metrics.totalTokens) / n),
979
- avgToolCalls: round2(sum((r) => r.metrics.toolCallCount) / n),
980
- avgLlmCalls: round2(sum((r) => r.metrics.llmCallCount) / n),
981
- avgExecutionTimeMs: round(sum((r) => r.metrics.executionTimeMs) / n),
982
- avgJudgeScore,
983
- judgePassCount,
984
- label
985
- };
986
- }
987
- /** Compare two plugin-id arrays for equality (order-insensitive). */
988
- function samePluginIds(a, b) {
989
- if (a.length !== b.length) return false;
990
- const set = new Set(a);
991
- for (const id of b) if (!set.has(id)) return false;
992
- return true;
993
- }
994
- /** Format a human-readable cell label for logging. */
995
- function formatCell(taskId, modelId, combo, rep) {
996
- return `${taskId}/${modelId}/{${combo.map((p) => `${p.id}=${p.enabled ? "on" : "off"}`).join(",")}}#${rep}`;
997
- }
998
- /** Format an aggregate group label: "model-id/plugins=on,off" or "model-id/no-plugins". */
999
- function formatAggregateLabel(modelId, pluginIds) {
1000
- if (pluginIds.length === 0) return `${modelId}/no-plugins`;
1001
- return `${modelId}/plugins=${pluginIds.join(",")}`;
1002
- }
1003
- /** Format a plugin combo label for comparisons: "no-plugins" or comma-joined ids. */
1004
- function comboLabel(pluginIds) {
1005
- if (pluginIds.length === 0) return "no-plugins";
1006
- return pluginIds.join(",");
1007
- }
1008
- /**
1009
- * Create a synthetic "average" {@link RunResult} representing a group of
1010
- * runs with the same plugin combo. Used to feed {@link compareCombos} which
1011
- * operates on single runs.
1012
- */
1013
- function averageRun(runs) {
1014
- if (runs.length === 0) return null;
1015
- const n = runs.length;
1016
- const avg = (f) => Math.round(runs.reduce((a, r) => a + f(r), 0) / n);
1017
- const avg2 = (f) => Math.round(runs.reduce((a, r) => a + f(r), 0) / n * 100) / 100;
1018
- const first = runs[0];
1019
- return {
1020
- taskId: first.taskId,
1021
- modelId: first.modelId,
1022
- pluginIds: first.pluginIds,
1023
- metrics: {
1024
- inputTokens: avg((r) => r.metrics.inputTokens),
1025
- outputTokens: avg((r) => r.metrics.outputTokens),
1026
- thoughtTokens: avg((r) => r.metrics.thoughtTokens),
1027
- totalTokens: avg((r) => r.metrics.totalTokens),
1028
- toolCallCount: avg2((r) => r.metrics.toolCallCount),
1029
- llmCallCount: avg2((r) => r.metrics.llmCallCount),
1030
- executionTimeMs: avg((r) => r.metrics.executionTimeMs),
1031
- stopReason: first.metrics.stopReason
1032
- },
1033
- trace: first.trace,
1034
- output: first.output,
1035
- checkResults: first.checkResults ?? [],
1036
- verdicts: first.verdicts,
1037
- success: runs.every((r) => r.success)
1038
- };
1039
- }
1040
- /**
1041
- * Compute per-task analysis comparing baseline (no plugins) vs each
1042
- * candidate plugin combo.
1043
- *
1044
- * For each task, groups runs by plugin combo, creates an averaged
1045
- * representative run per group, then compares the baseline (empty
1046
- * `pluginIds`) against every non-empty candidate combo using
1047
- * {@link compareCombos} from `judge.ts`.
1048
- */
1049
- function computeAnalysis(results, tasks) {
1050
- const analyses = [];
1051
- for (const task of tasks) {
1052
- const taskRuns = results.filter((r) => r.taskId === task.id);
1053
- const comboGroups = /* @__PURE__ */ new Map();
1054
- for (const run of taskRuns) {
1055
- const key = comboLabel(run.pluginIds);
1056
- const group = comboGroups.get(key);
1057
- if (group) group.push(run);
1058
- else comboGroups.set(key, [run]);
1059
- }
1060
- const baseline = averageRun(comboGroups.get("no-plugins") ?? []);
1061
- const comparisons = [];
1062
- if (baseline) for (const [key, candidateRuns] of comboGroups) {
1063
- if (key === "no-plugins") continue;
1064
- const candidate = averageRun(candidateRuns);
1065
- if (candidate) comparisons.push(compareCombos(baseline, candidate));
1066
- }
1067
- analyses.push({
1068
- taskId: task.id,
1069
- taskName: task.name,
1070
- prompt: task.prompt,
1071
- comparisons
1072
- });
1073
- }
1074
- return analyses;
1075
- }
1076
- //#endregion
1077
- export { DEFAULT_JUDGE_PROMPT, DevinAdapter, aggregateResults, buildJudgePrompt, compareCombos, computeAnalysis, countLlmCalls, createPermissionHandler, extractUsageTokens, installPlugins, judgeAllRuns, judgeRun, parseJudgeResponse, pluginCombinations, runAcpSession, runArena, sanitizeEnv, transcriptDir };
1078
-
1079
- //# sourceMappingURL=index.mjs.map
1
+ import { C as sanitizeEnv, S as runAcpSession, _ as countLlmCalls, a as resolveAdapter, b as transcriptDir, c as pluginCombinations, d as buildJudgePrompt, f as compareCombos, g as DevinAdapter, h as parseJudgeResponse, i as loadArenaConfig, l as runArena, m as judgeRun, n as ArenaError, o as aggregateResults, p as judgeAllRuns, r as defineConfig, s as computeAnalysis, t as renderReport, u as DEFAULT_JUDGE_PROMPT, v as extractUsageTokens, x as createPermissionHandler, y as installPlugins } from "./report-3Gl3woII.mjs";
2
+ export { ArenaError, DEFAULT_JUDGE_PROMPT, DevinAdapter, aggregateResults, buildJudgePrompt, compareCombos, computeAnalysis, countLlmCalls, createPermissionHandler, defineConfig, extractUsageTokens, installPlugins, judgeAllRuns, judgeRun, loadArenaConfig, parseJudgeResponse, pluginCombinations, renderReport, resolveAdapter, runAcpSession, runArena, sanitizeEnv, transcriptDir };