debutly 0.1.0 → 0.3.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
@@ -24,7 +24,7 @@ Website and docs: [debutly.dev](https://debutly.dev) ([quick start](https://debu
24
24
 
25
25
  ## Quick start
26
26
 
27
- Needs Node.js 20.12 or later. Until the first npm release, type `npx github:sroay/debutly` wherever this says `npx debutly`. In your app's folder:
27
+ Needs Node.js 20.12 or later. In your app's folder:
28
28
 
29
29
  ```bash
30
30
  npx debutly init # a few questions, then a debutly.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "debutly",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Rehearse your launch: AI agents use your web app like your first customers and report what breaks.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -1,4 +1,5 @@
1
- import type { Action, ActionResult, Observation, Signal } from "../types";
1
+ import type { Action, ActionResult, InteractiveElement, Observation, Signal } from "../types";
2
+ import { controlLabel, type StepView } from "./label";
2
3
 
3
4
  const pathOf = (url: string) => {
4
5
  try {
@@ -9,26 +10,88 @@ const pathOf = (url: string) => {
9
10
  }
10
11
  };
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
+
12
65
  // What a person would notice after acting: where they are now, whether anything changed,
13
66
  // and whether something visibly broke. Without this, agents retry dead buttons forever.
14
- export function describeEffect(action: Action, before: Observation, after: Observation, signals: Signal[], result: ActionResult): string {
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 {
15
69
  if (result.blocked) return `blocked: ${result.blocked}`;
16
70
  if (result.error) return `failed: ${result.error}`;
17
71
  // check_email reports its own result; done ends the session.
18
72
  if (action.type === "done" || action.type === "check_email") return "";
19
73
 
20
74
  const parts: string[] = [];
75
+ if (result.enterIgnored) parts.push("Enter did nothing on it, so Space was pressed instead");
76
+ if (result.invalid?.length) parts.push(`the browser stopped the form and said: ${result.invalid.join("; ")}`);
77
+ const changed = whatChanged(before, after, (e) => controlLabel(e, view.screenReader));
21
78
  if (pathOf(after.url) !== pathOf(before.url)) parts.push(`now on ${pathOf(after.url)}`);
79
+ else if (changed) parts.push(changed);
22
80
  else if (after.text !== before.text) parts.push("the page changed");
23
- else if (action.type !== "fill" && !signals.some((s) => s.kind === "download")) parts.push("nothing visibly changed");
81
+ else if (action.type !== "fill" && !result.invalid?.length && !signals.some((s) => s.kind === "download")) parts.push("nothing visibly changed");
24
82
 
25
83
  for (const s of signals) if (s.kind === "download") parts.push(`a file downloaded: ${s.filename}`);
26
84
 
27
85
  for (const s of signals) if (s.kind === "dialog") parts.push(`a ${s.dialogType} dialog said "${s.message}" (answered OK)`);
28
86
  if (signals.some((s) => s.kind === "page_error")) parts.push("a JavaScript error occurred");
87
+ // Only the server failing: a person never sees a status code, and apps answer 404 or 401 as part
88
+ // of working (a setting never saved yet). Told "a request failed (HTTP 404)" after each click, an
89
+ // agent decided onboarding was broken (2026-10-25). The report still lists those answers.
29
90
  const failed = [
30
91
  ...new Set(
31
- signals.filter((s) => s.kind === "http_error" && pathOf(s.requestUrl) !== "/favicon.ico").map((s) => (s.kind === "http_error" ? s.status : 0)),
92
+ signals
93
+ .filter((s) => s.kind === "http_error" && s.status >= 500 && pathOf(s.requestUrl) !== "/favicon.ico")
94
+ .map((s) => (s.kind === "http_error" ? s.status : 0)),
32
95
  ),
33
96
  ];
34
97
  for (const status of failed) parts.push(`a request failed (HTTP ${status})`);
@@ -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,51 @@
1
+ import type { Action, InteractiveElement, Observation } from "../types";
2
+
3
+ const norm = (s: string) => s.toLowerCase().replace(/\s+/g, " ").trim();
4
+
5
+ /**
6
+ * How a control is named in an agent's words: whole when short, by its first words when its name
7
+ * runs on into a description ("Email alerts Get notified of hiring changes" is "email alerts").
8
+ */
9
+ function mentionOf(name: string): string {
10
+ const n = norm(name);
11
+ if (n.length <= 30) return n;
12
+ const words = n.split(" ");
13
+ const two = words.slice(0, 2).join(" ");
14
+ return two.length >= 8 ? two : words.slice(0, 3).join(" ");
15
+ }
16
+
17
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18
+
19
+ /**
20
+ * Whether the agent's words name the control. A one-word name counts only as written on the
21
+ * control (its capitals), so "go back" in passing isn't the "Back" button.
22
+ */
23
+ function said(reasoning: string, name: string): boolean {
24
+ const m = mentionOf(name);
25
+ if (m.length < 3) return false;
26
+ if (m.includes(" ")) return norm(reasoning).includes(m);
27
+ return new RegExp(`(^|[^\\p{L}\\p{N}])${escape(name.trim())}($|[^\\p{L}\\p{N}])`, "u").test(reasoning);
28
+ }
29
+
30
+ const nameOf = (e: InteractiveElement, screenReader: boolean) => (screenReader && e.ax ? e.ax.name : e.name || e.ax?.name || "");
31
+
32
+ /**
33
+ * When an agent's reasoning names controls on the page but its action points at another one: the
34
+ * controls it named. An agent in a product's onboarding wrote "I will click the button for email
35
+ * alerts" and then "select 'Competitor'", and both times sent the id of the logo, which restarted
36
+ * onboarding; it gave up blaming the app (2026-10-25). Undefined when the action matches what it
37
+ * said, or when there is nothing to compare (no id, or a control without a name).
38
+ */
39
+ export function saidOtherControl(reasoning: string, action: Action, obs: Observation, screenReader = false): string[] | undefined {
40
+ if (action.type !== "click" && action.type !== "fill") return undefined;
41
+ const chosen = obs.elements.find((e) => e.id === action.id);
42
+ if (!chosen) return undefined;
43
+ const chosenName = nameOf(chosen, screenReader);
44
+ if (!chosenName.trim() || said(reasoning, chosenName)) return undefined;
45
+ // A field filled with text it names ("type Agency into Industry") is fine either way.
46
+ if (action.type === "fill" && norm(reasoning).includes(norm(action.text))) return undefined;
47
+ const meant = obs.elements
48
+ .filter((e) => e.id !== chosen.id && nameOf(e, screenReader).trim().length >= 4 && said(reasoning, nameOf(e, screenReader)))
49
+ .map((e) => nameOf(e, screenReader).trim());
50
+ return meant.length ? [...new Set(meant)].slice(0, 3) : undefined;
51
+ }
@@ -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
+ }
package/src/agent/loop.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Page } from "playwright";
1
+ import type { Page, Request } from "playwright";
2
2
  import type { ArtifactStore } from "../artifacts/store";
3
3
  import { execute } from "../browser/execute";
4
4
  import { observe } from "../browser/observe";
@@ -11,15 +11,22 @@ import type { LlmClient, LlmMessage } from "../llm/types";
11
11
  import type { ActionResult, Persona, Session, Step } from "../types";
12
12
  import { describeAction } from "./describe";
13
13
  import { describeEffect } from "./effect";
14
- import { parseAgentReply } from "./parse-reply";
14
+ import { nothingToPressYet, untriedWaysOn } from "./give-up";
15
+ import { saidOtherControl } from "./intent";
16
+ import { controlLabel } from "./label";
17
+ import { NoActionError, parseAgentReply } from "./parse-reply";
15
18
  import { buildStepMessage, buildSystemPrompt, type AgentIdentity } from "./prompt";
16
19
 
17
20
  const MAX_REPLY_ATTEMPTS = 3;
21
+ /** Steps a session may wait for want of an action in the reply, before that is an engine error. */
22
+ const MAX_NO_ACTION_WAITS = 3;
18
23
  const LINKS_PER_EMAIL = 3;
19
24
  /** How long the first page may take to arrive; a slow-connection agent needs more than 30 s. */
20
25
  const FIRST_LOAD_MS = 90_000;
21
26
  /** A first page slower than this is reported. */
22
27
  const SLOW_LOAD_MS = 10_000;
28
+ /** How long a slow first page's scripts and styles may take to be weighed before it is reported without. */
29
+ const WEIGH_MS = 2_000;
23
30
  /** How long a step's screenshot may take before the step goes without one. */
24
31
  const SCREENSHOT_MS = 15_000;
25
32
 
@@ -50,12 +57,35 @@ export interface RunSessionOptions {
50
57
  firstLoad?: { timeoutMs?: number; slowMs?: number };
51
58
  }
52
59
 
53
- function describeInbox(messages: EmailMessage[]): string {
60
+ /** The inbox as the agent reads it, each link numbered ("email link 2") so it can be opened without retyping it. */
61
+ export function describeInbox(messages: EmailMessage[], numberOf: (link: string) => number): string {
54
62
  if (!messages.length) return "checked email → no emails yet";
55
- const items = messages.map((m) => `"${m.subject}"${m.links.length ? ` links: ${m.links.slice(0, LINKS_PER_EMAIL).join(" ")}` : ""}`);
63
+ const items = messages.map((m) => {
64
+ const links = m.links.slice(0, LINKS_PER_EMAIL).map((l) => `[email link ${numberOf(l)}] ${l}`);
65
+ return `"${m.subject}"${links.length ? ` links: ${links.join(" ")}` : ""}`;
66
+ });
56
67
  return `checked email → ${messages.length} email${messages.length === 1 ? "" : "s"}: ${items.join("; ")}`;
57
68
  }
58
69
 
70
+ /**
71
+ * The emailed link a goto means: "email link 2" by its number, or a mistyped copy of one, the same
72
+ * path and query under a garbled host. An agent retyped a sign-in link as http://127.0.0.0.0.1:…,
73
+ * got "Invalid URL" and gave up (2026-10-25). Undefined when it means no emailed link.
74
+ */
75
+ export function emailedLinkMeant(path: string, links: string[]): string | undefined {
76
+ const numbered = /^email ?link ?(\d+)$/i.exec(path.trim());
77
+ if (numbered) return links[Number(numbered[1]) - 1];
78
+ const tail = (u: string) => {
79
+ const start = u.indexOf("//");
80
+ const slash = u.indexOf("/", start < 0 ? 0 : start + 2);
81
+ return slash < 0 ? "" : u.slice(slash);
82
+ };
83
+ const typed = tail(path.trim());
84
+ // Long enough to be a link's own path and token, not a common page like "/login".
85
+ if (typed.length < 20) return undefined;
86
+ return links.find((l) => tail(l) === typed && l !== path.trim());
87
+ }
88
+
59
89
  /**
60
90
  * Open the app the way a person does: wait for the page, but never fail the session because it
61
91
  * is slow. Waiting for every image and font ("load") within Playwright's 30 s ended the
@@ -73,14 +103,37 @@ async function openFirstPage(
73
103
  ): Promise<void> {
74
104
  const startedAt = Date.now();
75
105
  let finished = true;
106
+ // What its scripts and styles weigh as sent, and how much came uncompressed: a test copy served
107
+ // straight from its container may skip the compression its real hosting does (Twenty's sent
108
+ // 10 MB uncompressed, 2026-10-24), and then a slow page says more about the copy than the app.
109
+ const assets = { files: 0, bytes: 0, uncompressedBytes: 0 };
110
+ const weighing = new Set<Promise<void>>();
111
+ const weigh = (request: Request) => {
112
+ if (request.resourceType() !== "script" && request.resourceType() !== "stylesheet") return;
113
+ const done = (async () => {
114
+ const [{ responseBodySize }, encoding] = await Promise.all([request.sizes(), request.response().then((r) => r?.headerValue("content-encoding"))]);
115
+ assets.files++;
116
+ assets.bytes += responseBodySize;
117
+ if (!encoding || encoding === "identity") assets.uncompressedBytes += responseBodySize;
118
+ })()
119
+ .catch(() => {}) // the page moved on before it could be weighed
120
+ .finally(() => weighing.delete(done));
121
+ weighing.add(done);
122
+ };
123
+ page.on("requestfinished", weigh);
76
124
  try {
77
125
  await page.goto(origin, { waitUntil: "domcontentloaded", timeout: timeoutMs });
78
126
  } catch (err) {
79
127
  if (!(err instanceof Error && err.name === "TimeoutError")) throw err;
80
128
  finished = false;
129
+ } finally {
130
+ page.off("requestfinished", weigh);
81
131
  }
82
132
  const ms = Date.now() - startedAt;
83
- if (!finished || ms >= slowMs) signals.note({ kind: "slow_load", url: origin, ms, finished, slowNetwork });
133
+ if (!finished || ms >= slowMs) {
134
+ await Promise.race([Promise.allSettled([...weighing]), new Promise((resolve) => setTimeout(resolve, WEIGH_MS))]);
135
+ signals.note({ kind: "slow_load", url: origin, ms, finished, slowNetwork, ...(assets.files ? { assets: { ...assets } } : {}) });
136
+ }
84
137
  }
85
138
 
86
139
  export async function runSession(o: RunSessionOptions): Promise<Session> {
@@ -129,6 +182,8 @@ interface SessionState {
129
182
  async function takeSteps(o: RunSessionOptions, { signals, system, steps, history, emailedLinks, meter, startedAt, end, reflow }: SessionState): Promise<Session> {
130
183
  // The screen-reader agent hears what the browser tells assistive technology and works by keyboard.
131
184
  const screenReader = o.persona.inclusion === "screen_reader";
185
+ let noActionWaits = 0;
186
+ const askedBeforeGivingUp = new Set<string>();
132
187
  for (let index = 0; index < o.maxSteps; index++) {
133
188
  if (o.shouldStop && (await o.shouldStop())) return end("cancelled", "run cancelled");
134
189
  const { inputTokens, outputTokens } = meter.total;
@@ -144,13 +199,14 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
144
199
  // Now that we can see the page after the previous action, tell the agent what it did.
145
200
  const prev = steps.at(-1);
146
201
  if (prev) {
147
- const effect = describeEffect(prev.action, prev.observation, observation, prev.signals, prev);
202
+ const effect = describeEffect(prev.action, prev.observation, observation, prev.signals, prev, { screenReader });
148
203
  if (effect) history[history.length - 1] += ` → ${effect}`;
149
204
  }
150
205
  const messages: LlmMessage[] = [{ role: "user", content: buildStepMessage(observation, history, { screenReader }) }];
151
206
  let reply: ReturnType<typeof parseAgentReply> | undefined;
152
207
  let raw = "";
153
208
  let problem = "";
209
+ let noAction: NoActionError | undefined;
154
210
  // Real models occasionally send prose, an empty reply or malformed JSON; say what was wrong and ask again.
155
211
  for (let attempt = 0; attempt < MAX_REPLY_ATTEMPTS && !reply; attempt++) {
156
212
  raw = await o.llm.complete({ system, messages: [...messages], maxTokens: 4096, onUsage: meter.add });
@@ -158,6 +214,7 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
158
214
  reply = parseAgentReply(raw);
159
215
  } catch (err) {
160
216
  problem = err instanceof Error ? err.message : String(err);
217
+ noAction = err instanceof NoActionError ? err : undefined;
161
218
  messages.push(
162
219
  { role: "assistant", content: raw || "(empty reply)" },
163
220
  {
@@ -167,9 +224,53 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
167
224
  );
168
225
  }
169
226
  }
227
+ // Asked three times, GLM 5.3 Flash went on sending only its reasoning, often "I will wait", and
228
+ // the agent ended on an engine error (2026-10-24). It waits instead, as someone unsure what to
229
+ // do next would; not for ever.
230
+ if (!reply && noAction && noActionWaits < MAX_NO_ACTION_WAITS) {
231
+ noActionWaits++;
232
+ reply = { reasoning: noAction.reasoning, action: { type: "wait" } };
233
+ }
170
234
  if (!reply) {
171
235
  return end("error", `${problem} | last reply: ${raw.slice(0, 300) || "(empty)"}`);
172
236
  }
237
+ // One more question about the reply just given; an unreadable answer leaves the reply standing.
238
+ const askAgain = async (question: string) => {
239
+ const again = await o.llm.complete({
240
+ system,
241
+ messages: [...messages, { role: "assistant", content: raw }, { role: "user", content: question }],
242
+ maxTokens: 4096,
243
+ onUsage: meter.add,
244
+ });
245
+ try {
246
+ const answer = parseAgentReply(again);
247
+ raw = again;
248
+ return answer;
249
+ } catch {
250
+ return undefined;
251
+ }
252
+ };
253
+ // Giving up with a way on in plain sight that it never pressed here, or on a page with nothing
254
+ // to press yet: asked once per page. Still giving up, it gives up.
255
+ if (reply.action.type === "done" && !reply.action.success && !askedBeforeGivingUp.has(observation.url)) {
256
+ const wayOn = untriedWaysOn(observation, steps, screenReader);
257
+ const ask = wayOn.length
258
+ ? `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.`
259
+ : nothingToPressYet(observation)
260
+ ? "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."
261
+ : "";
262
+ if (ask) {
263
+ askedBeforeGivingUp.add(observation.url);
264
+ reply = (await askAgain(`${ask} Reply again with one JSON object: go on, or give up if you are sure.`)) ?? reply;
265
+ }
266
+ }
267
+ // Its words name one control and its action points at another: asked once to check the id.
268
+ const meant = saidOtherControl(reply.reasoning, reply.action, observation, screenReader);
269
+ const chosen = "id" in reply.action ? observation.elements.find((e) => e.id === (reply!.action as { id: number }).id) : undefined;
270
+ if (meant && chosen) {
271
+ const question = `You wrote about ${meant.map((n) => `"${n}"`).join(" or ")}, but your action points at [${chosen.id}] ${controlLabel(chosen, screenReader)}. Check the id: reply again with one JSON object.`;
272
+ reply = (await askAgain(question)) ?? reply;
273
+ }
173
274
 
174
275
  let result: ActionResult;
175
276
  let historyLine = describeAction(reply.action, observation);
@@ -180,14 +281,22 @@ async function takeSteps(o: RunSessionOptions, { signals, system, steps, history
180
281
  try {
181
282
  const mail = await o.inbox.latest(o.identity.email);
182
283
  for (const m of mail) for (const link of m.links) emailedLinks.add(link);
183
- historyLine = describeInbox(mail);
284
+ const numbered = [...emailedLinks];
285
+ historyLine = describeInbox(mail, (link) => numbered.indexOf(link) + 1);
184
286
  result = {};
185
287
  } catch (err) {
186
288
  result = { error: err instanceof Error ? err.message : String(err) };
187
289
  }
188
290
  }
189
291
  } else {
190
- result = await execute(o.page, reply.action, observation, o.origin, (href) => emailedLinks.has(href), { keyboard: screenReader });
292
+ // An emailed link, by its number or as a mistyped copy, is opened as it was sent.
293
+ const link = reply.action.type === "goto" ? emailedLinkMeant(reply.action.path, [...emailedLinks]) : undefined;
294
+ if (reply.action.type === "goto" && /^email ?link ?\d+$/i.test(reply.action.path.trim()) && !link) {
295
+ result = { error: `there is no ${reply.action.path.trim()}: check your email to see the links` };
296
+ } else {
297
+ const action = link ? { type: "goto" as const, path: link } : reply.action;
298
+ result = await execute(o.page, action, observation, o.origin, (href) => emailedLinks.has(href), { keyboard: screenReader });
299
+ }
191
300
  }
192
301
 
193
302
  let screenshotPath: string | undefined;
@@ -19,7 +19,56 @@ export function parseAgentReply(raw: string): { reasoning: string; action: Actio
19
19
  const start = raw.indexOf("{");
20
20
  const end = raw.lastIndexOf("}");
21
21
  if (start === -1 || end <= start) throw new Error("no JSON object in agent reply");
22
- return ReplySchema.parse(actionAsObject(parseQuotedAction(raw.slice(start, end + 1))));
22
+ const reply = actionAsObject(parseQuotedAction(raw.slice(start, end + 1)));
23
+ const parsed = ReplySchema.safeParse(reply);
24
+ if (!parsed.success) throw new Error(problemWith(reply, parsed.error));
25
+ return parsed.data;
26
+ }
27
+
28
+ /** A reply with reasoning and no action. It carries the reasoning, so the agent can be left to wait. */
29
+ export class NoActionError extends Error {
30
+ constructor(readonly reasoning: string) {
31
+ super(`the reply has no "action". Add one, for example {"reasoning":"…","action":{"type":"wait"}}`);
32
+ }
33
+ }
34
+
35
+ // Said back to the agent when it is asked again, so in words it can act on. Shown the validator's
36
+ // own JSON for a reply with reasoning and no action, one model sent the same reply three times.
37
+ function problemWith(reply: unknown, error: z.ZodError): string {
38
+ if (reply && typeof reply === "object" && !("action" in reply)) {
39
+ const reasoning = (reply as { reasoning?: unknown }).reasoning;
40
+ throw new NoActionError(typeof reasoning === "string" ? reasoning : "");
41
+ }
42
+ return error.issues.map((i) => `${i.path.join(".") || "reply"}: ${i.message}`).join("; ");
43
+ }
44
+
45
+ /**
46
+ * The reply split into objects one after another, merged: {"reasoning":"…"} and on the next line
47
+ * {"action":"press","key":"Enter"} (GLM 5.3 Flash, 2026-10-24, twice in one run). When two whole
48
+ * replies come, the first one counts. Undefined when there is only one object or one doesn't parse.
49
+ */
50
+ function splitReply(text: string): Record<string, unknown> | undefined {
51
+ const objects: string[] = [];
52
+ let depth = 0;
53
+ let from = 0;
54
+ let inString = false;
55
+ for (let i = 0; i < text.length; i++) {
56
+ const c = text[i];
57
+ if (inString) {
58
+ if (c === "\\") i++;
59
+ else if (c === '"') inString = false;
60
+ } else if (c === '"') inString = true;
61
+ else if (c === "{" && depth++ === 0) from = i;
62
+ else if (c === "}" && depth > 0 && --depth === 0) objects.push(text.slice(from, i + 1));
63
+ }
64
+ if (objects.length < 2) return undefined;
65
+ try {
66
+ const parsed = objects.map((o) => JSON.parse(o) as unknown);
67
+ if (!parsed.every((p) => p && typeof p === "object" && !Array.isArray(p))) return undefined;
68
+ return Object.assign({}, ...(parsed as Record<string, unknown>[]).reverse());
69
+ } catch {
70
+ return undefined;
71
+ }
23
72
  }
24
73
 
25
74
  // Some models (GLM 5.3 Flash) send the action as a string. Unescaped,
@@ -31,6 +80,8 @@ function parseQuotedAction(text: string): unknown {
31
80
  try {
32
81
  return JSON.parse(text);
33
82
  } catch (err) {
83
+ const split = splitReply(text);
84
+ if (split) return split;
34
85
  const unquoted = text.replace(/("action"\s*:\s*)"(\{[^{}]*\})"/, "$1$2");
35
86
  if (unquoted !== text) {
36
87
  try {
@@ -1,6 +1,9 @@
1
1
  import { inclusionBrief } from "../persona/inclusion";
2
2
  import { languageBrief } from "../persona/language";
3
3
  import type { InteractiveElement, Observation, Persona } from "../types";
4
+ import { controlLabel, type StepView } from "./label";
5
+
6
+ export type { StepView } from "./label";
4
7
 
5
8
  // Stays constant for the whole session so providers can cache it; everything that changes goes in the user message.
6
9
  export interface AgentIdentity {
@@ -11,7 +14,7 @@ export interface AgentIdentity {
11
14
  export function buildSystemPrompt(persona: Persona, goal: string, identity?: AgentIdentity, brief?: string): string {
12
15
  const email = identity
13
16
  ? `Your email address is ${identity.email}. Use it whenever the site asks for your email.
14
- To read your inbox (for example a login link or a confirmation), reply {"type":"check_email"}. To open a link from an email, use {"type":"goto","path":"<the full link>"}.`
17
+ To read your inbox (for example a login link or a confirmation), reply {"type":"check_email"}. To open a link from an email, use its number: {"type":"goto","path":"email link 1"}.`
15
18
  : `Use emails at example.com.`;
16
19
  const about = [
17
20
  `You are ${persona.name}. ${persona.background}`,
@@ -38,31 +41,40 @@ Action is one of:
38
41
  {"type":"fill","id":number,"text":string} (on a select or dropdown, text is the option to choose)
39
42
  {"type":"upload","id":number,"file":"photo"|"document"} (attach a file through an upload button or file field: you have a sample photo and a sample CV/document)
40
43
  {"type":"goto","path":string}
41
- {"type":"press","key":"Escape"|"Enter"|"Tab"|"ArrowDown"|"ArrowUp"} (e.g. Escape closes a popup that blocks the page)
44
+ {"type":"press","key":"Escape"|"Enter"|"Space"|"Tab"|"ArrowDown"|"ArrowUp"} (e.g. Escape closes a popup that blocks the page; Space presses the focused button or ticks a box)
42
45
  {"type":"wait"} (the page is still loading, e.g. a spinner or a form that has not appeared yet: wait a few seconds rather than leave)${identity ? `\n{"type":"check_email"}` : ""}
43
46
  {"type":"done","success":boolean,"note":string}`;
44
47
  }
45
48
 
46
- function describeElement(e: InteractiveElement, screenReader = false): string {
47
- // A screen reader announces the role and name the browser gives it.
48
- const heard = screenReader && e.ax ? e.ax : e;
49
- const line = `[${e.id}] ${heard.role} "${heard.name}"`;
50
- if (!e.options) return line;
51
- return `${line} set to "${e.value ?? ""}", options: ${e.options.map((o) => `"${o}"`).join(" | ")}`;
49
+ /** A control's state, as a person sees it (and a screen reader announces it). */
50
+ function stateOf(e: InteractiveElement, behindOpenLayer = false): string {
51
+ const states = [
52
+ // Hidden from assistive technology while something is open: what a modal does to the page behind it.
53
+ behindOpenLayer && e.ax?.hidden ? "behind what is open" : "",
54
+ e.disabled ? "disabled" : "",
55
+ e.readOnly ? "read-only" : "",
56
+ e.checked === undefined ? "" : e.checked ? "checked" : "not checked",
57
+ e.expanded === undefined ? "" : e.expanded ? "open" : "closed",
58
+ e.filled ? "filled in" : "",
59
+ !e.options && e.value ? `holds "${e.value}"` : "",
60
+ ].filter(Boolean);
61
+ return states.length ? ` (${states.join(", ")})` : "";
52
62
  }
53
63
 
54
- export interface StepView {
55
- /** As a screen reader presents the page: the browser's names and roles, without what is hidden from it. */
56
- screenReader?: boolean;
64
+ function describeElement(e: InteractiveElement, screenReader = false, behindOpenLayer = false): string {
65
+ const line = `[${e.id}] ${controlLabel(e, screenReader)}${stateOf(e, behindOpenLayer)}`;
66
+ if (!e.options) return line;
67
+ return `${line} set to "${e.value ?? ""}", options: ${e.options.map((o) => `"${o}"`).join(" | ")}`;
57
68
  }
58
69
 
59
70
  export function buildStepMessage(obs: Observation, history: string[], view: StepView = {}): string {
60
71
  const shown = view.screenReader ? obs.elements.filter((e) => !e.ax?.hidden) : obs.elements;
61
- const elements = shown.map((e) => describeElement(e, view.screenReader)).join("\n");
72
+ const elements = shown.map((e) => describeElement(e, view.screenReader, !!obs.openLayer)).join("\n");
62
73
  const past = history.length ? history.map((h, i) => `${i + 1}. ${h}`).join("\n") : "(none yet)";
74
+ const open = obs.openLayer ? `\nOpen now: ${obs.openLayer}. It takes clicks outside it: finish it, choose from it or close it (Escape, or its close button) before using what is behind it.\n` : "";
63
75
  return `URL: ${obs.url}
64
76
  Title: ${obs.title}
65
-
77
+ ${open}
66
78
  Interactive elements:
67
79
  ${elements}
68
80
 
package/src/browser/ax.ts CHANGED
@@ -67,6 +67,7 @@ export async function accessibleNames(page: Page): Promise<Map<number, Accessibl
67
67
  role: node.role?.value ?? "",
68
68
  hidden: node.ignored,
69
69
  ...(description ? { description } : {}),
70
+ ...(node.ignoredReasons?.some((r) => r.name === "ariaHiddenElement" || r.name === "ariaHiddenSubtree") ? { ariaHidden: true as const } : {}),
70
71
  });
71
72
  }
72
73
  return out;