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.
- package/LICENSE +21 -0
- package/README.md +625 -0
- package/bin/sitelooper.js +6 -0
- package/dist/agent/llm.js +460 -0
- package/dist/agent/llm.js.map +1 -0
- package/dist/agent/loop.js +870 -0
- package/dist/agent/loop.js.map +1 -0
- package/dist/agent/prompt.js +40 -0
- package/dist/agent/prompt.js.map +1 -0
- package/dist/agent/report.js +545 -0
- package/dist/agent/report.js.map +1 -0
- package/dist/agent/tools.js +1147 -0
- package/dist/agent/tools.js.map +1 -0
- package/dist/cli.js +1692 -0
- package/dist/cli.js.map +1 -0
- package/dist/daemon/browser.js +218 -0
- package/dist/daemon/browser.js.map +1 -0
- package/dist/daemon/codegen.js +241 -0
- package/dist/daemon/codegen.js.map +1 -0
- package/dist/daemon/dialogs.js +57 -0
- package/dist/daemon/dialogs.js.map +1 -0
- package/dist/daemon/diff.js +198 -0
- package/dist/daemon/diff.js.map +1 -0
- package/dist/daemon/fingerprint.js +98 -0
- package/dist/daemon/fingerprint.js.map +1 -0
- package/dist/daemon/inputs.js +134 -0
- package/dist/daemon/inputs.js.map +1 -0
- package/dist/daemon/recorder.js +1232 -0
- package/dist/daemon/recorder.js.map +1 -0
- package/dist/daemon/refs.js +194 -0
- package/dist/daemon/refs.js.map +1 -0
- package/dist/daemon/server.js +1724 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/state.js +239 -0
- package/dist/daemon/state.js.map +1 -0
- package/dist/doctor.js +90 -0
- package/dist/doctor.js.map +1 -0
- package/dist/shared/paths.js +80 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/protocol.js +28 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/secrets.js +92 -0
- package/dist/shared/secrets.js.map +1 -0
- package/dist/shared/text.js +39 -0
- package/dist/shared/text.js.map +1 -0
- package/dist/skills/compile.js +1420 -0
- package/dist/skills/compile.js.map +1 -0
- package/dist/skills/components.js +456 -0
- package/dist/skills/components.js.map +1 -0
- package/dist/skills/flow.js +1041 -0
- package/dist/skills/flow.js.map +1 -0
- package/dist/skills/learn.js +406 -0
- package/dist/skills/learn.js.map +1 -0
- package/dist/skills/ledger.js +304 -0
- package/dist/skills/ledger.js.map +1 -0
- package/dist/skills/relabel.js +206 -0
- package/dist/skills/relabel.js.map +1 -0
- package/dist/skills/repair.js +570 -0
- package/dist/skills/repair.js.map +1 -0
- package/dist/skills/replay.js +1281 -0
- package/dist/skills/replay.js.map +1 -0
- package/dist/skills/store.js +147 -0
- package/dist/skills/store.js.map +1 -0
- package/dist/spec/check.js +428 -0
- package/dist/spec/check.js.map +1 -0
- package/dist/spec/diagnostics.js +58 -0
- package/dist/spec/diagnostics.js.map +1 -0
- package/dist/spec/emit.js +2084 -0
- package/dist/spec/emit.js.map +1 -0
- package/dist/spec/index.js +62 -0
- package/dist/spec/index.js.map +1 -0
- package/dist/spec/ir.js +216 -0
- package/dist/spec/ir.js.map +1 -0
- package/dist/spec/lift.js +162 -0
- package/dist/spec/lift.js.map +1 -0
- package/dist/spec/locators.js +270 -0
- package/dist/spec/locators.js.map +1 -0
- package/dist/spec/lower.js +124 -0
- package/dist/spec/lower.js.map +1 -0
- package/dist/spec/repair.js +657 -0
- package/dist/spec/repair.js.map +1 -0
- package/dist/spec/rerecord.js +169 -0
- package/dist/spec/rerecord.js.map +1 -0
- package/dist/spec/rethread.js +120 -0
- package/dist/spec/rethread.js.map +1 -0
- package/package.json +50 -0
- 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
|