@sverka/arena 0.0.0 → 0.1.1
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/bin.d.mts +1 -0
- package/dist/bin.mjs +13 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/index.d.mts +426 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1079 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +36 -10
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1079 @@
|
|
|
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
|