debutly 0.0.0-stage → 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.
Files changed (93) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +192 -2
  3. package/bin/debutly.mjs +8 -0
  4. package/drizzle/0000_init.sql +107 -0
  5. package/drizzle/0001_predictions.sql +14 -0
  6. package/drizzle/0002_dashboard.sql +15 -0
  7. package/drizzle/meta/0000_snapshot.json +722 -0
  8. package/drizzle/meta/0001_snapshot.json +821 -0
  9. package/drizzle/meta/0002_snapshot.json +942 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +83 -4
  12. package/src/agent/describe.ts +29 -0
  13. package/src/agent/effect.ts +94 -0
  14. package/src/agent/give-up.ts +42 -0
  15. package/src/agent/label.ts +19 -0
  16. package/src/agent/loop.ts +302 -0
  17. package/src/agent/parse-reply.ts +127 -0
  18. package/src/agent/prompt.ts +85 -0
  19. package/src/agent/run-agent.ts +68 -0
  20. package/src/artifacts/db.ts +55 -0
  21. package/src/artifacts/s3.ts +36 -0
  22. package/src/artifacts/store.ts +26 -0
  23. package/src/bin.ts +140 -0
  24. package/src/browser/ax.ts +77 -0
  25. package/src/browser/execute.ts +401 -0
  26. package/src/browser/observe.ts +62 -0
  27. package/src/browser/reflow.ts +73 -0
  28. package/src/browser/samples.ts +119 -0
  29. package/src/browser/scan-script.ts +108 -0
  30. package/src/browser/settle.ts +32 -0
  31. package/src/browser/signals.ts +73 -0
  32. package/src/cancel.ts +20 -0
  33. package/src/cli.ts +281 -0
  34. package/src/config.ts +62 -0
  35. package/src/cost/estimate.ts +32 -0
  36. package/src/cost/prices.ts +32 -0
  37. package/src/cost/tiers.ts +27 -0
  38. package/src/costs.ts +96 -0
  39. package/src/crew/run-unit.ts +62 -0
  40. package/src/detect/findings.ts +649 -0
  41. package/src/detect/wcag.ts +38 -0
  42. package/src/doctor.ts +145 -0
  43. package/src/hear-cli.ts +137 -0
  44. package/src/hear.ts +309 -0
  45. package/src/import.ts +108 -0
  46. package/src/inbox/mailpit.ts +46 -0
  47. package/src/inbox/types.ts +10 -0
  48. package/src/init.ts +184 -0
  49. package/src/llm/anthropic.ts +41 -0
  50. package/src/llm/credits.ts +35 -0
  51. package/src/llm/factory.ts +65 -0
  52. package/src/llm/meter.ts +39 -0
  53. package/src/llm/openai-compatible.ts +90 -0
  54. package/src/llm/openrouter.ts +9 -0
  55. package/src/llm/scripted.ts +18 -0
  56. package/src/llm/types.ts +36 -0
  57. package/src/perceive.ts +150 -0
  58. package/src/perception/aggregate.ts +111 -0
  59. package/src/perception/compare.ts +77 -0
  60. package/src/perception/evaluate.ts +83 -0
  61. package/src/perception/exhibits.ts +146 -0
  62. package/src/perception/market-context.ts +50 -0
  63. package/src/perception/run-perception.ts +178 -0
  64. package/src/persona/generate.ts +92 -0
  65. package/src/persona/inclusion.ts +53 -0
  66. package/src/persona/language.ts +59 -0
  67. package/src/persona/people.ts +32 -0
  68. package/src/predictions.ts +33 -0
  69. package/src/queue/dispatch.ts +18 -0
  70. package/src/queue/jobs.ts +28 -0
  71. package/src/queue/processor.ts +42 -0
  72. package/src/report/fix-first.ts +121 -0
  73. package/src/report/launch-readiness.ts +106 -0
  74. package/src/report/markdown.ts +202 -0
  75. package/src/run.ts +141 -0
  76. package/src/sandbox/outbox.ts +65 -0
  77. package/src/store/db.ts +40 -0
  78. package/src/store/import-run.ts +116 -0
  79. package/src/store/pg-run-store.ts +163 -0
  80. package/src/store/predictions.ts +70 -0
  81. package/src/store/run-store.ts +78 -0
  82. package/src/store/schema.ts +193 -0
  83. package/src/store/url.ts +15 -0
  84. package/src/types.ts +201 -0
  85. package/src/util/chromium.ts +20 -0
  86. package/src/util/json.ts +7 -0
  87. package/src/util/key-defaults.ts +34 -0
  88. package/src/util/legacy-env.ts +14 -0
  89. package/src/util/load-env.ts +28 -0
  90. package/src/util/pool.ts +12 -0
  91. package/src/util/run-origin.ts +27 -0
  92. package/src/worker.ts +53 -0
  93. package/tsconfig.json +13 -0
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1790627348170,
9
+ "tag": "0000_init",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1790631168176,
16
+ "tag": "0001_predictions",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1791025761130,
23
+ "tag": "0002_dashboard",
24
+ "breakpoints": true
25
+ }
26
+ ]
27
+ }
package/package.json CHANGED
@@ -1,6 +1,85 @@
1
1
  {
2
2
  "name": "debutly",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Rehearse your launch: AI agents use your web app like your first customers and report what breaks.",
5
+ "type": "module",
6
+ "license": "AGPL-3.0-only",
7
+ "author": "Chetan Sroay (https://github.com/sroay)",
8
+ "homepage": "https://debutly.dev",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/sroay/debutly.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/sroay/debutly/issues"
15
+ },
16
+ "keywords": [
17
+ "testing",
18
+ "e2e",
19
+ "ai-agents",
20
+ "playwright",
21
+ "llm",
22
+ "synthetic-users",
23
+ "qa",
24
+ "launch"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20.12"
28
+ },
29
+ "bin": {
30
+ "debutly": "bin/debutly.mjs"
31
+ },
32
+ "files": [
33
+ "bin/",
34
+ "src/",
35
+ "drizzle/",
36
+ "tsconfig.json",
37
+ "LICENSE",
38
+ "README.md"
39
+ ],
40
+ "workspaces": [
41
+ "dashboard"
42
+ ],
43
+ "scripts": {
44
+ "test": "vitest run",
45
+ "typecheck": "tsc --noEmit",
46
+ "fixture": "tsx fixture/main.ts",
47
+ "debutly": "tsx src/cli.ts",
48
+ "debutly:cancel": "tsx src/cancel.ts",
49
+ "debutly:worker": "tsx src/worker.ts",
50
+ "debutly:predictions": "tsx src/predictions.ts",
51
+ "debutly:perceive": "tsx src/perceive.ts",
52
+ "debutly:costs": "tsx src/costs.ts",
53
+ "debutly:import": "tsx src/import.ts",
54
+ "dashboard": "npm run dev -w dashboard",
55
+ "dashboard:build": "npm run build -w dashboard",
56
+ "docs": "tsx scripts/build-docs.ts"
57
+ },
58
+ "dependencies": {
59
+ "@anthropic-ai/sdk": "^0.128.0",
60
+ "@aws-sdk/client-s3": "^3.1142.0",
61
+ "@electric-sql/pglite": "^0.5.8",
62
+ "bullmq": "^6.3.9",
63
+ "drizzle-orm": "^0.45.3",
64
+ "express": "^5.2.1",
65
+ "ioredis": "^5.11.1",
66
+ "playwright": "^1.63.0",
67
+ "postgres": "^3.4.9",
68
+ "tsx": "^4.23.15",
69
+ "zod": "^4.6.5"
70
+ },
71
+ "devDependencies": {
72
+ "@types/express": "^5.0.6",
73
+ "@types/node": "^26.6.3",
74
+ "drizzle-kit": "^0.31.11",
75
+ "marked": "18.0.14",
76
+ "typescript": "^7.0.2",
77
+ "vitest": "^5.0.2"
78
+ },
79
+ "publishConfig": {
80
+ "access": "public"
81
+ },
82
+ "optionalDependencies": {
83
+ "@guidepup/guidepup": "0.36.4"
84
+ }
85
+ }
@@ -0,0 +1,29 @@
1
+ import type { Action, Observation } from "../types";
2
+
3
+ export function describeAction(action: Action, obs: Observation): string {
4
+ const target = (id: number) => {
5
+ const el = obs.elements.find((e) => e.id === id);
6
+ return el ? `${el.role} "${el.name}"` : `element #${id}`;
7
+ };
8
+ switch (action.type) {
9
+ case "click":
10
+ return `clicked ${target(action.id)}`;
11
+ case "fill": {
12
+ const el = obs.elements.find((e) => e.id === action.id);
13
+ const picks = el && (el.tag === "select" || (el.role === "combobox" && el.tag !== "input" && el.tag !== "textarea"));
14
+ return picks ? `chose "${action.text}" in ${target(action.id)}` : `typed "${action.text}" into ${target(action.id)}`;
15
+ }
16
+ case "upload":
17
+ return `uploaded the sample ${action.file ?? "file"} with ${target(action.id)}`;
18
+ case "goto":
19
+ return `went to ${action.path}`;
20
+ case "check_email":
21
+ return "checked email";
22
+ case "press":
23
+ return `pressed ${action.key}`;
24
+ case "wait":
25
+ return "waited for the page to load";
26
+ case "done":
27
+ return `finished (${action.success ? "success" : "gave up"}): ${action.note}`;
28
+ }
29
+ }
@@ -0,0 +1,94 @@
1
+ import type { Action, ActionResult, InteractiveElement, Observation, Signal } from "../types";
2
+ import { controlLabel, type StepView } from "./label";
3
+
4
+ const pathOf = (url: string) => {
5
+ try {
6
+ const u = new URL(url);
7
+ return u.pathname;
8
+ } catch {
9
+ return url;
10
+ }
11
+ };
12
+
13
+ const MAX_LISTED = 4;
14
+ const MAX_LINE = 80;
15
+
16
+ type Label = (e: InteractiveElement) => string;
17
+
18
+ /** Controls in `a` beyond those in `b`, counting copies: a second "Untitled" row is new. */
19
+ function beyond(a: InteractiveElement[], b: InteractiveElement[], controlOf: Label): InteractiveElement[] {
20
+ const left = new Map<string, number>();
21
+ for (const e of b) left.set(controlOf(e), (left.get(controlOf(e)) ?? 0) + 1);
22
+ return a.filter((e) => {
23
+ const n = left.get(controlOf(e)) ?? 0;
24
+ if (n > 0) left.set(controlOf(e), n - 1);
25
+ return n === 0;
26
+ });
27
+ }
28
+
29
+ /** Controls still there whose state changed: a Save button now enabled, a box now checked. */
30
+ function stateChanges(before: InteractiveElement[], after: InteractiveElement[], controlOf: Label): string[] {
31
+ const out: string[] = [];
32
+ for (const a of after) {
33
+ const b = before.find((e) => controlOf(e) === controlOf(a));
34
+ if (!b) continue;
35
+ if (!!b.disabled !== !!a.disabled) out.push(`now ${a.disabled ? "disabled" : "enabled"} ${controlOf(a)}`);
36
+ if (b.checked !== undefined && a.checked !== undefined && b.checked !== a.checked) out.push(`now ${a.checked ? "checked" : "unchecked"} ${controlOf(a)}`);
37
+ if (b.expanded !== undefined && a.expanded !== undefined && b.expanded !== a.expanded) out.push(`now ${a.expanded ? "open" : "closed"} ${controlOf(a)}`);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ const listed = (items: string[]) => items.slice(0, MAX_LISTED).join(", ") + (items.length > MAX_LISTED ? `, and ${items.length - MAX_LISTED} more` : "");
43
+
44
+ /**
45
+ * What changed on the page, as a person would notice it: controls that appeared or went, state
46
+ * that changed, and new lines of text. Told only "the page changed", agents missed a password
47
+ * field that had appeared, pressed "Create company" seven times without seeing seven new rows,
48
+ * and kept retyping values while waiting for a Save button to wake up.
49
+ */
50
+ function whatChanged(before: Observation, after: Observation, controlOf: Label): string {
51
+ const appeared = beyond(after.elements, before.elements, controlOf);
52
+ const gone = beyond(before.elements, after.elements, controlOf);
53
+ const names = new Set(after.elements.map((e) => e.name));
54
+ const had = new Set(before.text.split("\n").map((l) => l.trim()));
55
+ const newText = [...new Set(after.text.split("\n").map((l) => l.trim()))].filter((l) => l && !had.has(l) && !names.has(l)).map((l) => `"${l.slice(0, MAX_LINE)}"`);
56
+ const parts = [
57
+ appeared.length ? `new ${listed(appeared.map(controlOf))}` : "",
58
+ gone.length ? `gone ${listed(gone.map(controlOf))}` : "",
59
+ ...stateChanges(before.elements, after.elements, controlOf).slice(0, MAX_LISTED),
60
+ newText.length ? `new text ${listed(newText)}` : "",
61
+ ].filter(Boolean);
62
+ return parts.length ? `the page changed: ${parts.join("; ")}` : "";
63
+ }
64
+
65
+ // What a person would notice after acting: where they are now, whether anything changed,
66
+ // and whether something visibly broke. Without this, agents retry dead buttons forever.
67
+ // Controls are named as the agent meets them, as in its step message.
68
+ export function describeEffect(action: Action, before: Observation, after: Observation, signals: Signal[], result: ActionResult, view: StepView = {}): string {
69
+ if (result.blocked) return `blocked: ${result.blocked}`;
70
+ if (result.error) return `failed: ${result.error}`;
71
+ // check_email reports its own result; done ends the session.
72
+ if (action.type === "done" || action.type === "check_email") return "";
73
+
74
+ const parts: string[] = [];
75
+ if (result.enterIgnored) parts.push("Enter did nothing on it, so Space was pressed instead");
76
+ const changed = whatChanged(before, after, (e) => controlLabel(e, view.screenReader));
77
+ if (pathOf(after.url) !== pathOf(before.url)) parts.push(`now on ${pathOf(after.url)}`);
78
+ else if (changed) parts.push(changed);
79
+ else if (after.text !== before.text) parts.push("the page changed");
80
+ else if (action.type !== "fill" && !signals.some((s) => s.kind === "download")) parts.push("nothing visibly changed");
81
+
82
+ for (const s of signals) if (s.kind === "download") parts.push(`a file downloaded: ${s.filename}`);
83
+
84
+ for (const s of signals) if (s.kind === "dialog") parts.push(`a ${s.dialogType} dialog said "${s.message}" (answered OK)`);
85
+ if (signals.some((s) => s.kind === "page_error")) parts.push("a JavaScript error occurred");
86
+ const failed = [
87
+ ...new Set(
88
+ signals.filter((s) => s.kind === "http_error" && pathOf(s.requestUrl) !== "/favicon.ico").map((s) => (s.kind === "http_error" ? s.status : 0)),
89
+ ),
90
+ ];
91
+ for (const status of failed) parts.push(`a request failed (HTTP ${status})`);
92
+
93
+ return parts.join("; ");
94
+ }
@@ -0,0 +1,42 @@
1
+ import type { Observation, Step } from "../types";
2
+
3
+ // What a person presses before giving up: to go on, join, sign in or start, or to add or create
4
+ // what they came to add (agents said adding a person was "missing" beside "Add person").
5
+ // Arabic too, as agents often use Arabic interfaces.
6
+ const WAY_ON =
7
+ /^(continue|next|proceed|join\b.*|accept\b.*|sign ?in|log ?in|sign ?up|create (an )?account|get started|start|confirm|verify|submit|(add|create|new)\b.*|استمر|متابعة|التالي|انضم.*|قبول.*|تسجيل الدخول|ابدأ.*|تأكيد|إرسال|(إنشاء|أضف|إضافة).*)$/i;
8
+ // Buttons only: a page's own way on. A "Sign up" link in the site's menu is not one from where it stands.
9
+ const PRESSABLE = /^(button|input:(submit|button))$/;
10
+
11
+ const pathOf = (url: string) => {
12
+ try {
13
+ return new URL(url).pathname;
14
+ } catch {
15
+ return url;
16
+ }
17
+ };
18
+
19
+ /**
20
+ * The ways on that an agent about to give up hasn't tried on the page it is on: enabled buttons
21
+ * named like Continue, Join or Sign in that it hasn't pressed since it came to this page.
22
+ * A teammate agent gave up on Twenty's invite page saying there was "no way into the workspace",
23
+ * with a Continue button under its eyes (2026-10-25), and the report blamed the app.
24
+ */
25
+ export function untriedWaysOn(obs: Observation, steps: Step[], screenReader = false): string[] {
26
+ const path = pathOf(obs.url);
27
+ const pressed = new Set<string>();
28
+ for (let i = steps.length - 1; i >= 0 && pathOf(steps[i].observation.url) === path; i--) {
29
+ const { action, observation } = steps[i];
30
+ if (action.type !== "click") continue;
31
+ const el = observation.elements.find((e) => e.id === action.id);
32
+ if (el) pressed.add((el.ax?.name || el.name).trim().toLowerCase());
33
+ }
34
+ const names = obs.elements
35
+ .filter((e) => PRESSABLE.test(e.role) && !e.disabled && !(screenReader && e.ax?.hidden))
36
+ .map((e) => (screenReader && e.ax ? e.ax.name : e.name || e.ax?.name || "").trim())
37
+ .filter((name) => WAY_ON.test(name) && !pressed.has(name.toLowerCase()));
38
+ return [...new Set(names)].slice(0, 3);
39
+ }
40
+
41
+ /** A page with nothing to press is often still arriving: one agent on a slow connection gave up the moment an invite page opened. */
42
+ export const nothingToPressYet = (obs: Observation) => obs.elements.length === 0;
@@ -0,0 +1,19 @@
1
+ import type { InteractiveElement } from "../types";
2
+
3
+ // Apart from prompt.ts, which needs the browser (Playwright) through the inclusion briefs: the
4
+ // dashboard imports effect.ts, and its build has no Playwright.
5
+
6
+ export interface StepView {
7
+ /** As a screen reader presents the page: the browser's names and roles, without what is hidden from it. */
8
+ screenReader?: boolean;
9
+ }
10
+
11
+ /**
12
+ * A control as the agent meets it. A screen reader announces the role and name the browser gives
13
+ * it; a sighted person looking at a control with no text sees its icon.
14
+ */
15
+ export function controlLabel(e: InteractiveElement, screenReader = false): string {
16
+ const heard = screenReader && e.ax ? e.ax : e;
17
+ const icon = !screenReader && !e.name && e.icon ? ` (${e.icon} icon)` : "";
18
+ return `${heard.role} "${heard.name}"${icon}`;
19
+ }
@@ -0,0 +1,302 @@
1
+ import type { Page, Request } from "playwright";
2
+ import type { ArtifactStore } from "../artifacts/store";
3
+ import { execute } from "../browser/execute";
4
+ import { observe } from "../browser/observe";
5
+ import { ReflowMeter } from "../browser/reflow";
6
+ import { settle, type SettleOptions } from "../browser/settle";
7
+ import { SignalCollector } from "../browser/signals";
8
+ import type { EmailMessage, Inbox } from "../inbox/types";
9
+ import { Meter, type ModelPrice } from "../llm/meter";
10
+ import type { LlmClient, LlmMessage } from "../llm/types";
11
+ import type { ActionResult, Persona, Session, Step } from "../types";
12
+ import { describeAction } from "./describe";
13
+ import { describeEffect } from "./effect";
14
+ import { nothingToPressYet, untriedWaysOn } from "./give-up";
15
+ import { NoActionError, parseAgentReply } from "./parse-reply";
16
+ import { buildStepMessage, buildSystemPrompt, type AgentIdentity } from "./prompt";
17
+
18
+ const MAX_REPLY_ATTEMPTS = 3;
19
+ /** Steps a session may wait for want of an action in the reply, before that is an engine error. */
20
+ const MAX_NO_ACTION_WAITS = 3;
21
+ const LINKS_PER_EMAIL = 3;
22
+ /** How long the first page may take to arrive; a slow-connection agent needs more than 30 s. */
23
+ const FIRST_LOAD_MS = 90_000;
24
+ /** A first page slower than this is reported. */
25
+ const SLOW_LOAD_MS = 10_000;
26
+ /** How long a slow first page's scripts and styles may take to be weighed before it is reported without. */
27
+ const WEIGH_MS = 2_000;
28
+ /** How long a step's screenshot may take before the step goes without one. */
29
+ const SCREENSHOT_MS = 15_000;
30
+
31
+ export interface RunSessionOptions {
32
+ page: Page;
33
+ llm: LlmClient;
34
+ persona: Persona;
35
+ goal: string;
36
+ origin: string;
37
+ maxSteps: number;
38
+ /** Where step screenshots go (a local folder, or S3); none means no screenshots. */
39
+ artifacts?: ArtifactStore;
40
+ identity?: AgentIdentity;
41
+ inbox?: Inbox;
42
+ /** Shared context for the system prompt, e.g. a crew member's company. */
43
+ brief?: string;
44
+ /** Per-agent limits on top of maxSteps. */
45
+ caps?: { maxTokens?: number; maxMs?: number };
46
+ /** The run's kill switch, checked before every step. */
47
+ shouldStop?: () => Promise<boolean> | boolean;
48
+ /** Prices the session's usage; omitted or null leaves the cost unknown. */
49
+ price?: ModelPrice | null;
50
+ /** Called with each step as it completes, e.g. to record it for live views. */
51
+ onStep?: (step: Step) => Promise<void> | void;
52
+ /** How long to let each page finish loading before the agent reads it. */
53
+ settle?: SettleOptions;
54
+ /** How long the first page may take, and from when it counts as slow (defaults: 90 s, 10 s). */
55
+ firstLoad?: { timeoutMs?: number; slowMs?: number };
56
+ }
57
+
58
+ function describeInbox(messages: EmailMessage[]): string {
59
+ if (!messages.length) return "checked email → no emails yet";
60
+ const items = messages.map((m) => `"${m.subject}"${m.links.length ? ` links: ${m.links.slice(0, LINKS_PER_EMAIL).join(" ")}` : ""}`);
61
+ return `checked email → ${messages.length} email${messages.length === 1 ? "" : "s"}: ${items.join("; ")}`;
62
+ }
63
+
64
+ /**
65
+ * Open the app the way a person does: wait for the page, but never fail the session because it
66
+ * is slow. Waiting for every image and font ("load") within Playwright's 30 s ended the
67
+ * slow-connection agents on a test app's home page before their first step, two nights running.
68
+ * Now the agent starts once the document is in (settle() waits for the rest), with up to
69
+ * FIRST_LOAD_MS for that; whatever has arrived by then is what it sees, and it can wait or give up.
70
+ * A slow start is itself a finding.
71
+ */
72
+ async function openFirstPage(
73
+ page: Page,
74
+ origin: string,
75
+ signals: SignalCollector,
76
+ slowNetwork: boolean,
77
+ { timeoutMs = FIRST_LOAD_MS, slowMs = SLOW_LOAD_MS }: RunSessionOptions["firstLoad"] = {},
78
+ ): Promise<void> {
79
+ const startedAt = Date.now();
80
+ let finished = true;
81
+ // What its scripts and styles weigh as sent, and how much came uncompressed: a test copy served
82
+ // straight from its container may skip the compression its real hosting does (Twenty's sent
83
+ // 10 MB uncompressed, 2026-10-24), and then a slow page says more about the copy than the app.
84
+ const assets = { files: 0, bytes: 0, uncompressedBytes: 0 };
85
+ const weighing = new Set<Promise<void>>();
86
+ const weigh = (request: Request) => {
87
+ if (request.resourceType() !== "script" && request.resourceType() !== "stylesheet") return;
88
+ const done = (async () => {
89
+ const [{ responseBodySize }, encoding] = await Promise.all([request.sizes(), request.response().then((r) => r?.headerValue("content-encoding"))]);
90
+ assets.files++;
91
+ assets.bytes += responseBodySize;
92
+ if (!encoding || encoding === "identity") assets.uncompressedBytes += responseBodySize;
93
+ })()
94
+ .catch(() => {}) // the page moved on before it could be weighed
95
+ .finally(() => weighing.delete(done));
96
+ weighing.add(done);
97
+ };
98
+ page.on("requestfinished", weigh);
99
+ try {
100
+ await page.goto(origin, { waitUntil: "domcontentloaded", timeout: timeoutMs });
101
+ } catch (err) {
102
+ if (!(err instanceof Error && err.name === "TimeoutError")) throw err;
103
+ finished = false;
104
+ } finally {
105
+ page.off("requestfinished", weigh);
106
+ }
107
+ const ms = Date.now() - startedAt;
108
+ if (!finished || ms >= slowMs) {
109
+ await Promise.race([Promise.allSettled([...weighing]), new Promise((resolve) => setTimeout(resolve, WEIGH_MS))]);
110
+ signals.note({ kind: "slow_load", url: origin, ms, finished, slowNetwork, ...(assets.files ? { assets: { ...assets } } : {}) });
111
+ }
112
+ }
113
+
114
+ export async function runSession(o: RunSessionOptions): Promise<Session> {
115
+ const signals = new SignalCollector(o.page);
116
+ const system = buildSystemPrompt(o.persona, o.goal, o.inbox ? o.identity : undefined, o.brief);
117
+ const steps: Step[] = [];
118
+ const history: string[] = [];
119
+ const emailedLinks = new Set<string>();
120
+ const meter = new Meter();
121
+ const startedAt = Date.now();
122
+ const base = { agentId: o.persona.id, personaName: o.persona.name, goal: o.goal, steps };
123
+ // The low-vision agent's pages, each measured at 320 CSS pixels wide as it arrives (WCAG 1.4.10).
124
+ const reflow = o.persona.inclusion === "low_vision" ? new ReflowMeter() : undefined;
125
+ // Every way out of the session carries what it spent, and what it measured.
126
+ const end = (outcome: Session["outcome"], outcomeNote: string): Session => ({
127
+ ...base,
128
+ outcome,
129
+ outcomeNote,
130
+ usage: { ...meter.total, costUsd: meter.costUsd(o.price) },
131
+ ...(reflow?.checks.length ? { reflow: [...reflow.checks] } : {}),
132
+ });
133
+
134
+ await openFirstPage(o.page, o.origin, signals, o.persona.inclusion === "slow_connection", o.firstLoad);
135
+
136
+ // A failure part-way (the provider out of credits, the browser gone) still ends the session
137
+ // with the steps it took: they hold findings, and they were paid for.
138
+ try {
139
+ return await takeSteps(o, { signals, system, steps, history, emailedLinks, meter, startedAt, end, reflow });
140
+ } catch (err) {
141
+ return end("error", err instanceof Error ? err.message : String(err));
142
+ }
143
+ }
144
+
145
+ interface SessionState {
146
+ signals: SignalCollector;
147
+ system: string;
148
+ steps: Step[];
149
+ history: string[];
150
+ emailedLinks: Set<string>;
151
+ meter: Meter;
152
+ startedAt: number;
153
+ end: (outcome: Session["outcome"], outcomeNote: string) => Session;
154
+ reflow?: ReflowMeter;
155
+ }
156
+
157
+ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history, emailedLinks, meter, startedAt, end, reflow }: SessionState): Promise<Session> {
158
+ // The screen-reader agent hears what the browser tells assistive technology and works by keyboard.
159
+ const screenReader = o.persona.inclusion === "screen_reader";
160
+ let noActionWaits = 0;
161
+ const askedBeforeGivingUp = new Set<string>();
162
+ for (let index = 0; index < o.maxSteps; index++) {
163
+ if (o.shouldStop && (await o.shouldStop())) return end("cancelled", "run cancelled");
164
+ const { inputTokens, outputTokens } = meter.total;
165
+ if (o.caps?.maxTokens !== undefined && inputTokens + outputTokens >= o.caps.maxTokens) {
166
+ return end("budget_exhausted", `stopped at the token cap (${o.caps.maxTokens} tokens)`);
167
+ }
168
+ if (o.caps?.maxMs !== undefined && Date.now() - startedAt >= o.caps.maxMs) {
169
+ return end("budget_exhausted", `stopped at the time cap (${Math.round(o.caps.maxMs / 1000)}s)`);
170
+ }
171
+ await settle(o.page, o.settle);
172
+ await reflow?.measure(o.page);
173
+ const observation = await observe(o.page);
174
+ // Now that we can see the page after the previous action, tell the agent what it did.
175
+ const prev = steps.at(-1);
176
+ if (prev) {
177
+ const effect = describeEffect(prev.action, prev.observation, observation, prev.signals, prev, { screenReader });
178
+ if (effect) history[history.length - 1] += ` → ${effect}`;
179
+ }
180
+ const messages: LlmMessage[] = [{ role: "user", content: buildStepMessage(observation, history, { screenReader }) }];
181
+ let reply: ReturnType<typeof parseAgentReply> | undefined;
182
+ let raw = "";
183
+ let problem = "";
184
+ let noAction: NoActionError | undefined;
185
+ // Real models occasionally send prose, an empty reply or malformed JSON; say what was wrong and ask again.
186
+ for (let attempt = 0; attempt < MAX_REPLY_ATTEMPTS && !reply; attempt++) {
187
+ raw = await o.llm.complete({ system, messages: [...messages], maxTokens: 4096, onUsage: meter.add });
188
+ try {
189
+ reply = parseAgentReply(raw);
190
+ } catch (err) {
191
+ problem = err instanceof Error ? err.message : String(err);
192
+ noAction = err instanceof NoActionError ? err : undefined;
193
+ messages.push(
194
+ { role: "assistant", content: raw || "(empty reply)" },
195
+ {
196
+ role: "user",
197
+ content: `Your last reply was not valid: ${problem.slice(0, 300)}\nReply with only one JSON object: {"reasoning": string, "action": Action}.`,
198
+ },
199
+ );
200
+ }
201
+ }
202
+ // Asked three times, GLM 5.3 Flash went on sending only its reasoning, often "I will wait", and
203
+ // the agent ended on an engine error (2026-10-24). It waits instead, as someone unsure what to
204
+ // do next would; not for ever.
205
+ if (!reply && noAction && noActionWaits < MAX_NO_ACTION_WAITS) {
206
+ noActionWaits++;
207
+ reply = { reasoning: noAction.reasoning, action: { type: "wait" } };
208
+ }
209
+ if (!reply) {
210
+ return end("error", `${problem} | last reply: ${raw.slice(0, 300) || "(empty)"}`);
211
+ }
212
+ // Giving up with a way on in plain sight that it never pressed here, or on a page with nothing
213
+ // to press yet: asked once per page. Still giving up, it gives up.
214
+ if (reply.action.type === "done" && !reply.action.success && !askedBeforeGivingUp.has(observation.url)) {
215
+ const wayOn = untriedWaysOn(observation, steps, screenReader);
216
+ const ask = wayOn.length
217
+ ? `Before you give up: on this page you haven't tried ${wayOn.map((n) => `"${n}"`).join(", ")}. Someone who wanted to get on would try that first.`
218
+ : nothingToPressYet(observation)
219
+ ? "Before you give up: nothing on this page can be pressed yet, so it may still be loading. Someone who wanted to get on would wait a little first."
220
+ : "";
221
+ if (ask) {
222
+ askedBeforeGivingUp.add(observation.url);
223
+ const again = await o.llm.complete({
224
+ system,
225
+ messages: [
226
+ ...messages,
227
+ { role: "assistant", content: raw },
228
+ {
229
+ role: "user",
230
+ content: `${ask} Reply again with one JSON object: go on, or give up if you are sure.`,
231
+ },
232
+ ],
233
+ maxTokens: 4096,
234
+ onUsage: meter.add,
235
+ });
236
+ try {
237
+ reply = parseAgentReply(again);
238
+ } catch {
239
+ // an unreadable second answer leaves the first one standing
240
+ }
241
+ }
242
+ }
243
+
244
+ let result: ActionResult;
245
+ let historyLine = describeAction(reply.action, observation);
246
+ if (reply.action.type === "check_email") {
247
+ if (!o.inbox || !o.identity) {
248
+ result = { error: "this agent has no inbox" };
249
+ } else {
250
+ try {
251
+ const mail = await o.inbox.latest(o.identity.email);
252
+ for (const m of mail) for (const link of m.links) emailedLinks.add(link);
253
+ historyLine = describeInbox(mail);
254
+ result = {};
255
+ } catch (err) {
256
+ result = { error: err instanceof Error ? err.message : String(err) };
257
+ }
258
+ }
259
+ } else {
260
+ result = await execute(o.page, reply.action, observation, o.origin, (href) => emailedLinks.has(href), { keyboard: screenReader });
261
+ }
262
+
263
+ let screenshotPath: string | undefined;
264
+ if (o.artifacts) {
265
+ // JPEG where the store asks for it (the database keeps them small); PNG otherwise. A
266
+ // screenshot that fails (one timed out on a slow page) costs this step
267
+ // its picture, not the agent its session.
268
+ const format = o.artifacts.screenshotFormat;
269
+ try {
270
+ const shot = await o.page.screenshot({ ...(format ?? {}), timeout: SCREENSHOT_MS });
271
+ const name = `${o.persona.id}-${String(index).padStart(3, "0")}`;
272
+ screenshotPath = format
273
+ ? await o.artifacts.put(`${name}.jpg`, shot, "image/jpeg")
274
+ : await o.artifacts.put(`${name}.png`, shot, "image/png");
275
+ } catch (err) {
276
+ console.warn(`[${o.persona.name}] no screenshot for step ${index}: ${err instanceof Error ? err.message.split("\n")[0] : err}`);
277
+ }
278
+ }
279
+
280
+ const guessedUrl = reply.action.type === "goto" && !emailedLinks.has(reply.action.path);
281
+ await signals.settle();
282
+ const step: Step = {
283
+ index,
284
+ observation,
285
+ action: reply.action,
286
+ reasoning: reply.reasoning,
287
+ signals: signals.drain(),
288
+ screenshotPath,
289
+ ...result,
290
+ ...(guessedUrl ? { guessedUrl } : {}),
291
+ };
292
+ steps.push(step);
293
+ await o.onStep?.(step);
294
+ history.push(historyLine);
295
+
296
+ if (reply.action.type === "done") {
297
+ return end(reply.action.success ? "success" : "gave_up", reply.action.note);
298
+ }
299
+ }
300
+
301
+ return end("budget_exhausted", `stopped after ${o.maxSteps} steps`);
302
+ }