agentperf 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -26,10 +26,17 @@ round-trips, actions, success rate — as a markdown table and JSON under
26
26
 
27
27
  Options: `--lane both|tools|dom` · `--runs N` · `--model <id>` (default
28
28
  `gpt-5.6-luna`) · `--base-url <url>` for compatible providers · `--task
29
- booking|path/to/task.json` · `--max-turns N` · `--out dir`. A task file is
30
- `{ id, prompt, successPattern, maxTurns }` — the prompt is handed to both
31
- lanes verbatim and `successPattern` is a regex the page's visible text must
32
- match when the task is truly done.
29
+ booking|path/to/task.json` · `--max-turns N` · `--max-snapshot-chars N` ·
30
+ `--out dir`. A task file is `{ id, prompt, successPattern, maxTurns }` — the
31
+ prompt is handed to both lanes verbatim and `successPattern` is a regex the
32
+ page's visible text must match when the task is truly done.
33
+
34
+ `--max-snapshot-chars` caps the accessibility snapshot the DOM lane receives
35
+ per `read_page`. It defaults to 120,000 — deliberately generous, so a heavy
36
+ page reaches the DOM agent whole and it pays for the page in tokens instead
37
+ of being handicapped by truncation. Runs that still hit the cap are flagged
38
+ in the report, because their measured cost is a floor rather than the real
39
+ number.
33
40
 
34
41
  Instrument your app with [`@agentperf/react`](https://www.npmjs.com/package/@agentperf/react)
35
42
  to give it the tools lane.
@@ -1,5 +1,22 @@
1
1
  // src/llm.ts
2
+ var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
3
+ var MAX_ATTEMPTS = 5;
4
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2
5
  async function chat(config, messages, tools) {
6
+ let lastError = "";
7
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
8
+ try {
9
+ return await chatOnce(config, messages, tools);
10
+ } catch (error) {
11
+ lastError = error instanceof Error ? error.message : String(error);
12
+ const status = Number(/\((\d{3})\)/.exec(lastError)?.[1]);
13
+ if (!RETRY_STATUSES.has(status) || attempt === MAX_ATTEMPTS) throw error;
14
+ await sleep(2e3 * 2 ** (attempt - 1));
15
+ }
16
+ }
17
+ throw new Error(lastError);
18
+ }
19
+ async function chatOnce(config, messages, tools) {
3
20
  const response = await fetch(`${config.baseUrl}/chat/completions`, {
4
21
  method: "POST",
5
22
  headers: {
@@ -56,13 +73,8 @@ var TASK_COMPLETE = {
56
73
  required: ["summary"]
57
74
  }
58
75
  };
59
- async function runAgentLoop(options) {
60
- const messages = [
61
- { role: "system", content: options.systemPrompt },
62
- { role: "user", content: options.taskPrompt }
63
- ];
64
- const tools = [...options.tools, TASK_COMPLETE];
65
- const outcome = {
76
+ function newLoopOutcome() {
77
+ return {
66
78
  turns: 0,
67
79
  actions: 0,
68
80
  promptTokens: 0,
@@ -70,6 +82,13 @@ async function runAgentLoop(options) {
70
82
  claimedDone: false,
71
83
  claimSummary: ""
72
84
  };
85
+ }
86
+ async function runAgentLoop(options, outcome = newLoopOutcome()) {
87
+ const messages = [
88
+ { role: "system", content: options.systemPrompt },
89
+ { role: "user", content: options.taskPrompt }
90
+ ];
91
+ const tools = [...options.tools, TASK_COMPLETE];
73
92
  let nudged = false;
74
93
  while (outcome.turns < options.maxTurns) {
75
94
  outcome.turns += 1;
@@ -153,10 +172,17 @@ var HOST_SHIM = `
153
172
 
154
173
  // src/lanes.ts
155
174
  import { chromium } from "playwright";
156
- async function verify(page, task) {
175
+ async function matchesSuccess(page, task) {
157
176
  const text = await page.evaluate(() => document.body.innerText);
158
177
  return new RegExp(task.successPattern).test(text);
159
178
  }
179
+ async function assertNotAlreadySatisfied(page, task) {
180
+ if (await matchesSuccess(page, task)) {
181
+ throw new Error(
182
+ `successPattern /${task.successPattern}/ already matches the page before the agent acted \u2014 the task cannot be verified against this URL`
183
+ );
184
+ }
185
+ }
160
186
  function metrics(lane, task) {
161
187
  return {
162
188
  lane,
@@ -170,7 +196,7 @@ function metrics(lane, task) {
170
196
  totalTokens: 0
171
197
  };
172
198
  }
173
- function finish(run, startedMs, loop, verified, claimedDone) {
199
+ function finish(run, startedMs, loop, verified, claimedDone, override = {}) {
174
200
  run.wallClockMs = Math.round(performance.now() - startedMs);
175
201
  run.turns = loop.turns;
176
202
  run.actions = loop.actions;
@@ -178,20 +204,23 @@ function finish(run, startedMs, loop, verified, claimedDone) {
178
204
  run.completionTokens = loop.completionTokens;
179
205
  run.totalTokens = loop.promptTokens + loop.completionTokens;
180
206
  run.success = verified;
207
+ run.claimSummary = loop.claimSummary || void 0;
181
208
  if (!verified) {
182
- run.failure = loop.failure ?? (claimedDone ? "model claimed completion but the page does not show the expected outcome" : "run ended without completion");
209
+ run.failure = override.failure ?? loop.failure ?? (claimedDone ? "model claimed completion but the page does not show the expected outcome" : "run ended without completion");
183
210
  }
184
211
  return run;
185
212
  }
186
213
  async function runToolsLane(url, task, llm) {
187
214
  const browser = await chromium.launch();
188
215
  const run = metrics("tools", task);
216
+ const outcome = newLoopOutcome();
189
217
  const startedMs = performance.now();
190
218
  try {
191
219
  const context = await browser.newContext();
192
220
  await context.addInitScript(HOST_SHIM);
193
221
  const page = await context.newPage();
194
222
  await page.goto(url, { waitUntil: "load" });
223
+ await assertNotAlreadySatisfied(page, task);
195
224
  await page.waitForFunction(() => window.__agentperf?.listTools().length > 0, void 0, {
196
225
  timeout: 15e3
197
226
  });
@@ -203,7 +232,7 @@ async function runToolsLane(url, task, llm) {
203
232
  }));
204
233
  const loop = await runAgentLoop({
205
234
  llm,
206
- systemPrompt: "You complete tasks on a web page through the page's own tools (WebMCP). Call get_page_state first to orient when it exists. Use only the tools. When the task is fully done, call task_complete.",
235
+ systemPrompt: "You complete tasks on a web page through the page's own tools (WebMCP). Call get_page_state first to orient when it exists. Use only the tools. Every action returns the updated result. When the task is fully done, call task_complete.",
207
236
  taskPrompt: task.prompt,
208
237
  tools,
209
238
  maxTurns: task.maxTurns,
@@ -215,17 +244,18 @@ async function runToolsLane(url, task, llm) {
215
244
  ),
216
245
  [name, args]
217
246
  );
247
+ await page.waitForTimeout(SETTLE_MS);
218
248
  const text = result.content.map((c) => c.text).join("\n");
219
249
  return result.isError ? `TOOL ERROR:
220
250
  ${text}` : text;
221
251
  }
222
- });
223
- const verified = loop.claimedDone && await verify(page, task);
252
+ }, outcome);
253
+ const verified = loop.claimedDone && await matchesSuccess(page, task);
224
254
  return finish(run, startedMs, loop, verified, loop.claimedDone);
225
255
  } catch (error) {
226
- run.wallClockMs = Math.round(performance.now() - startedMs);
227
- run.failure = error instanceof Error ? error.message : String(error);
228
- return run;
256
+ return finish(run, startedMs, outcome, false, outcome.claimedDone, {
257
+ failure: error instanceof Error ? error.message : String(error)
258
+ });
229
259
  } finally {
230
260
  await browser.close();
231
261
  }
@@ -233,12 +263,12 @@ ${text}` : text;
233
263
  var DOM_TOOLS = [
234
264
  {
235
265
  name: "read_page",
236
- description: "Read the page's current accessibility tree (roles and names). Call after every action that changes the page.",
266
+ description: "Read the page's current accessibility tree (roles and names).",
237
267
  parameters: { type: "object", properties: {} }
238
268
  },
239
269
  {
240
270
  name: "click",
241
- description: "Click the element with this ARIA role and accessible name.",
271
+ description: "Click the element with this ARIA role and accessible name. Returns the updated page.",
242
272
  parameters: {
243
273
  type: "object",
244
274
  properties: {
@@ -250,7 +280,7 @@ var DOM_TOOLS = [
250
280
  },
251
281
  {
252
282
  name: "fill",
253
- description: "Type a value into the input with this ARIA role and accessible name (replaces content).",
283
+ description: "Type a value into the input with this ARIA role and accessible name (replaces content). Returns the updated page.",
254
284
  parameters: {
255
285
  type: "object",
256
286
  properties: {
@@ -260,53 +290,88 @@ var DOM_TOOLS = [
260
290
  },
261
291
  required: ["role", "name", "value"]
262
292
  }
293
+ },
294
+ {
295
+ name: "select_option",
296
+ description: "Choose an option in a dropdown (select) by its accessible name and the option's visible label. Returns the updated page.",
297
+ parameters: {
298
+ type: "object",
299
+ properties: {
300
+ name: { type: "string", description: "Accessible name of the dropdown" },
301
+ option: { type: "string", description: "Visible label of the option to choose" }
302
+ },
303
+ required: ["name", "option"]
304
+ }
263
305
  }
264
306
  ];
265
- var MAX_SNAPSHOT_CHARS = 24e3;
266
- async function runDomLane(url, task, llm) {
307
+ var DEFAULT_MAX_SNAPSHOT_CHARS = 12e4;
308
+ var SETTLE_MS = 150;
309
+ async function runDomLane(url, task, llm, options = {}) {
310
+ const maxSnapshotChars = options.maxSnapshotChars ?? DEFAULT_MAX_SNAPSHOT_CHARS;
267
311
  const browser = await chromium.launch();
268
312
  const run = metrics("dom", task);
313
+ const outcome = newLoopOutcome();
269
314
  const startedMs = performance.now();
270
315
  try {
271
316
  const context = await browser.newContext();
272
317
  const page = await context.newPage();
273
318
  await page.goto(url, { waitUntil: "load" });
319
+ await assertNotAlreadySatisfied(page, task);
274
320
  const target = (role, name) => page.getByRole(String(role), {
275
321
  name: String(name),
276
322
  exact: false
277
323
  }).first();
278
324
  const loop = await runAgentLoop({
279
325
  llm,
280
- systemPrompt: "You complete tasks on a web page the way a browser-driving agent does: read the accessibility tree with read_page, then click and fill elements by role and name. Re-read the page after actions that change it. When the task is fully done, call task_complete.",
326
+ systemPrompt: "You complete tasks on a web page the way a browser-driving agent does: read the accessibility tree with read_page, then click, fill and select_option by role and name. Every action returns the updated page. When the task is fully done, call task_complete.",
281
327
  taskPrompt: task.prompt,
282
328
  tools: DOM_TOOLS,
283
329
  maxTurns: task.maxTurns,
284
330
  invoke: async (name, args) => {
331
+ const snapshot = async () => {
332
+ const text = await page.locator("body").ariaSnapshot();
333
+ if (text.length <= maxSnapshotChars) return text;
334
+ run.snapshotTruncated = true;
335
+ return text.slice(0, maxSnapshotChars) + "\n\u2026 (truncated)";
336
+ };
285
337
  switch (name) {
286
- case "read_page": {
287
- const snapshot = await page.locator("body").ariaSnapshot();
288
- return snapshot.length > MAX_SNAPSHOT_CHARS ? snapshot.slice(0, MAX_SNAPSHOT_CHARS) + "\n\u2026 (truncated)" : snapshot;
289
- }
338
+ case "read_page":
339
+ return snapshot();
290
340
  case "click": {
291
341
  await target(args.role, args.name).click({ timeout: 5e3 });
292
- await page.waitForTimeout(250);
293
- return `Clicked ${String(args.role)} "${String(args.name)}".`;
342
+ await page.waitForTimeout(SETTLE_MS);
343
+ return `Clicked ${String(args.role)} "${String(args.name)}".
344
+
345
+ ${await snapshot()}`;
294
346
  }
295
347
  case "fill": {
296
348
  await target(args.role, args.name).fill(String(args.value ?? ""), { timeout: 5e3 });
297
- return `Filled ${String(args.role)} "${String(args.name)}".`;
349
+ await page.waitForTimeout(SETTLE_MS);
350
+ return `Filled ${String(args.role)} "${String(args.name)}".
351
+
352
+ ${await snapshot()}`;
353
+ }
354
+ case "select_option": {
355
+ await target("combobox", args.name).selectOption(
356
+ { label: String(args.option) },
357
+ { timeout: 5e3 }
358
+ );
359
+ await page.waitForTimeout(SETTLE_MS);
360
+ return `Selected "${String(args.option)}" in "${String(args.name)}".
361
+
362
+ ${await snapshot()}`;
298
363
  }
299
364
  default:
300
365
  return `ACTION FAILED: unknown action "${name}"`;
301
366
  }
302
367
  }
303
- });
304
- const verified = loop.claimedDone && await verify(page, task);
368
+ }, outcome);
369
+ const verified = loop.claimedDone && await matchesSuccess(page, task);
305
370
  return finish(run, startedMs, loop, verified, loop.claimedDone);
306
371
  } catch (error) {
307
- run.wallClockMs = Math.round(performance.now() - startedMs);
308
- run.failure = error instanceof Error ? error.message : String(error);
309
- return run;
372
+ return finish(run, startedMs, outcome, false, outcome.claimedDone, {
373
+ failure: error instanceof Error ? error.message : String(error)
374
+ });
310
375
  } finally {
311
376
  await browser.close();
312
377
  }
@@ -321,19 +386,37 @@ function median(values) {
321
386
  const mid = Math.floor(sorted.length / 2);
322
387
  return sorted.length % 2 === 1 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
323
388
  }
389
+ function mean(values) {
390
+ if (values.length === 0) return 0;
391
+ return Math.round(values.reduce((sum, v) => sum + v, 0) / values.length);
392
+ }
393
+ function range(values) {
394
+ if (values.length === 0) return [0, 0];
395
+ return [Math.min(...values), Math.max(...values)];
396
+ }
324
397
  function aggregate(lane, runs) {
398
+ const ok = runs.filter((r) => r.success);
399
+ const wall = ok.map((r) => r.wallClockMs);
400
+ const tokens = ok.map((r) => r.totalTokens);
325
401
  return {
326
402
  lane,
327
403
  runs,
328
- successRate: runs.length === 0 ? 0 : runs.filter((r) => r.success).length / runs.length,
329
- medianWallClockMs: median(runs.map((r) => r.wallClockMs)),
330
- medianTotalTokens: median(runs.map((r) => r.totalTokens)),
331
- medianTurns: median(runs.map((r) => r.turns))
404
+ successRate: runs.length === 0 ? 0 : ok.length / runs.length,
405
+ medianWallClockMs: median(wall),
406
+ medianTotalTokens: median(tokens),
407
+ medianTurns: median(ok.map((r) => r.turns)),
408
+ meanWallClockMs: mean(wall),
409
+ meanTotalTokens: mean(tokens),
410
+ wallClockRangeMs: range(wall),
411
+ totalTokensRange: range(tokens)
332
412
  };
333
413
  }
334
414
  function seconds(ms) {
335
415
  return (ms / 1e3).toFixed(1) + "s";
336
416
  }
417
+ function ratio(dom, tools) {
418
+ return tools > 0 ? (dom / tools).toFixed(2) + "x" : "n/a";
419
+ }
337
420
  function toMarkdown(report) {
338
421
  const lines = [
339
422
  `# AgentPerf report \u2014 ${report.task.id}`,
@@ -343,30 +426,39 @@ function toMarkdown(report) {
343
426
  `- **Task:** ${report.task.prompt}`,
344
427
  `- **Started:** ${report.startedAt}`,
345
428
  "",
346
- "| lane | success | median wall-clock | median tokens | median round-trips |",
347
- "|------|---------|-------------------|---------------|--------------------|"
429
+ "| lane | success | median wall-clock | mean wall-clock | median tokens | mean tokens | round-trips |",
430
+ "|------|---------|-------------------|-----------------|---------------|-------------|-------------|"
348
431
  ];
349
432
  for (const lane of report.lanes) {
350
- const success = `${Math.round(lane.successRate * 100)}% (${lane.runs.filter((r) => r.success).length}/${lane.runs.length})`;
433
+ const passed = lane.runs.filter((r) => r.success).length;
351
434
  lines.push(
352
- `| ${lane.lane} | ${success} | ${seconds(lane.medianWallClockMs)} | ${lane.medianTotalTokens.toLocaleString()} | ${lane.medianTurns} |`
435
+ `| ${lane.lane} | ${Math.round(lane.successRate * 100)}% (${passed}/${lane.runs.length}) | ${seconds(lane.medianWallClockMs)} | ${seconds(lane.meanWallClockMs)} | ${lane.medianTotalTokens.toLocaleString()} | ${lane.meanTotalTokens.toLocaleString()} | ${lane.medianTurns} |`
353
436
  );
354
437
  }
355
438
  const tools = report.lanes.find((l) => l.lane === "tools");
356
439
  const dom = report.lanes.find((l) => l.lane === "dom");
357
- if (tools && dom && tools.medianTotalTokens > 0 && tools.medianWallClockMs > 0) {
440
+ if (tools && dom && tools.medianTotalTokens > 0) {
358
441
  lines.push(
359
442
  "",
360
- `**DOM lane pays ${(dom.medianTotalTokens / tools.medianTotalTokens).toFixed(1)}x the tokens and ${(dom.medianWallClockMs / tools.medianWallClockMs).toFixed(1)}x the wall-clock of the tools lane** (medians; failed runs included in success rate, excluded from nothing).`
443
+ `**DOM vs tools \u2014 tokens ${ratio(dom.medianTotalTokens, tools.medianTotalTokens)} (median) / ${ratio(dom.meanTotalTokens, tools.meanTotalTokens)} (mean); wall-clock ${ratio(dom.medianWallClockMs, tools.medianWallClockMs)} (median) / ${ratio(dom.meanWallClockMs, tools.meanWallClockMs)} (mean); round-trips ${dom.medianTurns} vs ${tools.medianTurns}.**`,
444
+ "",
445
+ `Spread \u2014 DOM ${seconds(dom.wallClockRangeMs[0])}\u2013${seconds(dom.wallClockRangeMs[1])}, ${dom.totalTokensRange[0].toLocaleString()}\u2013${dom.totalTokensRange[1].toLocaleString()} tokens; tools ${seconds(tools.wallClockRangeMs[0])}\u2013${seconds(tools.wallClockRangeMs[1])}, ${tools.totalTokensRange[0].toLocaleString()}\u2013${tools.totalTokensRange[1].toLocaleString()} tokens.`,
446
+ "",
447
+ "Central tendencies are over successful runs only; failures are counted in the success rate. Token counts are uncached prompt+completion totals as the API reports them \u2014 provider prompt caching may reduce billed cost, and does so unequally across lanes."
361
448
  );
362
449
  }
363
450
  lines.push("", "## Runs", "");
364
- lines.push("| lane | # | success | wall-clock | tokens | round-trips | actions | failure |");
365
- lines.push("|------|---|---------|------------|--------|-------------|---------|---------|");
451
+ lines.push("| lane | # | success | wall-clock | tokens | round-trips | actions | notes |");
452
+ lines.push("|------|---|---------|------------|--------|-------------|---------|-------|");
366
453
  for (const lane of report.lanes) {
367
454
  lane.runs.forEach((r, i) => {
455
+ const notes = [
456
+ r.failure,
457
+ r.snapshotTruncated ? "snapshot truncated \u2014 cost is a floor" : "",
458
+ r.claimSummary ? `claim: ${r.claimSummary.slice(0, 90)}` : ""
459
+ ].filter(Boolean).join("; ");
368
460
  lines.push(
369
- `| ${r.lane} | ${i + 1} | ${r.success ? "\u2713" : "\u2717"} | ${seconds(r.wallClockMs)} | ${r.totalTokens.toLocaleString()} | ${r.turns} | ${r.actions} | ${r.failure ?? ""} |`
461
+ `| ${r.lane} | ${i + 1} | ${r.success ? "\u2713" : "\u2717"} | ${seconds(r.wallClockMs)} | ${r.totalTokens.toLocaleString()} | ${r.turns} | ${r.actions} | ${notes} |`
370
462
  );
371
463
  });
372
464
  }
@@ -388,6 +480,7 @@ export {
388
480
  runAgentLoop,
389
481
  HOST_SHIM,
390
482
  runToolsLane,
483
+ DEFAULT_MAX_SNAPSHOT_CHARS,
391
484
  runDomLane,
392
485
  aggregate,
393
486
  toMarkdown,
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ DEFAULT_MAX_SNAPSHOT_CHARS,
3
4
  aggregate,
4
5
  runDomLane,
5
6
  runToolsLane,
6
7
  toMarkdown,
7
8
  writeReport
8
- } from "./chunk-BGU3NILX.js";
9
+ } from "./chunk-FCYQOMLH.js";
9
10
 
10
11
  // src/cli.ts
11
12
  import { readFileSync } from "fs";
@@ -43,6 +44,9 @@ async function main() {
43
44
  const lanes = laneArg === "both" ? ["dom", "tools"] : [laneArg];
44
45
  const runs = Number(arg("runs", "3"));
45
46
  const outDir = arg("out", "results");
47
+ const laneOptions = {
48
+ maxSnapshotChars: Number(arg("max-snapshot-chars", String(DEFAULT_MAX_SNAPSHOT_CHARS)))
49
+ };
46
50
  console.log(`AgentPerf \u2014 ${task.id} @ ${url}`);
47
51
  console.log(`model ${llm.model}, ${runs} run(s) per lane: ${lanes.join(", ")}
48
52
  `);
@@ -57,10 +61,10 @@ async function main() {
57
61
  const results = [];
58
62
  for (let i = 1; i <= runs; i++) {
59
63
  process.stdout.write(`[${lane}] run ${i}/${runs}\u2026 `);
60
- const result = lane === "tools" ? await runToolsLane(url, task, llm) : await runDomLane(url, task, llm);
64
+ const result = lane === "tools" ? await runToolsLane(url, task, llm) : await runDomLane(url, task, llm, laneOptions);
61
65
  results.push(result);
62
66
  console.log(
63
- result.success ? `\u2713 ${(result.wallClockMs / 1e3).toFixed(1)}s, ${result.totalTokens} tokens, ${result.turns} turns` : `\u2717 ${result.failure}`
67
+ result.success ? `\u2713 ${(result.wallClockMs / 1e3).toFixed(1)}s, ${result.totalTokens} tokens, ${result.turns} turns${result.snapshotTruncated ? " (snapshot truncated \u2014 cost is a floor)" : ""}` : `\u2717 ${result.failure}`
64
68
  );
65
69
  }
66
70
  report.lanes.push(aggregate(lane, results));
package/dist/index.d.ts CHANGED
@@ -27,10 +27,22 @@ interface TaskSpec {
27
27
  /** Hard cap on model round-trips before the run is failed. */
28
28
  maxTurns: number;
29
29
  }
30
+ interface LaneOptions {
31
+ /**
32
+ * Cap on the accessibility snapshot handed to the DOM lane per `read_page`.
33
+ * Set it high enough that the page is never truncated: a truncated snapshot
34
+ * handicaps the DOM lane instead of letting it pay honestly in tokens.
35
+ */
36
+ maxSnapshotChars?: number;
37
+ }
30
38
  interface RunMetrics {
31
39
  lane: Lane;
32
40
  taskId: string;
33
41
  success: boolean;
42
+ /** Set when the DOM lane's snapshot hit the cap — the run is then a floor, not a measurement. */
43
+ snapshotTruncated?: boolean;
44
+ /** What the model said it did, recorded so a reader can audit the run rather than trust it. */
45
+ claimSummary?: string;
34
46
  /** Why a run failed, when it did. */
35
47
  failure?: string;
36
48
  wallClockMs: number;
@@ -46,9 +58,14 @@ interface LaneAggregate {
46
58
  lane: Lane;
47
59
  runs: RunMetrics[];
48
60
  successRate: number;
61
+ /** Central tendencies are over SUCCESSFUL runs only; failures live in successRate. */
49
62
  medianWallClockMs: number;
50
63
  medianTotalTokens: number;
51
64
  medianTurns: number;
65
+ meanWallClockMs: number;
66
+ meanTotalTokens: number;
67
+ wallClockRangeMs: [number, number];
68
+ totalTokensRange: [number, number];
52
69
  }
53
70
  interface BenchmarkReport {
54
71
  url: string;
@@ -77,7 +94,14 @@ declare global {
77
94
  }
78
95
  }
79
96
  declare function runToolsLane(url: string, task: TaskSpec, llm: LlmConfig): Promise<RunMetrics>;
80
- declare function runDomLane(url: string, task: TaskSpec, llm: LlmConfig): Promise<RunMetrics>;
97
+ /**
98
+ * Deliberately generous: a heavy page must reach the DOM lane whole, so it
99
+ * pays for the page in tokens rather than being handicapped by truncation.
100
+ * Runs that still hit the cap are flagged — their cost is a floor, not a
101
+ * measurement.
102
+ */
103
+ declare const DEFAULT_MAX_SNAPSHOT_CHARS = 120000;
104
+ declare function runDomLane(url: string, task: TaskSpec, llm: LlmConfig, options?: LaneOptions): Promise<RunMetrics>;
81
105
 
82
106
  /**
83
107
  * The agent loop both lanes share, so nothing about the driver differs
@@ -101,8 +125,13 @@ declare function runAgentLoop(options: {
101
125
  tools: LlmToolDef[];
102
126
  invoke: (name: string, args: Record<string, unknown>) => Promise<string>;
103
127
  maxTurns: number;
104
- }): Promise<LoopOutcome>;
128
+ }, outcome?: LoopOutcome): Promise<LoopOutcome>;
105
129
 
130
+ /**
131
+ * Central tendencies are computed over successful runs only. A crashed run
132
+ * records near-zero cost, and averaging that in would drag a lane's reported
133
+ * cost toward zero — understating exactly the lane that failed.
134
+ */
106
135
  declare function aggregate(lane: Lane, runs: RunMetrics[]): LaneAggregate;
107
136
  declare function toMarkdown(report: BenchmarkReport): string;
108
137
  declare function writeReport(report: BenchmarkReport, outDir: string): {
@@ -118,4 +147,4 @@ declare function writeReport(report: BenchmarkReport, outDir: string): {
118
147
  */
119
148
  declare const HOST_SHIM = "\n(() => {\n const tools = new Map();\n const api = {\n listTools: () =>\n Array.from(tools.values()).map((t) => ({\n name: t.name,\n description: t.description,\n inputSchema: t.inputSchema || { type: \"object\", properties: {} },\n annotations: t.annotations || {}\n })),\n call: async (name, args) => {\n const tool = tools.get(name);\n if (!tool) {\n return {\n content: [{ type: \"text\", text: \"No tool named \\\"\" + name + \"\\\" is registered on this page.\" }],\n isError: true\n };\n }\n const result = await tool.execute(args || {});\n if (typeof result === \"string\") return { content: [{ type: \"text\", text: result }] };\n return result;\n }\n };\n Object.defineProperty(window, \"__agentperf\", { value: api });\n\n const modelContext = {\n registerTool: (tool, options) => {\n tools.set(tool.name, tool);\n if (options && options.signal) {\n options.signal.addEventListener(\"abort\", () => { tools.delete(tool.name); });\n }\n },\n getTools: async () => api.listTools()\n };\n Object.defineProperty(document, \"modelContext\", { value: modelContext, configurable: true });\n Object.defineProperty(navigator, \"modelContext\", { value: modelContext, configurable: true });\n})();\n";
120
149
 
121
- export { type BenchmarkReport, HOST_SHIM, type Lane, type LaneAggregate, type RunMetrics, type TaskSpec, aggregate, runAgentLoop, runDomLane, runToolsLane, toMarkdown, writeReport };
150
+ export { type BenchmarkReport, DEFAULT_MAX_SNAPSHOT_CHARS, HOST_SHIM, type Lane, type LaneAggregate, type LaneOptions, type RunMetrics, type TaskSpec, aggregate, runAgentLoop, runDomLane, runToolsLane, toMarkdown, writeReport };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DEFAULT_MAX_SNAPSHOT_CHARS,
2
3
  HOST_SHIM,
3
4
  aggregate,
4
5
  runAgentLoop,
@@ -6,8 +7,9 @@ import {
6
7
  runToolsLane,
7
8
  toMarkdown,
8
9
  writeReport
9
- } from "./chunk-BGU3NILX.js";
10
+ } from "./chunk-FCYQOMLH.js";
10
11
  export {
12
+ DEFAULT_MAX_SNAPSHOT_CHARS,
11
13
  HOST_SHIM,
12
14
  aggregate,
13
15
  runAgentLoop,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentperf",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Measure what AI agents pay to use a website: the same task via DOM driving vs. WebMCP tool calls — wall-clock, tokens, round-trips, success rate.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {