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 +1 -1
- package/package.json +1 -1
- package/src/agent/effect.ts +67 -4
- package/src/agent/give-up.ts +42 -0
- package/src/agent/intent.ts +51 -0
- package/src/agent/label.ts +19 -0
- package/src/agent/loop.ts +117 -8
- package/src/agent/parse-reply.ts +52 -1
- package/src/agent/prompt.ts +25 -13
- package/src/browser/ax.ts +1 -0
- package/src/browser/execute.ts +138 -7
- package/src/browser/observe.ts +39 -2
- package/src/browser/scan-script.ts +46 -0
- package/src/cli.ts +25 -9
- package/src/detect/findings.ts +96 -10
- package/src/init.ts +6 -5
- package/src/persona/people.ts +32 -0
- package/src/report/fix-first.ts +125 -0
- package/src/report/markdown.ts +3 -1
- package/src/run.ts +4 -2
- package/src/types.ts +25 -4
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.
|
|
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
package/src/agent/effect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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) =>
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/agent/parse-reply.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/agent/prompt.ts
CHANGED
|
@@ -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":"
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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;
|