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