sitelooper 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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +625 -0
  3. package/bin/sitelooper.js +6 -0
  4. package/dist/agent/llm.js +460 -0
  5. package/dist/agent/llm.js.map +1 -0
  6. package/dist/agent/loop.js +870 -0
  7. package/dist/agent/loop.js.map +1 -0
  8. package/dist/agent/prompt.js +40 -0
  9. package/dist/agent/prompt.js.map +1 -0
  10. package/dist/agent/report.js +545 -0
  11. package/dist/agent/report.js.map +1 -0
  12. package/dist/agent/tools.js +1147 -0
  13. package/dist/agent/tools.js.map +1 -0
  14. package/dist/cli.js +1692 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/daemon/browser.js +218 -0
  17. package/dist/daemon/browser.js.map +1 -0
  18. package/dist/daemon/codegen.js +241 -0
  19. package/dist/daemon/codegen.js.map +1 -0
  20. package/dist/daemon/dialogs.js +57 -0
  21. package/dist/daemon/dialogs.js.map +1 -0
  22. package/dist/daemon/diff.js +198 -0
  23. package/dist/daemon/diff.js.map +1 -0
  24. package/dist/daemon/fingerprint.js +98 -0
  25. package/dist/daemon/fingerprint.js.map +1 -0
  26. package/dist/daemon/inputs.js +134 -0
  27. package/dist/daemon/inputs.js.map +1 -0
  28. package/dist/daemon/recorder.js +1232 -0
  29. package/dist/daemon/recorder.js.map +1 -0
  30. package/dist/daemon/refs.js +194 -0
  31. package/dist/daemon/refs.js.map +1 -0
  32. package/dist/daemon/server.js +1724 -0
  33. package/dist/daemon/server.js.map +1 -0
  34. package/dist/daemon/state.js +239 -0
  35. package/dist/daemon/state.js.map +1 -0
  36. package/dist/doctor.js +90 -0
  37. package/dist/doctor.js.map +1 -0
  38. package/dist/shared/paths.js +80 -0
  39. package/dist/shared/paths.js.map +1 -0
  40. package/dist/shared/protocol.js +28 -0
  41. package/dist/shared/protocol.js.map +1 -0
  42. package/dist/shared/secrets.js +92 -0
  43. package/dist/shared/secrets.js.map +1 -0
  44. package/dist/shared/text.js +39 -0
  45. package/dist/shared/text.js.map +1 -0
  46. package/dist/skills/compile.js +1420 -0
  47. package/dist/skills/compile.js.map +1 -0
  48. package/dist/skills/components.js +456 -0
  49. package/dist/skills/components.js.map +1 -0
  50. package/dist/skills/flow.js +1041 -0
  51. package/dist/skills/flow.js.map +1 -0
  52. package/dist/skills/learn.js +406 -0
  53. package/dist/skills/learn.js.map +1 -0
  54. package/dist/skills/ledger.js +304 -0
  55. package/dist/skills/ledger.js.map +1 -0
  56. package/dist/skills/relabel.js +206 -0
  57. package/dist/skills/relabel.js.map +1 -0
  58. package/dist/skills/repair.js +570 -0
  59. package/dist/skills/repair.js.map +1 -0
  60. package/dist/skills/replay.js +1281 -0
  61. package/dist/skills/replay.js.map +1 -0
  62. package/dist/skills/store.js +147 -0
  63. package/dist/skills/store.js.map +1 -0
  64. package/dist/spec/check.js +428 -0
  65. package/dist/spec/check.js.map +1 -0
  66. package/dist/spec/diagnostics.js +58 -0
  67. package/dist/spec/diagnostics.js.map +1 -0
  68. package/dist/spec/emit.js +2084 -0
  69. package/dist/spec/emit.js.map +1 -0
  70. package/dist/spec/index.js +62 -0
  71. package/dist/spec/index.js.map +1 -0
  72. package/dist/spec/ir.js +216 -0
  73. package/dist/spec/ir.js.map +1 -0
  74. package/dist/spec/lift.js +162 -0
  75. package/dist/spec/lift.js.map +1 -0
  76. package/dist/spec/locators.js +270 -0
  77. package/dist/spec/locators.js.map +1 -0
  78. package/dist/spec/lower.js +124 -0
  79. package/dist/spec/lower.js.map +1 -0
  80. package/dist/spec/repair.js +657 -0
  81. package/dist/spec/repair.js.map +1 -0
  82. package/dist/spec/rerecord.js +169 -0
  83. package/dist/spec/rerecord.js.map +1 -0
  84. package/dist/spec/rethread.js +120 -0
  85. package/dist/spec/rethread.js.map +1 -0
  86. package/package.json +50 -0
  87. package/skills/sitelooper/SKILL.md +228 -0
@@ -0,0 +1,870 @@
1
+ import { captureSignature } from '../daemon/diff.js';
2
+ import { fingerprintPage } from '../daemon/fingerprint.js';
3
+ import { candidatesFor, renderCandidates } from '../skills/replay.js';
4
+ import { componentsOnPage, renderComponents } from '../skills/components.js';
5
+ import { originOf } from '../skills/store.js';
6
+ import { buildSystemPrompt } from './prompt.js';
7
+ import { addEvidenceValue, admitsIncompletion, backfillReadValues, flattenComposedValues, mergeReportValues, namingAskMessage, promoteLabelledReads, proseIdentifiers, unnamedReadValues, validateReport } from './report.js';
8
+ import { executeTool, toolDefsFor } from './tools.js';
9
+ import { captureReadBack, captureReadBackAt, setIdentityHints } from '../daemon/recorder.js';
10
+ /** Tools that change the page URL, staleing every existing snapshot's refs. */
11
+ const NAVIGATION_TOOLS = new Set(['goto', 'back', 'tabs']);
12
+ /** Per-turn LLM watchdog: a turn that hasn't produced a tool call by now is aborted. */
13
+ export const DEFAULT_TURN_TIMEOUT_MS = 90_000;
14
+ /**
15
+ * Consecutive turns allowed to end without a tool call (watchdog abort or
16
+ * prose-only reply) before the loop gives up. Bailing here turns a silent
17
+ * multi-minute stall into a fast, explicit failure.
18
+ */
19
+ const MAX_UNPRODUCTIVE_TURNS = 3;
20
+ /** Gestures that count towards cycle detection; observations never do. */
21
+ const CYCLE_TOOLS = new Set(['click', 'dblclick', 'modifier_click', 'right_click', 'fill', 'type', 'press', 'select', 'check', 'goto', 'back']);
22
+ /** Repetitions of a cycle before it counts as looping. */
23
+ const CYCLE_REPEATS = 3;
24
+ /** Longest cycle looked for (Exit edit ↔ Discard is 2; open-menu, pick, confirm is 3). */
25
+ const CYCLE_MAX_PERIOD = 3;
26
+ /**
27
+ * The period of a short cycle that has just repeated CYCLE_REPEATS times at
28
+ * the tail of `acts`, or 0. Entries carry the tool, its args AND the tool
29
+ * result (state diff included), so a paginating "Next" click whose diff
30
+ * differs each time is not a cycle — only identical responses to identical
31
+ * gestures are.
32
+ */
33
+ export function loopingCycle(acts) {
34
+ for (let p = 1; p <= CYCLE_MAX_PERIOD; p++) {
35
+ const need = p * CYCLE_REPEATS;
36
+ if (acts.length < need)
37
+ return 0;
38
+ const tail = acts.slice(-need);
39
+ let same = true;
40
+ for (let i = p; i < need && same; i++)
41
+ if (tail[i] !== tail[i - p])
42
+ same = false;
43
+ if (same)
44
+ return p;
45
+ }
46
+ return 0;
47
+ }
48
+ /**
49
+ * Extra turn/time budget the escalation attempt gets when the routine model
50
+ * bailed by exhausting its own. Deliberately modest: the point is to clear a
51
+ * wall the first attempt proved is there, not to let one instruction run away.
52
+ */
53
+ const ESCALATION_BUDGET_MULTIPLIER = 1.5;
54
+ /** Cap on the evidence values carried into the durable one-line report entry. */
55
+ const REPORT_FACTS_CHARS = 600;
56
+ /** The first hold not yet asked that has something to ask about `report`. */
57
+ function firstHold(holds, asked, report) {
58
+ for (const hold of holds) {
59
+ if (asked.has(hold.name))
60
+ continue;
61
+ const hit = hold.check(report);
62
+ if (hit)
63
+ return { name: hold.name, ...hit };
64
+ }
65
+ return null;
66
+ }
67
+ /**
68
+ * The agentic loop: send instruction + history, execute the model's tool
69
+ * calls in-process, feed results back, until a valid `report` (or turn/time
70
+ * caps hit → blocked with a transcript tail).
71
+ */
72
+ export async function runInstruction(provider, browser, state, instruction, opts) {
73
+ const deadline = Date.now() + opts.timeoutMs;
74
+ const usage = { promptTokens: 0, completionTokens: 0, cachedTokens: 0 };
75
+ const transcript = [];
76
+ const actions = [];
77
+ const screenshots = [];
78
+ let reportRetried = false;
79
+ /** evidence.values from the report held for naming, so the retry cannot lose them. */
80
+ let heldValues;
81
+ /**
82
+ * Reasons to hand a schema-valid report back to the model ONCE before
83
+ * accepting it, asked in this order. Each fires at most once per
84
+ * instruction and never on the last turn: a held report that then hits the
85
+ * cap is reported as blocked, and losing a completed instruction costs far
86
+ * more than anything a hold can win. The retry is accepted whatever it
87
+ * says — so is the first report, if the model simply repeats it.
88
+ */
89
+ const holds = [
90
+ {
91
+ // A success whose own summary says the work was not finished: the
92
+ // status and the prose must agree before a flow marks the step done on
93
+ // the strength of the status alone.
94
+ name: 'contradiction',
95
+ check: (report) => {
96
+ const admission = report.status === 'success' ? admitsIncompletion(report.summary) : null;
97
+ if (!admission)
98
+ return null;
99
+ return {
100
+ message: `report held — status is "success" but the summary says "${admission}". Those cannot both be true. If the instruction was FULLY completed and verified, call report again with a summary that does not describe unfinished work. If it was not, call report again with status "failure" or "blocked" and say exactly what is missing. Do not take further actions first.`,
101
+ transcript: `report held for contradiction: success but "${admission}"`,
102
+ progress: `holding success report that admits "${admission}"`,
103
+ };
104
+ },
105
+ },
106
+ {
107
+ // Values the report describes but never named. Ask with the values
108
+ // quoted back so the model supplies labels rather than re-reading the
109
+ // page — see unnamedReadValues for what an unnamed value costs every
110
+ // later replay.
111
+ name: 'naming',
112
+ check: (report) => {
113
+ const unnamed = unnamedReadValues(report, browser.script?.readsThisInstruction() ?? []);
114
+ if (!unnamed.length)
115
+ return null;
116
+ heldValues = report.evidence?.values;
117
+ browser.script?.noteNamingAsk?.(unnamed);
118
+ return {
119
+ message: namingAskMessage(unnamed),
120
+ transcript: `report held for naming: ${unnamed.join(', ')}`,
121
+ progress: `asking for names for ${unnamed.length} unnamed read value(s): ${unnamed.join(', ')}`,
122
+ };
123
+ },
124
+ },
125
+ ];
126
+ const holdsAsked = new Set();
127
+ let capWarned = false;
128
+ let unproductiveTurns = 0;
129
+ // Recent state-changing calls with their outcomes, for cycle detection —
130
+ // see loopingCycle. Cleared when the nudge is issued so the second strike
131
+ // needs a fresh full cycle.
132
+ const recentActs = [];
133
+ let loopNudged = false;
134
+ // Previous instructions' raw tool output describes a page that has usually
135
+ // moved on; their durable conclusion survives as the `[report]` line below.
136
+ // Blanking it here keeps per-turn context flat across a long session instead
137
+ // of letting it grow until trimHistory's size cap forces the same thing.
138
+ const elided = state.elidePriorToolResults();
139
+ if (elided.elided) {
140
+ opts.onProgress?.(`[history] elided ${elided.elided} tool result(s) from earlier instructions (~${Math.round(elided.charsSaved / 4000)}k tokens/turn saved)`);
141
+ }
142
+ state.trimHistory();
143
+ const location = await describeLocation(browser);
144
+ // Learning mode: offer the stored procedures that start on this page. They
145
+ // go in the user message, not the system prompt, so the cached prefix stays
146
+ // byte-identical across instructions.
147
+ const offered = await offerSkills(browser);
148
+ const skill = {
149
+ listed: offered.ids,
150
+ stepsReplayed: 0,
151
+ stepsTotal: 0,
152
+ repaired: false,
153
+ refused: false,
154
+ fallthroughs: 0,
155
+ similarity: null,
156
+ deterministicActions: 0,
157
+ totalActions: 0,
158
+ };
159
+ state.messages.push({
160
+ role: 'user',
161
+ content: [instruction, location, offered.text].filter(Boolean).join('\n\n'),
162
+ });
163
+ // Script recording (opt-in) groups this instruction's actions under one
164
+ // test.step, so a generated spec reads as the plan that produced it.
165
+ // Identity hints for this instruction's locators: the caller's declared
166
+ // variables (the runid every record of this run is named after). Typed
167
+ // values join them as the instruction runs — see ScriptRecorder.prepare.
168
+ setIdentityHints(Object.values(state.vars ?? {}));
169
+ browser.script?.beginInstruction(opts.recordAs?.text ?? instruction, opts.recordAs ? { ...offered.context, resume: true } : offered.context);
170
+ const system = { role: 'system', content: buildSystemPrompt(state) };
171
+ const toolDefs = toolDefsFor(browser);
172
+ /** Resume advice differs sharply depending on whether anything actually ran. */
173
+ const resumeHint = () => actions.length
174
+ ? 'Work may be partially complete — check the actions log and verify current state before resuming.'
175
+ : 'No tool call ran, so the browser was not touched — nothing to undo.';
176
+ /** Only for the failure modes where the agent never got going by itself. */
177
+ const narrowHint = ' Re-run with one concrete artifact per instruction.';
178
+ const finish = async (report, turns, blockedTail = false, bailReason) => {
179
+ // Deterministic evidence backfill: a read value the model cited in prose
180
+ // but left out of evidence.values would drop the read at compile time and
181
+ // leave the step with no skill. Runs before the facts line and before
182
+ // read-back synthesis so both see the promoted values.
183
+ if (report.status === 'success' && browser.script) {
184
+ // A value the model composed out of several page values (a JSON blob per
185
+ // order line) is unpinnable and unrepublishable as one string. Split it
186
+ // first, so everything below — backfill, read-back synthesis, compile —
187
+ // sees the scalars a real element actually shows.
188
+ const split = flattenComposedValues(report);
189
+ if (split.length)
190
+ opts.onProgress?.(`[report] split composed value(s) into ${split.join(', ')}`);
191
+ // Read-time labels first: the model named these values in the read call
192
+ // itself, and its name must win over the selector slug backfill derives.
193
+ const labelled = promoteLabelledReads(report, browser.script.readsThisInstruction());
194
+ if (labelled.length)
195
+ opts.onProgress?.(`[report] published ${labelled.length} read-time labelled value(s): ${labelled.join(', ')}`);
196
+ const promoted = backfillReadValues(report, browser.script.readsThisInstruction());
197
+ if (promoted.length)
198
+ opts.onProgress?.(`[report] promoted ${promoted.length} prose-cited read value(s) into evidence: ${promoted.join(', ')}`);
199
+ // Second source, for the identifiers no read observed at all: a record
200
+ // reference the model only ever put in prose (a confirmed order's
201
+ // S00021). Pin it on the live page as a real read, so it becomes a
202
+ // published output a later step can reference and a replay re-reads its
203
+ // OWN — fwod5 cancelled the recorded run's order for want of this.
204
+ if (browser.isOpen) {
205
+ try {
206
+ const page = await browser.getPage();
207
+ const pinned = [];
208
+ for (const value of proseIdentifiers(report)) {
209
+ const step = await captureReadBack(page, value);
210
+ if (!step)
211
+ continue; // not uniquely on the page — it stays prose
212
+ browser.script.addStep(step);
213
+ // Named `ref`, not after the target. The target of a synthesized
214
+ // read-back is the literal "(read-back)" or, on the model-sourced
215
+ // path, a CSS selector — odoo published a value called
216
+ // `o_subtotal_o_total_name_`, slugged from
217
+ // `.o_subtotal, .o_total, [name="amount_untaxed"]`. A later step
218
+ // can only reference a name a human or a model would write.
219
+ pinned.push(addEvidenceValue(report, 'ref', value));
220
+ }
221
+ if (pinned.length)
222
+ opts.onProgress?.(`[report] pinned ${pinned.length} prose-cited identifier(s) to the page: ${pinned.join(', ')}`);
223
+ }
224
+ catch {
225
+ // a wedged/navigating page must never turn a good report into no report
226
+ }
227
+ }
228
+ }
229
+ // This line is what survives once the instruction's tool results are
230
+ // elided at the next boundary, so the facts the caller asked for ride
231
+ // along with the prose — otherwise a value read in step 3 would be gone
232
+ // by step 4 despite having been correctly obtained and reported.
233
+ const values = report.evidence?.values;
234
+ const facts = values && Object.keys(values).length
235
+ ? ' | ' +
236
+ Object.entries(values)
237
+ .map(([k, v]) => `${k}=${v}`)
238
+ .join(', ')
239
+ .slice(0, REPORT_FACTS_CHARS)
240
+ : '';
241
+ state.messages.push({
242
+ role: 'assistant',
243
+ content: `[report] ${report.status}: ${report.summary}${facts}`,
244
+ });
245
+ // Close the recording's instruction group with its outcome, so a flow can
246
+ // be built from what each step achieved (values, the skill it used).
247
+ if (browser.script) {
248
+ const values = {};
249
+ for (const [k, v] of Object.entries(report.evidence?.values ?? {}))
250
+ values[k] = String(v);
251
+ // Read-back synthesis: for each value the agent reported, capture a
252
+ // durable read of the live element showing it, so a replay re-reads the
253
+ // value rather than dropping it as stale. Record-time only, best-effort,
254
+ // never blocks the report. Skip values already backed by a real read.
255
+ if (report.status === 'success' && Object.keys(values).length) {
256
+ try {
257
+ const page = await browser.getPage();
258
+ const alreadyRead = browser.script.readResultsThisInstruction();
259
+ const stragglers = [];
260
+ const seenValue = new Set();
261
+ // Keyed, not just valued. The evidence KEY is the output name a later
262
+ // flow step references, and compile used to recover it by matching
263
+ // the read's result against every reported value for an exact hit —
264
+ // so a read differing by a currency symbol was stored unlabelled,
265
+ // published nothing, and stranded every reference to it.
266
+ for (const [name, value] of Object.entries(values)) {
267
+ if (!value || alreadyRead.has(value) || seenValue.has(value))
268
+ continue;
269
+ seenValue.add(value);
270
+ const step = await captureReadBack(page, value, name);
271
+ if (step)
272
+ browser.script.addStep(step);
273
+ else
274
+ stragglers.push(value); // not pinnable by text — try the model next
275
+ }
276
+ // Verified model fallback: for values the deterministic search could
277
+ // not pin (typically because they are not unique on the page), ask
278
+ // the model — which knows where it read them — for a selector, then
279
+ // trust it only after it resolves to exactly that value. One extra
280
+ // turn, and only when a straggler exists.
281
+ // Never past the instruction deadline: this is one more model
282
+ // call, and the caller believes the budget bounds the whole thing.
283
+ if (stragglers.length && Date.now() < deadline) {
284
+ const sourced = await sourceStragglers(provider, page, system, state, stragglers, opts, usage);
285
+ for (const step of sourced)
286
+ browser.script.addStep(step);
287
+ }
288
+ }
289
+ catch {
290
+ // a wedged/navigating page must never turn a good report into no report
291
+ }
292
+ }
293
+ browser.script.endInstruction({
294
+ status: report.status,
295
+ summary: report.summary,
296
+ values,
297
+ ...(skill.invoked ? { skill: skill.invoked } : {}),
298
+ ...(skill.tier ? { tier: skill.tier } : {}),
299
+ });
300
+ }
301
+ state.recordUsage(provider.model, usage);
302
+ // Any blocked outcome carries its evidence, not just loop-enforced bail-outs:
303
+ // an agent that declares itself stuck is exactly when a caller — or the
304
+ // escalation model — needs to know what already ran. Clean successes stay lean.
305
+ const includeTail = blockedTail || report.status === 'blocked';
306
+ const finalState = includeTail ? await captureFinalState(browser) : undefined;
307
+ if (skill.invoked && !skill.refused && skill.stepsReplayed < skill.stepsTotal && report.status === 'success') {
308
+ skill.repaired = true;
309
+ }
310
+ return {
311
+ report,
312
+ turns,
313
+ usage,
314
+ screenshots,
315
+ ...(includeTail ? { transcriptTail: transcript.slice(-12), actions: actions.slice(-40) } : {}),
316
+ ...(finalState ? { finalState } : {}),
317
+ ...(bailReason ? { bailReason } : {}),
318
+ ...(browser.learn ? { skill } : {}),
319
+ };
320
+ };
321
+ const timedOut = (turns) => finish({
322
+ status: 'blocked',
323
+ summary: `Instruction timed out after ${Math.round(opts.timeoutMs / 1000)}s (${turns} turns, ${actions.length} tool call(s)). ${resumeHint()}${actions.length ? '' : narrowHint}`,
324
+ }, turns, true, 'timeout');
325
+ const looping = (turns, period) => finish({
326
+ status: 'blocked',
327
+ summary: `Agent looped: a cycle of ${period} action(s) repeated ${CYCLE_REPEATS}× with identical page responses, twice, despite being told to change approach — the state was not advancing. ${resumeHint()} Check the page for a dialog or unsaved-changes prompt that needs a different response.`,
328
+ }, turns, true, 'looping');
329
+ const stalled = (turns) => finish({
330
+ status: 'blocked',
331
+ summary: `Agent stalled: ${MAX_UNPRODUCTIVE_TURNS} consecutive turns produced no tool call — it reasoned without driving the browser. ${resumeHint()}${narrowHint} Raise --turn-timeout if the model legitimately needs longer per step.`,
332
+ }, turns, true, 'stalled');
333
+ /**
334
+ * Run one tool call, bounded by the instruction deadline (and by `stop`).
335
+ * Playwright actions have their own internal timeouts, but some can exceed
336
+ * the remaining budget or wedge entirely (a drag against a blocked renderer),
337
+ * which would otherwise let a single call run past `--timeout` unchecked.
338
+ * On expiry we abandon the call — the cooperative signal stops wait_for's
339
+ * polling, anything still in flight inside Playwright is left to settle
340
+ * unobserved — and hand back an error result so the loop bails out with the
341
+ * same blocked report a timeout produces.
342
+ */
343
+ const runTool = async (name, args) => {
344
+ // A stop that already landed must not let the rest of a multi-call turn
345
+ // run: the listener below never fires on an already-aborted signal.
346
+ if (opts.signal?.aborted)
347
+ return { result: `ERROR: ${name} was not run — the instruction was stopped.`, isError: true };
348
+ const abort = new AbortController();
349
+ const abortTool = () => abort.abort();
350
+ const timer = setTimeout(abortTool, Math.max(0, deadline - Date.now()));
351
+ opts.signal?.addEventListener('abort', abortTool, { once: true });
352
+ try {
353
+ return await Promise.race([
354
+ executeTool(browser, name, args, opts.screenshotDir, abort.signal),
355
+ new Promise((resolve) => {
356
+ abort.signal.addEventListener('abort', () => resolve({
357
+ result: `ERROR: ${name} was abandoned — it did not finish within the remaining instruction budget. The page may be mid-action; verify state before repeating it.`,
358
+ isError: true,
359
+ }), { once: true });
360
+ }),
361
+ ]);
362
+ }
363
+ finally {
364
+ clearTimeout(timer);
365
+ opts.signal?.removeEventListener('abort', abortTool);
366
+ }
367
+ };
368
+ for (let turn = 1; turn <= opts.maxTurns; turn++) {
369
+ if (opts.signal?.aborted) {
370
+ return finish({
371
+ status: 'blocked',
372
+ summary: `Instruction was stopped after ${turn - 1} turns and ${actions.length} tool call(s). ${resumeHint()}`,
373
+ }, turn - 1, true, 'stopped');
374
+ }
375
+ if (Date.now() > deadline)
376
+ return timedOut(turn - 1);
377
+ // Near the cap, tell the agent to stop acting and report now — otherwise a
378
+ // completed-but-unreported instruction is misreported as blocked/failed.
379
+ if (!capWarned && turn > opts.maxTurns - 2) {
380
+ capWarned = true;
381
+ state.messages.push({
382
+ role: 'user',
383
+ content: `Only ${opts.maxTurns - turn + 1} turn(s) left before the cap. Call report NOW with your best current assessment of what was done and verified — do not start new actions. Flag anything you could not confirm.`,
384
+ });
385
+ }
386
+ // Watchdog the LLM call itself: a model that reasons without emitting a tool
387
+ // call would otherwise spend the entire instruction budget inside one
388
+ // request, returning zero actions. Never wait past the instruction deadline.
389
+ // Floor keeps a nearly-expired deadline from producing a zero-length budget;
390
+ // the abort then falls through to the deadline check and reports a timeout.
391
+ const turnBudgetMs = Math.max(250, Math.min(opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS, deadline - Date.now()));
392
+ const watchdog = new AbortController();
393
+ const abortTurn = () => watchdog.abort();
394
+ const timer = setTimeout(abortTurn, turnBudgetMs);
395
+ opts.signal?.addEventListener('abort', abortTurn, { once: true });
396
+ let completion;
397
+ try {
398
+ completion = await provider.complete([system, ...state.messages], toolDefs, {
399
+ signal: watchdog.signal,
400
+ });
401
+ }
402
+ catch (err) {
403
+ if (!watchdog.signal.aborted)
404
+ throw err;
405
+ // Aborted: the assistant message never arrived, so history stays consistent.
406
+ if (opts.signal?.aborted)
407
+ continue; // stop requested — reported at the top of the next pass
408
+ if (Date.now() > deadline)
409
+ return timedOut(turn - 1);
410
+ unproductiveTurns++;
411
+ const secs = Math.round(turnBudgetMs / 1000);
412
+ transcript.push(`turn ${turn}: aborted after ${secs}s — still reasoning, no tool call issued`);
413
+ opts.onProgress?.(`[turn ${turn}/${opts.maxTurns}] watchdog: no tool call within ${secs}s, retrying`);
414
+ if (unproductiveTurns >= MAX_UNPRODUCTIVE_TURNS)
415
+ return stalled(turn);
416
+ state.messages.push({
417
+ role: 'user',
418
+ content: `Your previous turn was aborted after ${secs}s because it produced no tool call. Stop planning and issue exactly ONE tool call now — the smallest observation that moves the instruction forward (snapshot, read, or eval). You will get another turn after you see its result.`,
419
+ });
420
+ continue;
421
+ }
422
+ finally {
423
+ clearTimeout(timer);
424
+ opts.signal?.removeEventListener('abort', abortTurn);
425
+ }
426
+ usage.promptTokens += completion.usage.promptTokens;
427
+ usage.completionTokens += completion.usage.completionTokens;
428
+ usage.cachedTokens += completion.usage.cachedTokens;
429
+ state.messages.push(completion.assistantMessage);
430
+ if (completion.text)
431
+ transcript.push(`assistant: ${completion.text.slice(0, 300)}`);
432
+ if (completion.toolCalls.length === 0) {
433
+ // Model replied with prose only — remind it of the contract once per occurrence.
434
+ unproductiveTurns++;
435
+ if (unproductiveTurns >= MAX_UNPRODUCTIVE_TURNS)
436
+ return stalled(turn);
437
+ state.messages.push({
438
+ role: 'user',
439
+ content: 'Reminder: act via tool calls only, and finish by calling the report tool. Continue with the instruction.',
440
+ });
441
+ continue;
442
+ }
443
+ unproductiveTurns = 0;
444
+ // Every tool call on an assistant message must get a tool result, or the
445
+ // history is malformed for every later request in the session (both
446
+ // OpenAI-compatible and Anthropic hosts reject it with a 400). A turn that
447
+ // ends early — deadline, stop, an accepted report mid-turn — answers the
448
+ // calls it did not run with a stub. A nudge to the model is likewise held
449
+ // until the turn's results are all in, never pushed between them.
450
+ const calls = completion.toolCalls;
451
+ const stubFrom = (index, why) => {
452
+ for (const c of calls.slice(index))
453
+ state.messages.push({ role: 'tool', tool_call_id: c.id, content: `not executed — ${why}` });
454
+ };
455
+ let nudge = null;
456
+ for (const [ci, call] of calls.entries()) {
457
+ if (Date.now() > deadline || opts.signal?.aborted) {
458
+ stubFrom(ci, 'the instruction ended first');
459
+ break;
460
+ }
461
+ if (call.name === 'report') {
462
+ const validation = call.args ? validateReport(call.args) : { ok: false, error: 'arguments were not valid JSON' };
463
+ if (validation.ok) {
464
+ // The report is schema-valid and will be accepted unless one of the
465
+ // holds has something to ask first — see `holds`.
466
+ const roomToHold = turn < opts.maxTurns && Date.now() < deadline;
467
+ const hold = roomToHold ? firstHold(holds, holdsAsked, validation.report) : null;
468
+ if (hold) {
469
+ holdsAsked.add(hold.name);
470
+ state.messages.push({ role: 'tool', tool_call_id: call.id, content: hold.message });
471
+ transcript.push(hold.transcript);
472
+ opts.onProgress?.(`[turn ${turn}] ${hold.progress}`);
473
+ continue;
474
+ }
475
+ if (holdsAsked.has('naming')) {
476
+ if (Object.keys(validation.report.evidence?.values ?? {}).length)
477
+ browser.script?.noteNamingAnswered?.();
478
+ // The retry asked for the report "unchanged except…"; models drop
479
+ // things anyway — sometimes the whole evidence block. Keep every
480
+ // value either report named — see mergeReportValues for the
481
+ // trace that made this necessary.
482
+ if (heldValues) {
483
+ (validation.report.evidence ??= {}).values = mergeReportValues(heldValues, validation.report.evidence.values);
484
+ }
485
+ }
486
+ // A repaired payload is accepted, not silently rewritten: the caller
487
+ // and the transcript both see what was changed on the agent's behalf.
488
+ if (validation.coerced?.length) {
489
+ transcript.push(`report coerced: ${validation.coerced.join('; ')}`);
490
+ opts.onProgress?.(`[turn ${turn}] report accepted after repair: ${validation.coerced.join('; ')}`);
491
+ }
492
+ state.messages.push({ role: 'tool', tool_call_id: call.id, content: 'report accepted' });
493
+ stubFrom(ci + 1, 'the report closed the instruction');
494
+ return finish(validation.report, turn);
495
+ }
496
+ state.messages.push({
497
+ role: 'tool',
498
+ tool_call_id: call.id,
499
+ content: `report rejected — ${validation.error}. Call report again with a valid payload (status: success|failure|blocked, summary: string).`,
500
+ });
501
+ transcript.push(`report rejected: ${validation.error}`);
502
+ if (reportRetried) {
503
+ stubFrom(ci + 1, 'the instruction ended first');
504
+ return finish({
505
+ status: 'blocked',
506
+ summary: `Agent could not produce a schema-valid report (last error: ${validation.error}).`,
507
+ }, turn, true, 'invalid-report');
508
+ }
509
+ reportRetried = true;
510
+ continue;
511
+ }
512
+ if (!call.args) {
513
+ state.messages.push({
514
+ role: 'tool',
515
+ tool_call_id: call.id,
516
+ content: `ERROR: arguments for ${call.name} were not valid JSON. Re-issue the call.`,
517
+ });
518
+ transcript.push(`${call.name}: malformed arguments`);
519
+ continue;
520
+ }
521
+ const summary = summarizeArgs(call.args);
522
+ opts.onProgress?.(`[turn ${turn}/${opts.maxTurns}] ${call.name} ${summary}`);
523
+ const execution = await runTool(call.name, call.args);
524
+ actions.push({ tool: call.name, args: summary, ok: !execution.isError });
525
+ state.messages.push({ role: 'tool', tool_call_id: call.id, content: execution.result });
526
+ accountActions(skill, call.name, call.args, execution);
527
+ // The same gesture cycle producing the same page response, over and
528
+ // over, means the agent is not advancing — the r2 halt on grafana was
529
+ // 75 turns of Exit edit ↔ Discard dialog. One nudge to break out, then
530
+ // a blocked report that says so, instead of burning to the cap.
531
+ if (CYCLE_TOOLS.has(call.name)) {
532
+ recentActs.push(`${call.name} ${summary} → ${execution.result.slice(0, 600)}`);
533
+ const period = loopingCycle(recentActs);
534
+ if (period) {
535
+ recentActs.length = 0;
536
+ if (loopNudged) {
537
+ stubFrom(ci + 1, 'the instruction ended first');
538
+ return looping(turn, period);
539
+ }
540
+ loopNudged = true;
541
+ transcript.push(`turn ${turn}: looping — the last ${period} action(s) repeated ${CYCLE_REPEATS}× with identical results`);
542
+ opts.onProgress?.(`[turn ${turn}/${opts.maxTurns}] loop detected (period ${period}); nudging`);
543
+ nudge = `You are looping: your last ${period} action(s) have run ${CYCLE_REPEATS} times in a row and the page responded identically each time, so repeating them will not change anything. Stop. Take a snapshot, work out why the state is not advancing (a dialog that needs a different button, an unsaved change to discard or keep, a control that is not the one you think), and take a DIFFERENT route. If there is no other route, call report with status blocked and say exactly what is stuck.`;
544
+ }
545
+ }
546
+ if (call.name === 'screenshot' && !execution.isError) {
547
+ // Multiline: a native-dialog note may follow the path on its own line.
548
+ const m = execution.result.match(/^screenshot saved: (.+)$/m);
549
+ if (m)
550
+ screenshots.push(m[1]);
551
+ }
552
+ // Keep the re-sent context lean: a snapshot's @refs go stale on navigation
553
+ // and when a newer snapshot arrives, so stub superseded snapshots now
554
+ // rather than re-sending them (up to ~2k tokens each) every remaining turn.
555
+ if (!execution.isError) {
556
+ if (call.name === 'snapshot')
557
+ state.elideSnapshots(call.id);
558
+ else if (NAVIGATION_TOOLS.has(call.name) && !(call.name === 'tabs' && call.args.switch_to === undefined)) {
559
+ state.elideSnapshots();
560
+ }
561
+ }
562
+ transcript.push(`${call.name} ${summary} → ${execution.isError ? execution.result.slice(0, 200) : 'ok'}`);
563
+ }
564
+ if (nudge)
565
+ state.messages.push({ role: 'user', content: nudge });
566
+ }
567
+ return finish({
568
+ status: 'blocked',
569
+ summary: `Turn cap (${opts.maxTurns}) reached without a final report. ${resumeHint()} Do not blindly repeat state-changing actions like submit/delete/move.`,
570
+ }, opts.maxTurns, true, 'turn-cap');
571
+ }
572
+ /**
573
+ * Run an instruction on the routine model and, if it comes back blocked, retry
574
+ * it once on a stronger fallback model.
575
+ *
576
+ * Why blocked only: `failure` is a verified negative answer (the assertion was
577
+ * checked and did not hold) — retrying it on a better model just buys the same
578
+ * answer twice. `blocked` means the agent could not determine the answer, which
579
+ * is precisely the failure mode a stronger model can rescue, and the one this
580
+ * project measured on a real app (a cheap model abandoned a supplier-autocomplete
581
+ * step after 29 turns that a stronger model then solved).
582
+ *
583
+ * The retry shares the SAME live browser and message history, so the fallback
584
+ * inherits everything the first attempt discovered — and, critically, is told it
585
+ * is resuming, so it verifies state before repeating anything destructive.
586
+ */
587
+ export async function runEscalatingInstruction(primary, fallback, browser, state, instruction, opts) {
588
+ // Where this instruction's history starts, so the failed attempt can be
589
+ // compacted on handoff without touching earlier instructions (which are
590
+ // already cached and were not the thing that went wrong).
591
+ const historyMark = state.messages.length;
592
+ const first = await runInstruction(primary, browser, state, instruction, opts);
593
+ const blocked = first.report.status === 'blocked';
594
+ // An operator `stop` also yields a blocked report — escalating there would
595
+ // restart work the operator just killed, which is the opposite of the ask.
596
+ const operatorStopped = Boolean(opts.signal?.aborted);
597
+ if (!fallback || !blocked || operatorStopped || fallback.model === primary.model)
598
+ return first;
599
+ // A first attempt that exhausted its turn/time budget is positive evidence
600
+ // that the instruction needs MORE of that budget — handing the fallback the
601
+ // same allowance mostly buys a second bail-out at the same wall. Measured on
602
+ // a real app: both tiers hit a 30-turn cap on one step that the routine model
603
+ // then completed in 19 turns once the cap was raised.
604
+ const headroom = (n) => Math.ceil(n * ESCALATION_BUDGET_MULTIPLIER);
605
+ const escalatedOpts = {
606
+ ...opts,
607
+ // The recording must carry the caller's wording, not the resume scaffold —
608
+ // see LoopOptions.recordAs. Flow building then merges the continuation
609
+ // into the failed first attempt's group.
610
+ recordAs: { text: opts.recordAs?.text ?? instruction, resume: true },
611
+ ...(first.bailReason === 'turn-cap' ? { maxTurns: headroom(opts.maxTurns) } : {}),
612
+ ...(first.bailReason === 'timeout' ? { timeoutMs: headroom(opts.timeoutMs) } : {}),
613
+ };
614
+ // Hand the stronger model a clean brief, not a transcript of the failure.
615
+ // escalationPrompt() below already carries what mattered — the blocked
616
+ // report, the ordered actions log, where the browser was left — so leaving
617
+ // the raw tool results in place would re-send that same information every
618
+ // turn at the escalation tier's (much higher) cache rate.
619
+ const compacted = state.compactToolResults(historyMark);
620
+ opts.onProgress?.(`[escalating] ${primary.model} reported blocked (${first.bailReason ?? 'agent gave up'}) — ` +
621
+ `retrying on ${fallback.model}${escalatedOpts.maxTurns !== opts.maxTurns ? ` with ${escalatedOpts.maxTurns} turns` : ''}` +
622
+ `${compacted.elided ? `, compacted ${compacted.elided} tool result(s) (~${Math.round(compacted.charsSaved / 4000)}k tokens/turn saved)` : ''}`);
623
+ const second = await runInstruction(fallback, browser, state, escalationPrompt(instruction, first), escalatedOpts);
624
+ return {
625
+ ...second,
626
+ turns: first.turns + second.turns,
627
+ usage: {
628
+ promptTokens: first.usage.promptTokens + second.usage.promptTokens,
629
+ completionTokens: first.usage.completionTokens + second.usage.completionTokens,
630
+ cachedTokens: first.usage.cachedTokens + second.usage.cachedTokens,
631
+ },
632
+ screenshots: [...first.screenshots, ...second.screenshots],
633
+ escalation: {
634
+ from: primary.model,
635
+ to: fallback.model,
636
+ reason: first.report.summary,
637
+ firstAttempt: { status: first.report.status, turns: first.turns, usage: first.usage },
638
+ rescued: second.report.status === 'success',
639
+ compactedToolResults: compacted.elided,
640
+ },
641
+ };
642
+ }
643
+ function escalationPrompt(instruction, first) {
644
+ const ran = first.actions?.length
645
+ ? `\nActions the previous attempt ran (most recent last):\n${first.actions
646
+ .map((a) => ` ${a.ok ? 'ok' : 'FAILED'} ${a.tool} ${a.args}`)
647
+ .join('\n')}`
648
+ : '';
649
+ const where = first.finalState ? `\nThe browser was left at: ${first.finalState.url}` : '';
650
+ return (`You are RESUMING an instruction that a previous, weaker model could not complete. ` +
651
+ `It gave up with this report:\n"${first.report.summary}"${ran}${where}\n\n` +
652
+ `The browser session is that same attempt — nothing has been reset — but the previous attempt's ` +
653
+ `raw tool output has been elided from the conversation above to keep it small, so treat the ` +
654
+ `summary and action list here as the record of it and re-observe the page for anything you need. ` +
655
+ `Before you repeat ANY state-changing action (submit, create, delete, move), observe the current ` +
656
+ `page and confirm whether it already took effect; the previous attempt may have partially ` +
657
+ `succeeded. Do not assume its conclusions were correct — it may have been stuck because it ` +
658
+ `misread the page or probed the wrong values, so re-examine the evidence yourself and look for an ` +
659
+ `approach it did not try.\n\nThe original instruction to complete is:\n${instruction}`);
660
+ }
661
+ /**
662
+ * Verified model fallback for read-back synthesis: ask the model where each
663
+ * un-pinnable reported value lives on the current page, then trust the answer
664
+ * only after it resolves to exactly that value. Ephemeral — does not touch the
665
+ * running history — and bounded to one completion.
666
+ */
667
+ async function sourceStragglers(provider, page, system, state, values, opts, usage) {
668
+ const ask = {
669
+ role: 'user',
670
+ content: `Before this instruction is filed, point to where these value(s) you just reported are shown on the CURRENT page, so they can be re-read on a later run. ` +
671
+ `Call locate with, for each value, a CSS selector (or @ref from your latest snapshot) that resolves to EXACTLY the one element displaying it — or an empty selector if the value is computed and not shown verbatim on the page. Values:\n` +
672
+ values.map((v) => `- ${JSON.stringify(v)}`).join('\n'),
673
+ };
674
+ let completion;
675
+ try {
676
+ // One structured answer from a page the model has already seen: low
677
+ // reasoning effort, or a reasoning model spends a 16k budget on it.
678
+ completion = await provider.complete([system, ...state.messages, ask], [LOCATE_TOOL], { signal: opts.signal, effort: 'low' });
679
+ }
680
+ catch {
681
+ return [];
682
+ }
683
+ usage.promptTokens += completion.usage.promptTokens;
684
+ usage.completionTokens += completion.usage.completionTokens;
685
+ usage.cachedTokens += completion.usage.cachedTokens;
686
+ const call = completion.toolCalls.find((c) => c.name === 'locate');
687
+ const sources = call?.args && Array.isArray(call.args.sources) ? (call.args.sources) : [];
688
+ const out = [];
689
+ for (const entry of sources) {
690
+ const e = entry;
691
+ if (typeof e.value !== 'string' || typeof e.selector !== 'string' || !e.selector.trim())
692
+ continue;
693
+ const step = await captureReadBackAt(page, e.value, e.selector).catch(() => null);
694
+ if (step)
695
+ out.push(step);
696
+ }
697
+ if (out.length)
698
+ opts.onProgress?.(`[read-back] model sourced ${out.length}/${values.length} un-pinnable value(s)`);
699
+ return out;
700
+ }
701
+ const LOCATE_TOOL = {
702
+ name: 'locate',
703
+ description: 'Point to where each listed value is shown on the current page, so it can be re-read later.',
704
+ parameters: {
705
+ type: 'object',
706
+ required: ['sources'],
707
+ properties: {
708
+ sources: {
709
+ type: 'array',
710
+ items: {
711
+ type: 'object',
712
+ required: ['value', 'selector'],
713
+ properties: {
714
+ value: { type: 'string', description: 'The reported value, exactly as given.' },
715
+ selector: { type: 'string', description: 'CSS selector or @ref resolving to exactly the element that displays this value; "" if it is computed / not shown.' },
716
+ },
717
+ },
718
+ },
719
+ },
720
+ },
721
+ };
722
+ /** What the stored-skill listing contributes to an instruction's first message. */
723
+ /** Cap on the recorded instruction-start page text (identity evidence, not a snapshot). */
724
+ const START_TEXT_BUDGET = 8000;
725
+ async function offerSkills(browser) {
726
+ const none = { ids: [], text: '', context: {} };
727
+ if (!browser.learn || !browser.isOpen)
728
+ return none;
729
+ try {
730
+ const page = await browser.getPage();
731
+ const url = page.url();
732
+ const origin = originOf(url);
733
+ if (!origin)
734
+ return none;
735
+ const candidates = candidatesFor(browser.learn.list(origin), url);
736
+ const fingerprint = (await fingerprintPage(page)) ?? undefined;
737
+ // Recognized hard widgets on this page: one line telling the model that
738
+ // plain fill/type/select on them is recipe-backed and self-verifying, so
739
+ // it does not improvise long keyboard workarounds.
740
+ const components = renderComponents(await componentsOnPage(page));
741
+ const text = [renderCandidates(candidates), components].filter(Boolean).join('\n');
742
+ // Which RECORD this page showed when the instruction started, capped: the
743
+ // evidence compile needs to give a skill an identity precondition (see
744
+ // RecordedInstruction.startText).
745
+ const sig = await captureSignature(page);
746
+ const startText = sig ? sig.lines.join('\n').slice(0, START_TEXT_BUDGET) : undefined;
747
+ return {
748
+ ids: candidates.map((s) => s.id),
749
+ text,
750
+ context: { url, ...(fingerprint ? { fingerprint } : {}), ...(startText ? { startText } : {}) },
751
+ };
752
+ }
753
+ catch {
754
+ return none;
755
+ }
756
+ }
757
+ /** Count browser actions per tool call, and fold a replay's outcome into the record. */
758
+ function accountActions(skill, name, args, execution) {
759
+ if (name === 'snapshot' || name === 'report')
760
+ return;
761
+ if (name === 'run_skill') {
762
+ const r = execution.replay;
763
+ if (!r)
764
+ return;
765
+ // First replay wins the record; a second run_skill in one instruction is
766
+ // unusual and its steps still count as deterministic actions.
767
+ if (!skill.invoked) {
768
+ skill.tier = 'B';
769
+ skill.invoked = r.skill;
770
+ skill.stepsReplayed = r.stepsRun;
771
+ skill.stepsTotal = r.stepsTotal;
772
+ skill.refused = Boolean(r.refused);
773
+ skill.fallthroughs = r.fallthroughs;
774
+ skill.similarity = r.similarity;
775
+ if (r.misses.length)
776
+ skill.misses = r.misses;
777
+ if (r.reason)
778
+ skill.failReason = r.reason;
779
+ if (r.failedAt !== undefined)
780
+ skill.failedAt = r.failedAt;
781
+ skill.replayUrl = r.url;
782
+ }
783
+ skill.deterministicActions += r.stepsRun;
784
+ skill.totalActions += r.stepsRun;
785
+ return;
786
+ }
787
+ if (name === 'batch' && Array.isArray(args.steps)) {
788
+ // Only the steps that ran count; the result lists one line per step that did.
789
+ const ran = (execution.result.match(/^\d+\. /gm) ?? []).length;
790
+ if (execution.isError && !ran)
791
+ return; // rejected at validation — nothing ran
792
+ skill.totalActions += ran || args.steps.length;
793
+ return;
794
+ }
795
+ skill.totalActions += 1;
796
+ }
797
+ function summarizeArgs(args) {
798
+ // A batch's raw args are a wall of nested JSON; the step tools are what the
799
+ // progress line and the actions log actually need to convey.
800
+ if (Array.isArray(args.steps)) {
801
+ const tools = args.steps.map((s) => String(s?.tool ?? '?'));
802
+ return `[${tools.length} steps: ${tools.join(', ')}]`;
803
+ }
804
+ const s = JSON.stringify(args);
805
+ return s.length > 120 ? s.slice(0, 120) + '…' : s;
806
+ }
807
+ /**
808
+ * Best-effort "where did this leave the browser" for bail-out results. Bounded
809
+ * and never throws: this runs on the failure path, where a wedged page must not
810
+ * turn a blocked report into no report at all.
811
+ */
812
+ async function captureFinalState(browser) {
813
+ try {
814
+ if (!browser.isOpen)
815
+ return undefined; // never launch a browser just to report on one
816
+ const page = await browser.getPage();
817
+ const url = page.url();
818
+ const title = await Promise.race([
819
+ page.title().catch(() => undefined),
820
+ new Promise((r) => setTimeout(() => r(undefined), 2_000)),
821
+ ]);
822
+ return title ? { url, title } : { url };
823
+ }
824
+ catch {
825
+ return undefined;
826
+ }
827
+ }
828
+ /**
829
+ * Where the browser is right now, as a line appended to every instruction.
830
+ *
831
+ * Without it the model starts blind and, being told to act rather than
832
+ * deliberate, may guess. Bench run c0822bp (2026-08-22) began its first
833
+ * instruction with `goto http://localhost:3000` although the caller had just
834
+ * opened the app on another port: it landed on a browser error page,
835
+ * port-scanned from inside it, reported the app unreachable, and — because
836
+ * history persists across instructions — repeated that verdict 118 times.
837
+ * Naming the page costs ~30 tokens per instruction and removes the guess.
838
+ *
839
+ * Never launches a browser: a session with no page yet gets no line, and the
840
+ * rules then only allow a URL the instruction itself provides. Never throws —
841
+ * a wedged page must not stop an instruction from starting.
842
+ */
843
+ async function describeLocation(browser) {
844
+ if (!browser.isOpen)
845
+ return null;
846
+ try {
847
+ const page = await browser.getPage();
848
+ const url = page.url();
849
+ const title = await Promise.race([
850
+ page.title().catch(() => ''),
851
+ new Promise((r) => setTimeout(() => r(''), 2_000)),
852
+ ]);
853
+ let line = `[browser] You are currently on ${url}${title ? ` — "${title}"` : ''}.`;
854
+ if (url.startsWith('chrome-error://')) {
855
+ line +=
856
+ ' That is a browser error page: the last navigation failed, so the app is NOT at that address. Use back to return to the page the caller set up, or goto a URL the instruction gives — do not guess one.';
857
+ }
858
+ else if (url === 'about:blank') {
859
+ line += ' Nothing has been loaded yet: only navigate to a URL the instruction or briefing gives.';
860
+ }
861
+ else {
862
+ line += ' Start from this page; the caller put the browser here on purpose.';
863
+ }
864
+ return line;
865
+ }
866
+ catch {
867
+ return null;
868
+ }
869
+ }
870
+ //# sourceMappingURL=loop.js.map