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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/action/action.yml +147 -0
  4. package/action/sticky-comment.mjs +376 -0
  5. package/dist/api.d.ts +121 -0
  6. package/dist/api.js +256 -0
  7. package/dist/cache/fingerprint.d.ts +59 -0
  8. package/dist/cache/fingerprint.js +67 -0
  9. package/dist/cache/store.d.ts +17 -0
  10. package/dist/cache/store.js +41 -0
  11. package/dist/cli.d.ts +25 -0
  12. package/dist/cli.js +1355 -0
  13. package/dist/config.d.ts +130 -0
  14. package/dist/config.js +163 -0
  15. package/dist/debug.d.ts +1 -0
  16. package/dist/debug.js +30 -0
  17. package/dist/detect.d.ts +50 -0
  18. package/dist/detect.js +105 -0
  19. package/dist/driver/browser.d.ts +64 -0
  20. package/dist/driver/browser.js +200 -0
  21. package/dist/driver/target.d.ts +23 -0
  22. package/dist/driver/target.js +97 -0
  23. package/dist/engine/actions.d.ts +26 -0
  24. package/dist/engine/actions.js +47 -0
  25. package/dist/engine/loop.d.ts +118 -0
  26. package/dist/engine/loop.js +649 -0
  27. package/dist/engine/prompts.d.ts +22 -0
  28. package/dist/engine/prompts.js +112 -0
  29. package/dist/evidence/ci.d.ts +18 -0
  30. package/dist/evidence/ci.js +61 -0
  31. package/dist/evidence/link.d.ts +35 -0
  32. package/dist/evidence/link.js +90 -0
  33. package/dist/executor/a0.d.ts +29 -0
  34. package/dist/executor/a0.js +38 -0
  35. package/dist/fsutil.d.ts +5 -0
  36. package/dist/fsutil.js +12 -0
  37. package/dist/index/context.d.ts +17 -0
  38. package/dist/index/context.js +88 -0
  39. package/dist/index/diff.d.ts +1 -0
  40. package/dist/index/diff.js +33 -0
  41. package/dist/index/invalidate.d.ts +28 -0
  42. package/dist/index/invalidate.js +56 -0
  43. package/dist/index/scan.d.ts +26 -0
  44. package/dist/index/scan.js +209 -0
  45. package/dist/journal/build.d.ts +15 -0
  46. package/dist/journal/build.js +47 -0
  47. package/dist/journal/schema.d.ts +59 -0
  48. package/dist/journal/schema.js +6 -0
  49. package/dist/journal/store.d.ts +10 -0
  50. package/dist/journal/store.js +26 -0
  51. package/dist/live.d.ts +2 -0
  52. package/dist/live.js +46 -0
  53. package/dist/log.d.ts +17 -0
  54. package/dist/log.js +24 -0
  55. package/dist/report/comment.d.ts +13 -0
  56. package/dist/report/comment.js +135 -0
  57. package/dist/report/junit.d.ts +10 -0
  58. package/dist/report/junit.js +46 -0
  59. package/dist/report/run.d.ts +51 -0
  60. package/dist/report/run.js +36 -0
  61. package/dist/vision/cost.d.ts +36 -0
  62. package/dist/vision/cost.js +16 -0
  63. package/dist/vision/ledger.d.ts +29 -0
  64. package/dist/vision/ledger.js +65 -0
  65. package/dist/vision/openrouter.d.ts +70 -0
  66. package/dist/vision/openrouter.js +134 -0
  67. 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[];