argus-reviewer-e2e 0.1.2
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 +80 -0
- package/action/action.yml +147 -0
- package/action/sticky-comment.mjs +376 -0
- package/dist/api.d.ts +121 -0
- package/dist/api.js +256 -0
- package/dist/cache/fingerprint.d.ts +59 -0
- package/dist/cache/fingerprint.js +67 -0
- package/dist/cache/store.d.ts +17 -0
- package/dist/cache/store.js +41 -0
- package/dist/cli.d.ts +25 -0
- package/dist/cli.js +1355 -0
- package/dist/config.d.ts +130 -0
- package/dist/config.js +163 -0
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +30 -0
- package/dist/detect.d.ts +50 -0
- package/dist/detect.js +105 -0
- package/dist/driver/browser.d.ts +64 -0
- package/dist/driver/browser.js +200 -0
- package/dist/driver/target.d.ts +23 -0
- package/dist/driver/target.js +97 -0
- package/dist/engine/actions.d.ts +26 -0
- package/dist/engine/actions.js +47 -0
- package/dist/engine/loop.d.ts +118 -0
- package/dist/engine/loop.js +649 -0
- package/dist/engine/prompts.d.ts +22 -0
- package/dist/engine/prompts.js +112 -0
- package/dist/evidence/ci.d.ts +18 -0
- package/dist/evidence/ci.js +61 -0
- package/dist/evidence/link.d.ts +35 -0
- package/dist/evidence/link.js +90 -0
- package/dist/executor/a0.d.ts +29 -0
- package/dist/executor/a0.js +38 -0
- package/dist/fsutil.d.ts +5 -0
- package/dist/fsutil.js +12 -0
- package/dist/index/context.d.ts +17 -0
- package/dist/index/context.js +88 -0
- package/dist/index/diff.d.ts +1 -0
- package/dist/index/diff.js +33 -0
- package/dist/index/invalidate.d.ts +28 -0
- package/dist/index/invalidate.js +56 -0
- package/dist/index/scan.d.ts +26 -0
- package/dist/index/scan.js +209 -0
- package/dist/journal/build.d.ts +15 -0
- package/dist/journal/build.js +47 -0
- package/dist/journal/schema.d.ts +59 -0
- package/dist/journal/schema.js +6 -0
- package/dist/journal/store.d.ts +10 -0
- package/dist/journal/store.js +26 -0
- package/dist/live.d.ts +2 -0
- package/dist/live.js +46 -0
- package/dist/log.d.ts +17 -0
- package/dist/log.js +24 -0
- package/dist/report/comment.d.ts +13 -0
- package/dist/report/comment.js +135 -0
- package/dist/report/junit.d.ts +10 -0
- package/dist/report/junit.js +46 -0
- package/dist/report/run.d.ts +51 -0
- package/dist/report/run.js +36 -0
- package/dist/vision/cost.d.ts +36 -0
- package/dist/vision/cost.js +16 -0
- package/dist/vision/ledger.d.ts +29 -0
- package/dist/vision/ledger.js +65 -0
- package/dist/vision/openrouter.d.ts +70 -0
- package/dist/vision/openrouter.js +134 -0
- package/package.json +65 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import { DEFAULT_RECORD_STEP_CAP } from '../config.js';
|
|
2
|
+
import { computeRegionHash, Fingerprint, fnv1a, } from '../cache/fingerprint.js';
|
|
3
|
+
import { saveFlow } from '../cache/store.js';
|
|
4
|
+
import { actionSchema, assertionSchema, buildActionMessages, buildAssertMessages, } from './prompts.js';
|
|
5
|
+
export class Engine {
|
|
6
|
+
_opts;
|
|
7
|
+
_visionCalls = 0;
|
|
8
|
+
_steps = [];
|
|
9
|
+
_fingerprints = [];
|
|
10
|
+
_assertCache = new Map();
|
|
11
|
+
_errors = [];
|
|
12
|
+
/** Structured, non-fatal anomalies — journaled as evidence, never thrown. */
|
|
13
|
+
get errorRecords() {
|
|
14
|
+
return this._errors;
|
|
15
|
+
}
|
|
16
|
+
_note(stage, message, context) {
|
|
17
|
+
const rec = { stage, message, ...(context !== undefined ? { context } : {}) };
|
|
18
|
+
this._errors.push(rec);
|
|
19
|
+
this._opts.logger?.debug(`${stage}: ${message}${context ? ` (${context})` : ''}`);
|
|
20
|
+
}
|
|
21
|
+
constructor(_opts) {
|
|
22
|
+
this._opts = _opts;
|
|
23
|
+
for (const entry of _opts.initialAsserts ?? []) {
|
|
24
|
+
this._assertCache.set(`${entry.question}${entry.a11yHash}`, entry);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Assertion verdicts collected/known this run — persist into the flow cache. */
|
|
28
|
+
get assertEntries() {
|
|
29
|
+
return [...this._assertCache.values()];
|
|
30
|
+
}
|
|
31
|
+
get visionCalls() {
|
|
32
|
+
return this._visionCalls;
|
|
33
|
+
}
|
|
34
|
+
async record(instruction, tdApi = this._opts.actions, options = {}) {
|
|
35
|
+
this._visionCalls = 0;
|
|
36
|
+
this._steps = [];
|
|
37
|
+
this._fingerprints = [];
|
|
38
|
+
const cap = options.stepCap ?? this._opts.config.recordStepCap ?? DEFAULT_RECORD_STEP_CAP;
|
|
39
|
+
let observation = await this._opts.driver.observe({ grid: true });
|
|
40
|
+
for (let i = 0; i < cap; i++) {
|
|
41
|
+
const response = await this._callModel('ground', buildActionMessages(instruction, observation, this._fingerprints.map((f) => ({ action: f.action, label: f.a11ySnippet }))));
|
|
42
|
+
if (!response) {
|
|
43
|
+
return this._result(false, 'budget exceeded or model call blocked');
|
|
44
|
+
}
|
|
45
|
+
const action = this._parseAction(response.content);
|
|
46
|
+
if (action.action === 'done') {
|
|
47
|
+
this._steps.push({
|
|
48
|
+
instruction,
|
|
49
|
+
action: 'done',
|
|
50
|
+
ok: true,
|
|
51
|
+
reason: action.reasoning,
|
|
52
|
+
model: response.model,
|
|
53
|
+
});
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
if (action.action === 'fail') {
|
|
57
|
+
this._steps.push({
|
|
58
|
+
instruction,
|
|
59
|
+
action: 'fail',
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: action.reasoning,
|
|
62
|
+
model: response.model,
|
|
63
|
+
});
|
|
64
|
+
return this._result(false, action.reasoning);
|
|
65
|
+
}
|
|
66
|
+
const resolved = await this._resolveAction(action);
|
|
67
|
+
const nextObservation = await this._executeAction(tdApi, action);
|
|
68
|
+
const fingerprint = await this._buildFingerprint(instruction, action, resolved, response.model);
|
|
69
|
+
this._fingerprints.push(fingerprint);
|
|
70
|
+
this._steps.push({ instruction, action: action.action, ok: true, model: response.model });
|
|
71
|
+
observation = nextObservation;
|
|
72
|
+
}
|
|
73
|
+
const finished = this._steps[this._steps.length - 1]?.action === 'done';
|
|
74
|
+
if (!finished) {
|
|
75
|
+
return this._result(false, `record did not finish after ${cap} steps — raise the cap with --max-steps or config.recordStepCap`);
|
|
76
|
+
}
|
|
77
|
+
if (options.flowName && this._opts.config.cacheDir) {
|
|
78
|
+
await saveFlow(this._opts.config.cacheDir, options.flowName, this._fingerprints);
|
|
79
|
+
}
|
|
80
|
+
return this._result(true);
|
|
81
|
+
}
|
|
82
|
+
async replay(flow, options = {}) {
|
|
83
|
+
this._visionCalls = 0;
|
|
84
|
+
this._steps = [];
|
|
85
|
+
for (let i = 0; i < flow.steps.length; i++) {
|
|
86
|
+
const step = flow.steps[i];
|
|
87
|
+
if (!step)
|
|
88
|
+
continue;
|
|
89
|
+
let observation = await this._opts.driver.observe({ grid: true });
|
|
90
|
+
// Diff-invalidated entries skip hash verification entirely and go
|
|
91
|
+
// straight to the heal path — the diff already told us they're stale.
|
|
92
|
+
let resolve;
|
|
93
|
+
if (step.stale !== undefined) {
|
|
94
|
+
this._note('heal', 'cache entry invalidated by diff', step.stale);
|
|
95
|
+
resolve = { matched: false, currentHash: '', regionMatched: false, a11yMatched: false };
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const regionBuffer = await this._regionScreenshot(step.bbox);
|
|
99
|
+
resolve = new Fingerprint(step).resolve(regionBuffer, observation.a11yYaml);
|
|
100
|
+
}
|
|
101
|
+
if (resolve.matched) {
|
|
102
|
+
await this._executeAction(this._opts.actions, step.action);
|
|
103
|
+
this._steps.push({ instruction: step.instruction, action: step.action.action, ok: true });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (this._opts.ledger.replayOnly || !this._opts.ledger.canSpend(0.001)) {
|
|
107
|
+
this._steps.push({
|
|
108
|
+
instruction: step.instruction,
|
|
109
|
+
action: step.action.action,
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: 'fingerprint mismatch and budget/replay-only prevents heal',
|
|
112
|
+
});
|
|
113
|
+
return this._result(false);
|
|
114
|
+
}
|
|
115
|
+
const response = await this._callModel('heal', buildActionMessages(step.instruction, observation), { escalationModels: [this._opts.config.escalation_model] });
|
|
116
|
+
if (!response) {
|
|
117
|
+
this._steps.push({
|
|
118
|
+
instruction: step.instruction,
|
|
119
|
+
action: step.action.action,
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: 'heal blocked by budget',
|
|
122
|
+
});
|
|
123
|
+
return this._result(false);
|
|
124
|
+
}
|
|
125
|
+
const action = this._parseAction(response.content);
|
|
126
|
+
if (action.action === 'fail') {
|
|
127
|
+
this._steps.push({
|
|
128
|
+
instruction: step.instruction,
|
|
129
|
+
action: step.action.action,
|
|
130
|
+
ok: false,
|
|
131
|
+
reason: action.reasoning,
|
|
132
|
+
healed: false,
|
|
133
|
+
});
|
|
134
|
+
return this._result(false);
|
|
135
|
+
}
|
|
136
|
+
if (action.action === 'done') {
|
|
137
|
+
this._steps.push({
|
|
138
|
+
instruction: step.instruction,
|
|
139
|
+
action: step.action.action,
|
|
140
|
+
ok: false,
|
|
141
|
+
reason: 'heal returned done instead of a relocated action',
|
|
142
|
+
healed: false,
|
|
143
|
+
});
|
|
144
|
+
return this._result(false);
|
|
145
|
+
}
|
|
146
|
+
const resolved = await this._resolveAction(action);
|
|
147
|
+
const nextObservation = await this._executeAction(this._opts.actions, action);
|
|
148
|
+
const newFingerprint = await this._buildFingerprint(step.instruction, action, resolved, response.model);
|
|
149
|
+
flow.steps[i] = newFingerprint;
|
|
150
|
+
this._note('heal', 'fingerprint mismatch healed by model', step.instruction);
|
|
151
|
+
this._steps.push({
|
|
152
|
+
instruction: step.instruction,
|
|
153
|
+
action: action.action,
|
|
154
|
+
ok: true,
|
|
155
|
+
healed: true,
|
|
156
|
+
model: response.model,
|
|
157
|
+
});
|
|
158
|
+
observation = nextObservation;
|
|
159
|
+
}
|
|
160
|
+
if (options.flowName && this._opts.config.cacheDir) {
|
|
161
|
+
await saveFlow(this._opts.config.cacheDir, options.flowName, flow.steps);
|
|
162
|
+
}
|
|
163
|
+
return this._result(true);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a single element for the `td.find()` DSL (R13). When `cached` is
|
|
167
|
+
* provided and still resolves locally, this costs zero vision calls (R2);
|
|
168
|
+
* otherwise it grounds (or heals) via the model and returns a fresh
|
|
169
|
+
* fingerprint (R4). The returned point is the viewport-pixel click target.
|
|
170
|
+
*/
|
|
171
|
+
async locate(instruction, cached) {
|
|
172
|
+
const observation = await this._opts.driver.observe({ grid: true });
|
|
173
|
+
if (cached && cached.stale !== undefined) {
|
|
174
|
+
this._note('locate', 'cache entry invalidated by diff', cached.stale);
|
|
175
|
+
}
|
|
176
|
+
if (cached && cached.stale === undefined) {
|
|
177
|
+
const regionBuffer = await this._regionScreenshot(cached.bbox);
|
|
178
|
+
const resolve = new Fingerprint(cached).resolve(regionBuffer, observation.a11yYaml);
|
|
179
|
+
if (resolve.matched) {
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
reason: undefined,
|
|
183
|
+
healed: false,
|
|
184
|
+
point: cached.clickPoint,
|
|
185
|
+
fingerprint: cached,
|
|
186
|
+
model: undefined,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (this._opts.ledger.replayOnly || !this._opts.ledger.canSpend(0.001)) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
reason: 'fingerprint mismatch and budget/replay-only prevents heal',
|
|
193
|
+
healed: false,
|
|
194
|
+
point: undefined,
|
|
195
|
+
fingerprint: undefined,
|
|
196
|
+
model: undefined,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// A diff-invalidated (stale) entry is a fresh ground, not a heal — heal
|
|
201
|
+
// implies the fingerprint *checked out as wrong*, stale means we never
|
|
202
|
+
// verified it. Keeping the kind split honest keeps the heal-rate signal
|
|
203
|
+
// in the journal meaningful and avoids spending escalation calls on
|
|
204
|
+
// entries we already know are stale.
|
|
205
|
+
const isStale = cached !== undefined && cached.stale !== undefined;
|
|
206
|
+
const useHeal = cached !== undefined && !isStale;
|
|
207
|
+
const primary = await this._locateWithModel(instruction, observation, useHeal);
|
|
208
|
+
if (primary.ok)
|
|
209
|
+
return primary;
|
|
210
|
+
// Semantic escalation fallback (issue #14): the model answered but could
|
|
211
|
+
// not ground — provider-level OpenRouter fallback only covers unavailable
|
|
212
|
+
// models, not bad answers. Retry once with escalation_model as primary on
|
|
213
|
+
// a fresh observation; a page may have changed under the failure.
|
|
214
|
+
const esc = this._opts.config.escalation_model;
|
|
215
|
+
const failedModel = primary.model ?? this._opts.config.model;
|
|
216
|
+
if (esc === undefined ||
|
|
217
|
+
esc === failedModel ||
|
|
218
|
+
this._opts.ledger.replayOnly ||
|
|
219
|
+
!this._opts.ledger.canSpend(0.001)) {
|
|
220
|
+
return primary;
|
|
221
|
+
}
|
|
222
|
+
this._note('locate', 'escalating to fallback model', `failed=${failedModel} esc=${esc} reason=${(primary.reason ?? '').slice(0, 80)}`);
|
|
223
|
+
const fresh = await this._opts.driver.observe({ grid: true });
|
|
224
|
+
return this._locateWithModel(instruction, fresh, useHeal, esc);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* One locate attempt against a specific model: initial call plus the
|
|
228
|
+
* verify-then-correct loop. `modelOverride` is the escalation fallback —
|
|
229
|
+
* it keeps the action schema unless a specialist grounding model is in
|
|
230
|
+
* play (native "(x,y)" format).
|
|
231
|
+
*/
|
|
232
|
+
async _locateWithModel(instruction, observation, useHeal, modelOverride) {
|
|
233
|
+
const specialist = this._opts.config.grounding_model !== undefined;
|
|
234
|
+
const prompt = specialist
|
|
235
|
+
? // ui-tars-class models ignore JSON schemas and answer with bare
|
|
236
|
+
// "(x,y)" coordinates — ask in their native format.
|
|
237
|
+
`Click on the UI element matching this description: ${instruction.replace(/^locate:\s*/i, '')}.`
|
|
238
|
+
: instruction;
|
|
239
|
+
// The escalation retry (modelOverride) wins; otherwise a specialist's
|
|
240
|
+
// grounding_model is the primary. Don't also pass esc as the provider
|
|
241
|
+
// fallback list when esc IS the primary — that just duplicates it.
|
|
242
|
+
const primaryModel = modelOverride ?? this._opts.config.grounding_model;
|
|
243
|
+
const escalation = (specialist || useHeal) && primaryModel !== this._opts.config.escalation_model
|
|
244
|
+
? [this._opts.config.escalation_model]
|
|
245
|
+
: undefined;
|
|
246
|
+
let response;
|
|
247
|
+
try {
|
|
248
|
+
response = await this._callModel(useHeal ? 'heal' : 'ground', buildActionMessages(prompt, observation), {
|
|
249
|
+
...(escalation !== undefined ? { escalationModels: escalation } : {}),
|
|
250
|
+
...(primaryModel !== undefined ? { model: primaryModel } : {}),
|
|
251
|
+
dropSchema: specialist,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
// Provider failures are hard errors — still journal them as evidence.
|
|
256
|
+
this._note('locate', 'model call threw', e.message);
|
|
257
|
+
throw e;
|
|
258
|
+
}
|
|
259
|
+
if (!response) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
reason: 'model call blocked by budget',
|
|
263
|
+
healed: false,
|
|
264
|
+
point: undefined,
|
|
265
|
+
fingerprint: undefined,
|
|
266
|
+
model: undefined,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
let action = this._parseAction(response.content);
|
|
270
|
+
let model = response.model;
|
|
271
|
+
// Verify-then-correct: resolve the DOM node under the proposed point and
|
|
272
|
+
// check its label against the instruction's target words. A mismatch means
|
|
273
|
+
// the model's pixel grounding drifted (small models are systematically
|
|
274
|
+
// imprecise); re-ask once with the resolved element as feedback.
|
|
275
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
276
|
+
if (action.action === 'fail') {
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
reason: action.reasoning,
|
|
280
|
+
healed: false,
|
|
281
|
+
point: undefined,
|
|
282
|
+
fingerprint: undefined,
|
|
283
|
+
model,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const coordsOk = action.action === 'click' &&
|
|
287
|
+
typeof action.x === 'number' &&
|
|
288
|
+
Number.isFinite(action.x) &&
|
|
289
|
+
typeof action.y === 'number' &&
|
|
290
|
+
Number.isFinite(action.y);
|
|
291
|
+
const probe = coordsOk ? await this._resolveNode(action.x, action.y) : null;
|
|
292
|
+
// A point that resolves to no element at all is a mismatch too — it was
|
|
293
|
+
// previously accepted and cached, which let clicks into empty space get
|
|
294
|
+
// fingerprinted and replayed as "ok". Retry once with feedback; if the
|
|
295
|
+
// second attempt still resolves to nothing we accept it (canvas/shadow
|
|
296
|
+
// DOM and other unresolvable nodes are legitimate).
|
|
297
|
+
if (action.action === 'click' && coordsOk && probe !== null &&
|
|
298
|
+
instructionMatchesNode(instruction, probe.a11ySnippet)) {
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
if (attempt === 1 || !this._opts.ledger.canSpend(0.001))
|
|
302
|
+
break;
|
|
303
|
+
this._note('locate', action.action !== 'click'
|
|
304
|
+
? `locate steered after "${action.action}" response`
|
|
305
|
+
: coordsOk
|
|
306
|
+
? probe !== null
|
|
307
|
+
? 'grounding corrected after probe mismatch'
|
|
308
|
+
: 'grounding corrected after no element at coordinates'
|
|
309
|
+
: 'grounding corrected after missing/invalid coords', `attempt=${attempt} instruction=${instruction.slice(0, 80)}`);
|
|
310
|
+
const feedback = specialist
|
|
311
|
+
? // ui-tars-class models want their native prompt format.
|
|
312
|
+
`Click on the UI element matching this description: ${instruction.replace(/^locate:\s*/i, '')}.`
|
|
313
|
+
: action.action !== 'click'
|
|
314
|
+
? `A locate step must return the click point (x, y in CSS pixels) for: ${instruction}. You returned "${action.action}".`
|
|
315
|
+
: probe !== null
|
|
316
|
+
? `Your previous coordinates (${action.x},${action.y}) resolved to "${probe.a11ySnippet}", which does not match the target. Re-examine the grid labels and return corrected coordinates for: ${instruction}`
|
|
317
|
+
: coordsOk
|
|
318
|
+
? `Your previous coordinates (${action.x},${action.y}) did not resolve to any element. Re-examine the grid labels and return corrected coordinates for: ${instruction}`
|
|
319
|
+
: `Your previous response was a "${action.action}" action with no usable coordinates. Return the click point (x, y in CSS pixels) for: ${instruction}`;
|
|
320
|
+
let retry;
|
|
321
|
+
try {
|
|
322
|
+
retry = await this._callModel(useHeal ? 'heal' : 'ground', buildActionMessages(feedback, observation), {
|
|
323
|
+
...(escalation !== undefined ? { escalationModels: escalation } : {}),
|
|
324
|
+
...(primaryModel !== undefined ? { model: primaryModel } : {}),
|
|
325
|
+
dropSchema: specialist,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
this._note('locate', 'correction retry threw', e.message);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
if (!retry)
|
|
333
|
+
break;
|
|
334
|
+
action = this._parseAction(retry.content);
|
|
335
|
+
model = retry.model;
|
|
336
|
+
}
|
|
337
|
+
if (action.action === 'fail') {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
reason: action.reasoning,
|
|
341
|
+
healed: false,
|
|
342
|
+
point: undefined,
|
|
343
|
+
fingerprint: undefined,
|
|
344
|
+
model,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (typeof action.x !== 'number' || !Number.isFinite(action.x) || typeof action.y !== 'number' || !Number.isFinite(action.y)) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: `model returned "${action.action}" without coordinates`,
|
|
351
|
+
healed: false,
|
|
352
|
+
point: undefined,
|
|
353
|
+
fingerprint: undefined,
|
|
354
|
+
model,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const resolved = await this._resolveNode(action.x, action.y);
|
|
358
|
+
// Never commit a fingerprint for a node we can see is wrong — a false
|
|
359
|
+
// cache entry would silently replay the mis-click forever.
|
|
360
|
+
if (resolved !== null &&
|
|
361
|
+
action.action === 'click' &&
|
|
362
|
+
!instructionMatchesNode(instruction, resolved.a11ySnippet)) {
|
|
363
|
+
this._note('locate', 'grounding mismatch rejected', `resolved_hash=${fnv1a(resolved.a11ySnippet)}`);
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
reason: `model grounded to "${resolved.a11ySnippet}", which does not match the instruction`,
|
|
367
|
+
healed: false,
|
|
368
|
+
point: undefined,
|
|
369
|
+
fingerprint: undefined,
|
|
370
|
+
model,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const fingerprint = await this._buildFingerprint(instruction, action, resolved, model);
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
reason: undefined,
|
|
377
|
+
healed: useHeal,
|
|
378
|
+
point: { x: action.x, y: action.y },
|
|
379
|
+
fingerprint,
|
|
380
|
+
model: response.model,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
async assert(question) {
|
|
384
|
+
const observation = await this._opts.driver.observe();
|
|
385
|
+
// Page-state key is the a11y tree, not screenshot bytes — JPEG pixels
|
|
386
|
+
// shift every render, but identical DOM means the answer is unchanged.
|
|
387
|
+
const a11yHash = fnv1a(observation.a11yYaml);
|
|
388
|
+
const key = `${question}${a11yHash}`;
|
|
389
|
+
const cached = this._assertCache.get(key);
|
|
390
|
+
if (cached) {
|
|
391
|
+
return { verdict: cached.verdict, reasoning: cached.reasoning, cached: true };
|
|
392
|
+
}
|
|
393
|
+
if (this._opts.ledger.replayOnly || !this._opts.ledger.canSpend(0.001)) {
|
|
394
|
+
return { verdict: 'fail', reasoning: 'budget exceeded or replay-only', cached: false };
|
|
395
|
+
}
|
|
396
|
+
const response = await this._callModel('assert', buildAssertMessages(question, observation));
|
|
397
|
+
if (!response) {
|
|
398
|
+
return { verdict: 'fail', reasoning: 'budget exceeded', cached: false };
|
|
399
|
+
}
|
|
400
|
+
const parsed = this._parseAssertion(response.content);
|
|
401
|
+
this._assertCache.set(key, {
|
|
402
|
+
question,
|
|
403
|
+
a11yHash,
|
|
404
|
+
verdict: parsed.verdict,
|
|
405
|
+
reasoning: parsed.reasoning,
|
|
406
|
+
model: response.model,
|
|
407
|
+
});
|
|
408
|
+
return { ...parsed, cached: false };
|
|
409
|
+
}
|
|
410
|
+
async _callModel(kind, messages, opts = {}) {
|
|
411
|
+
if (!this._opts.ledger.canSpend(0.001)) {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
// Specialist grounding models don't emit JSON — sending response_format
|
|
415
|
+
// plus require_parameters would filter out their providers entirely.
|
|
416
|
+
const schema = opts.dropSchema === true ? undefined : kind === 'assert' ? assertionSchema : actionSchema;
|
|
417
|
+
const response = await this._opts.client.complete({
|
|
418
|
+
model: opts.model ?? this._opts.config.model,
|
|
419
|
+
messages,
|
|
420
|
+
...(schema !== undefined ? { schema } : {}),
|
|
421
|
+
...(opts.escalationModels !== undefined ? { escalationModels: opts.escalationModels } : {}),
|
|
422
|
+
provider: this._opts.config.provider,
|
|
423
|
+
kind,
|
|
424
|
+
});
|
|
425
|
+
this._opts.ledger.recordCall(response.cost);
|
|
426
|
+
if (this._opts.config.budgetUsd !== undefined &&
|
|
427
|
+
this._opts.ledger.visionCostUsd > this._opts.config.budgetUsd) {
|
|
428
|
+
this._opts.ledger.flagBudgetExceeded();
|
|
429
|
+
}
|
|
430
|
+
this._visionCalls++;
|
|
431
|
+
return response;
|
|
432
|
+
}
|
|
433
|
+
_parseAction(content) {
|
|
434
|
+
try {
|
|
435
|
+
const parsed = JSON.parse(content);
|
|
436
|
+
// Variant shape some models emit: {"click": "(x,y)"} or
|
|
437
|
+
// {"click": {"x": .., "y": ..}} — action name as key, payload as value.
|
|
438
|
+
const variantKey = ['click', 'type', 'pressKeys', 'scroll', 'wait', 'done', 'fail'].find((k) => k in parsed);
|
|
439
|
+
if (parsed.action === undefined && variantKey !== undefined) {
|
|
440
|
+
this._note('locate', 'tolerant action parse: variant JSON shape', content.slice(0, 80));
|
|
441
|
+
const v = parsed[variantKey];
|
|
442
|
+
const out = { action: variantKey };
|
|
443
|
+
if (typeof v === 'object' && v !== null)
|
|
444
|
+
Object.assign(out, v);
|
|
445
|
+
else if (typeof v === 'string') {
|
|
446
|
+
const coord = v.match(/\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?/);
|
|
447
|
+
if (coord) {
|
|
448
|
+
out.x = Number(coord[1]);
|
|
449
|
+
out.y = Number(coord[2]);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
out.text = v;
|
|
453
|
+
}
|
|
454
|
+
if (typeof out.x === 'string')
|
|
455
|
+
out.x = Number(out.x);
|
|
456
|
+
if (typeof out.y === 'string')
|
|
457
|
+
out.y = Number(out.y);
|
|
458
|
+
}
|
|
459
|
+
if (typeof parsed.reasoning === 'string')
|
|
460
|
+
out.reasoning = parsed.reasoning;
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
const action = String(parsed.action ?? '');
|
|
464
|
+
if (!['click', 'type', 'pressKeys', 'scroll', 'wait', 'done', 'fail'].includes(action)) {
|
|
465
|
+
return {
|
|
466
|
+
action: 'fail',
|
|
467
|
+
reasoning: `unknown action: ${action} (raw: ${content.slice(0, 160)})`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
// Some specialist models return JSON action names but put coordinates in
|
|
471
|
+
// a trailing "(x,y)" or start_box token instead of the schema fields.
|
|
472
|
+
let x = typeof parsed.x === 'number' ? parsed.x : undefined;
|
|
473
|
+
let y = typeof parsed.y === 'number' ? parsed.y : undefined;
|
|
474
|
+
if (x === undefined || y === undefined) {
|
|
475
|
+
const coord = content.match(/\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?/);
|
|
476
|
+
if (coord) {
|
|
477
|
+
x = Number(coord[1]);
|
|
478
|
+
y = Number(coord[2]);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
action: action,
|
|
483
|
+
x,
|
|
484
|
+
y,
|
|
485
|
+
text: typeof parsed.text === 'string' ? parsed.text : undefined,
|
|
486
|
+
keys: Array.isArray(parsed.keys) ? parsed.keys.map((k) => String(k)) : undefined,
|
|
487
|
+
dx: typeof parsed.dx === 'number' ? parsed.dx : undefined,
|
|
488
|
+
dy: typeof parsed.dy === 'number' ? parsed.dy : undefined,
|
|
489
|
+
ms: typeof parsed.ms === 'number' ? parsed.ms : undefined,
|
|
490
|
+
reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : '',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
catch (e) {
|
|
494
|
+
// Tolerant fallback for specialist grounding models (e.g. ui-tars) that
|
|
495
|
+
// answer with a bare "(x,y)" or `click(start_box='(x,y)')` instead of
|
|
496
|
+
// JSON. Coordinates are absolute pixels of the screenshot; values <= 1
|
|
497
|
+
// are treated as normalized [0,1] and scaled to the viewport.
|
|
498
|
+
const coord = content.match(/\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?/);
|
|
499
|
+
// Malformed-JSON fallback: pull "x":N and "y":N fields independently.
|
|
500
|
+
const xm = content.match(/"x"\s*:\s*(\d+(?:\.\d+)?)/);
|
|
501
|
+
const ym = content.match(/"y"\s*:\s*(\d+(?:\.\d+)?)/);
|
|
502
|
+
const px = coord?.[1] ?? xm?.[1];
|
|
503
|
+
const py = coord?.[2] ?? ym?.[1];
|
|
504
|
+
if (px !== undefined && py !== undefined) {
|
|
505
|
+
this._note('locate', 'tolerant action parse: coordinate extraction', content.slice(0, 80));
|
|
506
|
+
let x = Number(px);
|
|
507
|
+
let y = Number(py);
|
|
508
|
+
if (x <= 1 && y <= 1) {
|
|
509
|
+
x = Math.round(x * 1280);
|
|
510
|
+
y = Math.round(y * 720);
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
action: 'click',
|
|
514
|
+
x: Math.round(x),
|
|
515
|
+
y: Math.round(y),
|
|
516
|
+
reasoning: `coordinate-only response: ${content.slice(0, 120)}`,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
this._note('locate', 'model output unparseable', content.slice(0, 80));
|
|
520
|
+
return { action: 'fail', reasoning: `JSON parse failed: ${e.message}` };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
_parseAssertion(content) {
|
|
524
|
+
try {
|
|
525
|
+
const parsed = JSON.parse(content);
|
|
526
|
+
const verdict = String(parsed.verdict ?? '');
|
|
527
|
+
if (verdict !== 'pass' && verdict !== 'fail') {
|
|
528
|
+
return { verdict: 'fail', reasoning: `invalid verdict: ${verdict}` };
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
verdict,
|
|
532
|
+
reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : '',
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
catch (e) {
|
|
536
|
+
return { verdict: 'fail', reasoning: `JSON parse failed: ${e.message}` };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async _resolveAction(action) {
|
|
540
|
+
if (typeof action.x !== 'number' || !Number.isFinite(action.x) || typeof action.y !== 'number' || !Number.isFinite(action.y)) {
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
return this._resolveNode(action.x, action.y);
|
|
544
|
+
}
|
|
545
|
+
async _resolveNode(x, y) {
|
|
546
|
+
const info = await this._opts.driver.rawPage.evaluate(([cx, cy]) => {
|
|
547
|
+
const doc = globalThis.document;
|
|
548
|
+
const el = doc.elementFromPoint(cx, cy);
|
|
549
|
+
if (!el) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const rect = el.getBoundingClientRect();
|
|
553
|
+
const snippet = (el.getAttribute('aria-label') || el.textContent || '')
|
|
554
|
+
.trim()
|
|
555
|
+
.slice(0, 200);
|
|
556
|
+
return {
|
|
557
|
+
x: Math.round(rect.x),
|
|
558
|
+
y: Math.round(rect.y),
|
|
559
|
+
width: Math.round(rect.width),
|
|
560
|
+
height: Math.round(rect.height),
|
|
561
|
+
snippet,
|
|
562
|
+
};
|
|
563
|
+
}, [x, y]);
|
|
564
|
+
if (!info) {
|
|
565
|
+
return {
|
|
566
|
+
bbox: { x, y, width: 0, height: 0 },
|
|
567
|
+
clickPoint: { x, y },
|
|
568
|
+
a11ySnippet: '',
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
return { bbox: info, clickPoint: { x, y }, a11ySnippet: info.snippet };
|
|
572
|
+
}
|
|
573
|
+
async _executeAction(tdApi, action) {
|
|
574
|
+
switch (action.action) {
|
|
575
|
+
case 'click':
|
|
576
|
+
return tdApi.click(Number.isFinite(action.x) ? action.x : 0, Number.isFinite(action.y) ? action.y : 0);
|
|
577
|
+
case 'type':
|
|
578
|
+
return tdApi.type(action.text ?? '');
|
|
579
|
+
case 'pressKeys':
|
|
580
|
+
return tdApi.pressKeys(action.keys ?? []);
|
|
581
|
+
case 'scroll':
|
|
582
|
+
return tdApi.scroll(action.dx ?? 0, action.dy ?? 0);
|
|
583
|
+
case 'wait':
|
|
584
|
+
return tdApi.wait(action.ms ?? 0);
|
|
585
|
+
default:
|
|
586
|
+
return this._opts.driver.observe({ grid: true });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async _buildFingerprint(instruction, action, resolved, model) {
|
|
590
|
+
const { reasoning: _, ...payload } = action;
|
|
591
|
+
const actionPayload = payload;
|
|
592
|
+
if (!resolved) {
|
|
593
|
+
return {
|
|
594
|
+
instruction,
|
|
595
|
+
action: actionPayload,
|
|
596
|
+
bbox: { x: 0, y: 0, width: 0, height: 0 },
|
|
597
|
+
clickPoint: { x: 0, y: 0 },
|
|
598
|
+
model,
|
|
599
|
+
a11ySnippet: '',
|
|
600
|
+
regionHash: '',
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
const regionBuffer = await this._regionScreenshot(resolved.bbox);
|
|
604
|
+
return {
|
|
605
|
+
instruction,
|
|
606
|
+
action: actionPayload,
|
|
607
|
+
bbox: resolved.bbox,
|
|
608
|
+
clickPoint: resolved.clickPoint,
|
|
609
|
+
model,
|
|
610
|
+
a11ySnippet: resolved.a11ySnippet,
|
|
611
|
+
regionHash: computeRegionHash(regionBuffer),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
async _regionScreenshot(bbox) {
|
|
615
|
+
const raw = await this._opts.driver.rawPage.screenshot({
|
|
616
|
+
clip: { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height },
|
|
617
|
+
type: 'jpeg',
|
|
618
|
+
quality: 70,
|
|
619
|
+
scale: 'css',
|
|
620
|
+
});
|
|
621
|
+
return Buffer.from(raw);
|
|
622
|
+
}
|
|
623
|
+
_result(ok, reason) {
|
|
624
|
+
return { ok, steps: this._steps, visionCalls: this._visionCalls, ...(reason ? { reason } : {}) };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Cheap semantic check for the verify-then-correct loop: does the resolved
|
|
629
|
+
* node's label share any content word with the instruction? Stopwords and
|
|
630
|
+
* short words are ignored; quoted phrases are split into words.
|
|
631
|
+
*/
|
|
632
|
+
const LOCATE_STOPWORDS = new Set([
|
|
633
|
+
'the', 'a', 'an', 'in', 'on', 'of', 'to', 'for', 'with', 'below', 'above',
|
|
634
|
+
'left', 'right', 'top', 'bottom', 'side', 'sidebar', 'navigation', 'nav',
|
|
635
|
+
'item', 'button', 'link', 'field', 'input', 'section', 'area', 'panel',
|
|
636
|
+
'that', 'this', 'into', 'onto', 'page', 'view', 'menu', 'click', 'find',
|
|
637
|
+
]);
|
|
638
|
+
export function instructionMatchesNode(instruction, nodeSnippet) {
|
|
639
|
+
const words = instruction
|
|
640
|
+
.replace(/^locate:\s*/i, '')
|
|
641
|
+
.toLowerCase()
|
|
642
|
+
.replace(/["'']/g, ' ')
|
|
643
|
+
.split(/[^a-z0-9]+/)
|
|
644
|
+
.filter((w) => w.length >= 4 && !LOCATE_STOPWORDS.has(w));
|
|
645
|
+
if (words.length === 0)
|
|
646
|
+
return true;
|
|
647
|
+
const haystack = nodeSnippet.toLowerCase();
|
|
648
|
+
return words.some((w) => haystack.includes(w));
|
|
649
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Observation } from '../driver/browser.js';
|
|
2
|
+
import { JsonSchema, Message } from '../vision/openrouter.js';
|
|
3
|
+
import { ActionPayload } from '../cache/fingerprint.js';
|
|
4
|
+
export interface ProposedAction extends ActionPayload {
|
|
5
|
+
reasoning: string;
|
|
6
|
+
}
|
|
7
|
+
export interface AssertionResult {
|
|
8
|
+
verdict: 'pass' | 'fail';
|
|
9
|
+
reasoning: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const actionSchema: JsonSchema;
|
|
12
|
+
export declare const assertionSchema: JsonSchema;
|
|
13
|
+
/** One executed record step as it appears in the next prompt's transcript. */
|
|
14
|
+
export interface PriorAction {
|
|
15
|
+
action: ActionPayload;
|
|
16
|
+
/** Resolved element label (a11y snippet) when the action hit a node. */
|
|
17
|
+
label?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Compact one-line rendering of an executed action for the record transcript. */
|
|
20
|
+
export declare function describeAction(action: ActionPayload, label?: string): string;
|
|
21
|
+
export declare function buildActionMessages(instruction: string, observation: Observation, priorActions?: PriorAction[]): Message[];
|
|
22
|
+
export declare function buildAssertMessages(question: string, observation: Observation): Message[];
|