argus-reviewer-e2e 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -71
- package/action/action.yml +129 -11
- package/action/approval-review.mjs +13 -3
- package/action/bootstrap.mjs +2 -0
- package/action/emit-review.mjs +16 -0
- package/action/runtime.mjs +20 -0
- package/action/sticky-comment.cjs +1260 -479
- package/dist/cli.d.ts +103 -7
- package/dist/cli.js +1202 -186
- package/dist/config.d.ts +96 -11
- package/dist/config.js +102 -4
- package/dist/detect.d.ts +29 -2
- package/dist/detect.js +98 -7
- package/dist/driver/browser.d.ts +32 -0
- package/dist/driver/browser.js +56 -1
- package/dist/driver/target.d.ts +4 -1
- package/dist/driver/target.js +27 -6
- package/dist/engine/actions.d.ts +5 -0
- package/dist/engine/actions.js +8 -0
- package/dist/engine/explore.d.ts +78 -0
- package/dist/engine/explore.js +373 -0
- package/dist/engine/loop.d.ts +2 -2
- package/dist/engine/loop.js +8 -8
- package/dist/engine/prompts.d.ts +28 -1
- package/dist/engine/prompts.js +88 -0
- package/dist/evidence/ci.d.ts +13 -1
- package/dist/evidence/ci.js +38 -3
- package/dist/evidence/gate.d.ts +8 -0
- package/dist/evidence/gate.js +1 -1
- package/dist/evidence/link.js +1 -1
- package/dist/executor/a0.d.ts +114 -1
- package/dist/executor/a0.js +216 -4
- package/dist/fsutil.d.ts +3 -2
- package/dist/fsutil.js +7 -4
- package/dist/journal/schema.d.ts +1 -1
- package/dist/log.d.ts +2 -1
- package/dist/log.js +10 -2
- package/dist/mention.d.ts +45 -0
- package/dist/mention.js +107 -0
- package/dist/pipeline/app.d.ts +126 -0
- package/dist/pipeline/app.js +250 -0
- package/dist/pipeline/budget.d.ts +1 -0
- package/dist/pipeline/budget.js +1 -1
- package/dist/pipeline/verify.d.ts +20 -3
- package/dist/pipeline/verify.js +189 -35
- package/dist/probe/persist.d.ts +68 -0
- package/dist/probe/persist.js +184 -0
- package/dist/probe/queue.d.ts +12 -0
- package/dist/probe/queue.js +10 -2
- package/dist/report/brand-assets.generated.d.ts +9 -0
- package/dist/report/brand-assets.generated.js +8 -0
- package/dist/report/comment.d.ts +99 -6
- package/dist/report/comment.js +292 -103
- package/dist/report/html.d.ts +50 -0
- package/dist/report/html.js +879 -0
- package/dist/report/manifest.d.ts +29 -0
- package/dist/report/manifest.js +37 -0
- package/dist/report/run.d.ts +54 -1
- package/dist/report/run.js +34 -9
- package/dist/report/viewmodel.d.ts +91 -0
- package/dist/report/viewmodel.js +241 -0
- package/dist/review/adjudicate.d.ts +6 -6
- package/dist/review/adjudicate.js +2 -2
- package/dist/review/inline.d.ts +44 -0
- package/dist/review/inline.js +95 -0
- package/dist/review/packs.d.ts +21 -0
- package/dist/review/packs.js +47 -0
- package/dist/review/scope.d.ts +16 -0
- package/dist/review/scope.js +74 -0
- package/dist/review/secrets.d.ts +10 -10
- package/dist/review/secrets.js +7 -7
- package/dist/review/testfiles.d.ts +18 -0
- package/dist/review/testfiles.js +26 -0
- package/dist/review/triage.d.ts +1 -1
- package/dist/review/triage.js +10 -10
- package/dist/review/validate.d.ts +41 -0
- package/dist/review/validate.js +76 -0
- package/dist/ui/errors.d.ts +54 -0
- package/dist/ui/errors.js +236 -0
- package/dist/ui/style.d.ts +34 -0
- package/dist/ui/style.js +48 -0
- package/dist/ui/summary.d.ts +38 -0
- package/dist/ui/summary.js +101 -0
- package/dist/vision/cost.d.ts +1 -1
- package/dist/vision/decisions.d.ts +9 -3
- package/dist/vision/decisions.js +31 -21
- package/dist/vision/openrouter.d.ts +4 -0
- package/dist/vision/openrouter.js +30 -4
- package/package.json +11 -2
package/dist/driver/browser.js
CHANGED
|
@@ -3,6 +3,10 @@ import { tmpdir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { debug } from '../debug.js';
|
|
5
5
|
import { chromium, firefox, webkit } from 'playwright';
|
|
6
|
+
/** Distinct capture signatures kept per browser session. */
|
|
7
|
+
export const MAX_CAPTURE_SIGNATURES = 50;
|
|
8
|
+
/** Per-capture message length — console text is attacker/page-controlled. */
|
|
9
|
+
const MAX_CAPTURE_TEXT = 240;
|
|
6
10
|
const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
7
11
|
const DEFAULT_QUALITY = 70;
|
|
8
12
|
/**
|
|
@@ -19,6 +23,7 @@ export class BrowserDriver {
|
|
|
19
23
|
browserTimeoutMs;
|
|
20
24
|
video;
|
|
21
25
|
closed;
|
|
26
|
+
captures = new Map();
|
|
22
27
|
constructor(browser, context, page, quality, videoDir, viewport, browserTimeoutMs, video, closed = false) {
|
|
23
28
|
this.browser = browser;
|
|
24
29
|
this.context = context;
|
|
@@ -62,7 +67,10 @@ export class BrowserDriver {
|
|
|
62
67
|
recordVideo: { dir: videoDir, size: viewport },
|
|
63
68
|
});
|
|
64
69
|
const page = await context.newPage();
|
|
65
|
-
|
|
70
|
+
const driver = new BrowserDriver(browser, context, page, options.screenshotQuality ?? DEFAULT_QUALITY, videoDir, viewport, options.browserTimeoutMs ?? 30_000, undefined);
|
|
71
|
+
if (options.captureErrors === true)
|
|
72
|
+
driver._attachCaptureTaps();
|
|
73
|
+
return driver;
|
|
66
74
|
}
|
|
67
75
|
catch (e) {
|
|
68
76
|
await browser.close().catch(() => undefined);
|
|
@@ -70,6 +78,53 @@ export class BrowserDriver {
|
|
|
70
78
|
throw e;
|
|
71
79
|
}
|
|
72
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Exploratory capture taps (U4a). Noise controls are applied at collection:
|
|
83
|
+
* identical signatures collapse into one capture with a repeat count,
|
|
84
|
+
* request-failed events drop third-party origins (analytics/tag beacons
|
|
85
|
+
* failing is noise, not signal), and distinct signatures are capped.
|
|
86
|
+
*/
|
|
87
|
+
_attachCaptureTaps() {
|
|
88
|
+
const page = this.page;
|
|
89
|
+
page.on('console', (msg) => {
|
|
90
|
+
if (msg.type() === 'error')
|
|
91
|
+
this._addCapture('console-error', msg.text());
|
|
92
|
+
});
|
|
93
|
+
page.on('pageerror', (err) => {
|
|
94
|
+
this._addCapture('pageerror', err.message);
|
|
95
|
+
});
|
|
96
|
+
page.on('requestfailed', (req) => {
|
|
97
|
+
const url = req.url();
|
|
98
|
+
// Third-party failures (blocked trackers, dead CDNs) are ambient —
|
|
99
|
+
// keep only requests same-origin with the page under test.
|
|
100
|
+
try {
|
|
101
|
+
const pageOrigin = new URL(page.url()).origin;
|
|
102
|
+
if (pageOrigin !== 'null' && new URL(url).origin !== pageOrigin)
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Unparseable page URL (pre-navigation) — keep the capture.
|
|
107
|
+
}
|
|
108
|
+
const failure = req.failure()?.errorText ?? 'failed';
|
|
109
|
+
this._addCapture('request-failed', failure, url);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
_addCapture(kind, text, url) {
|
|
113
|
+
const clean = text.replace(/\s+/g, ' ').trim().slice(0, MAX_CAPTURE_TEXT);
|
|
114
|
+
const key = `${kind}|${clean}|${url ?? ''}`;
|
|
115
|
+
const existing = this.captures.get(key);
|
|
116
|
+
if (existing !== undefined) {
|
|
117
|
+
existing.count++;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (this.captures.size >= MAX_CAPTURE_SIGNATURES)
|
|
121
|
+
return;
|
|
122
|
+
this.captures.set(key, { kind, text: clean, ...(url !== undefined ? { url } : {}), count: 1 });
|
|
123
|
+
}
|
|
124
|
+
/** Captured page anomalies for this session — empty unless captureErrors. */
|
|
125
|
+
pageCaptures() {
|
|
126
|
+
return [...this.captures.values()];
|
|
127
|
+
}
|
|
73
128
|
get rawPage() {
|
|
74
129
|
return this.page;
|
|
75
130
|
}
|
package/dist/driver/target.d.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { Target } from '../config.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Poll `url` until it answers with HTTP 2xx/3xx or the timeout elapses.
|
|
4
4
|
* Non-http(s) schemes (e.g. file://) cannot be fetched, so they are treated
|
|
5
|
-
* as immediately ready — Playwright navigates them directly.
|
|
5
|
+
* as immediately ready — Playwright navigates them directly. An
|
|
6
|
+
* absent/non-finite timeout falls back to the default rather than NaN-ing
|
|
7
|
+
* the deadline into a hang; each fetch is itself time-bounded so a
|
|
8
|
+
* connect-then-silent endpoint can't park a poll.
|
|
6
9
|
*/
|
|
7
10
|
export declare function waitForReady(url: string, timeoutMs: number): Promise<void>;
|
|
8
11
|
/**
|
package/dist/driver/target.js
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
const POLL_INTERVAL_MS = 250;
|
|
3
3
|
const STOP_GRACE_MS = 3_000;
|
|
4
|
+
const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
|
5
|
+
const PER_REQUEST_TIMEOUT_MS = 5_000;
|
|
4
6
|
/**
|
|
5
7
|
* Poll `url` until it answers with HTTP 2xx/3xx or the timeout elapses.
|
|
6
8
|
* Non-http(s) schemes (e.g. file://) cannot be fetched, so they are treated
|
|
7
|
-
* as immediately ready — Playwright navigates them directly.
|
|
9
|
+
* as immediately ready — Playwright navigates them directly. An
|
|
10
|
+
* absent/non-finite timeout falls back to the default rather than NaN-ing
|
|
11
|
+
* the deadline into a hang; each fetch is itself time-bounded so a
|
|
12
|
+
* connect-then-silent endpoint can't park a poll.
|
|
8
13
|
*/
|
|
9
14
|
export async function waitForReady(url, timeoutMs) {
|
|
10
15
|
if (!/^https?:/i.test(url))
|
|
11
16
|
return;
|
|
12
|
-
const
|
|
17
|
+
const bounded = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_READY_TIMEOUT_MS;
|
|
18
|
+
const deadline = Date.now() + bounded;
|
|
13
19
|
for (;;) {
|
|
14
20
|
try {
|
|
15
|
-
const res = await fetch(url, {
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
redirect: 'manual',
|
|
23
|
+
signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS),
|
|
24
|
+
});
|
|
16
25
|
if (res.status >= 200 && res.status < 400)
|
|
17
26
|
return;
|
|
18
27
|
}
|
|
@@ -21,7 +30,7 @@ export async function waitForReady(url, timeoutMs) {
|
|
|
21
30
|
}
|
|
22
31
|
if (Date.now() >= deadline) {
|
|
23
32
|
throw new Error(`Target did not become ready: ${url} did not respond ` +
|
|
24
|
-
`with HTTP 2xx/3xx within ${
|
|
33
|
+
`with HTTP 2xx/3xx within ${bounded}ms`);
|
|
25
34
|
}
|
|
26
35
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
27
36
|
}
|
|
@@ -60,6 +69,16 @@ export class TargetProcess {
|
|
|
60
69
|
const ready = waitForReady(spec.url, spec.readyTimeoutMs);
|
|
61
70
|
try {
|
|
62
71
|
await Promise.race([ready, childExited]);
|
|
72
|
+
// A squatter already serving the URL resolves `ready` while the real
|
|
73
|
+
// child is still binding (or has just died EADDRINUSE). Give it one
|
|
74
|
+
// poll interval to surface, then refuse: running lanes against the
|
|
75
|
+
// stale server would bind evidence to code that isn't this checkout.
|
|
76
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
77
|
+
if (child.exitCode !== null) {
|
|
78
|
+
throw new Error(`Target command exited (${child.exitCode}) right after ${spec.url} ` +
|
|
79
|
+
'answered ready — another process is likely already serving that ' +
|
|
80
|
+
'URL; refusing to run evidence against a server this run did not start');
|
|
81
|
+
}
|
|
63
82
|
}
|
|
64
83
|
catch (e) {
|
|
65
84
|
await proc.stop();
|
|
@@ -86,9 +105,11 @@ export class TargetProcess {
|
|
|
86
105
|
}
|
|
87
106
|
await exited;
|
|
88
107
|
try {
|
|
89
|
-
|
|
108
|
+
// Escalate on the GROUP unconditionally, not on the child's exitCode:
|
|
109
|
+
// a wrapper shell can exit promptly while a spawned server keeps the
|
|
110
|
+
// group (and the port) alive — gating on exitCode leaves it orphaned.
|
|
111
|
+
if (this.child.pid !== undefined)
|
|
90
112
|
process.kill(-this.child.pid, 'SIGKILL');
|
|
91
|
-
}
|
|
92
113
|
}
|
|
93
114
|
catch {
|
|
94
115
|
// already gone
|
package/dist/engine/actions.d.ts
CHANGED
|
@@ -23,4 +23,9 @@ export declare class Actions {
|
|
|
23
23
|
scroll(dx: number, dy: number): Promise<Observation>;
|
|
24
24
|
/** Wait ms milliseconds, then observe. */
|
|
25
25
|
wait(ms: number): Promise<Observation>;
|
|
26
|
+
/**
|
|
27
|
+
* Navigate to a URL, then observe. Explore-lane primitive — the caller
|
|
28
|
+
* (ExploreLoop) enforces the same-origin bound before this is invoked.
|
|
29
|
+
*/
|
|
30
|
+
navigate(url: string): Promise<Observation>;
|
|
26
31
|
}
|
package/dist/engine/actions.js
CHANGED
|
@@ -44,4 +44,12 @@ export class Actions {
|
|
|
44
44
|
await this.driver.rawPage.waitForTimeout(ms);
|
|
45
45
|
return this.driver.observe({ grid: true });
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Navigate to a URL, then observe. Explore-lane primitive — the caller
|
|
49
|
+
* (ExploreLoop) enforces the same-origin bound before this is invoked.
|
|
50
|
+
*/
|
|
51
|
+
async navigate(url) {
|
|
52
|
+
await this.driver.goto(url);
|
|
53
|
+
return this.driver.observe({ grid: true });
|
|
54
|
+
}
|
|
47
55
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { BrowserDriver, Observation } from '../driver/browser.js';
|
|
2
|
+
import type { Page } from 'playwright';
|
|
3
|
+
import { Actions } from './actions.js';
|
|
4
|
+
import { Config } from '../config.js';
|
|
5
|
+
import { ErrorRecord } from '../journal/schema.js';
|
|
6
|
+
import { Logger } from '../log.js';
|
|
7
|
+
import { Ledger } from '../vision/ledger.js';
|
|
8
|
+
import { ExploreAction } from './prompts.js';
|
|
9
|
+
import type { VisionClient } from './loop.js';
|
|
10
|
+
export type ExploreStopReason = 'done' | 'max-steps' | 'budget' | 'stalled' | 'error' | 'expectation' | 'timeout';
|
|
11
|
+
export interface ExploreStep {
|
|
12
|
+
action: string;
|
|
13
|
+
/** Page URL after the act. */
|
|
14
|
+
url: string;
|
|
15
|
+
/** Refusal/execution note (e.g. 'refused: cross-origin'). */
|
|
16
|
+
note?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ExploreResult {
|
|
19
|
+
steps: ExploreStep[];
|
|
20
|
+
/** Distinct page URLs visited during the pass. */
|
|
21
|
+
visited: number;
|
|
22
|
+
stopReason: ExploreStopReason;
|
|
23
|
+
visionCalls: number;
|
|
24
|
+
/** Spend attributable to this explore pass (delta over the shared ledger). */
|
|
25
|
+
visionCostUsd: number;
|
|
26
|
+
notes: ErrorRecord[];
|
|
27
|
+
/** Page URL at stop time — the lane records where evidence ended. */
|
|
28
|
+
finalUrl: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Lane-side expected-state input: fresh observation, current URL, raw page. */
|
|
31
|
+
export interface ExpectationContext {
|
|
32
|
+
observation: Observation;
|
|
33
|
+
url: string;
|
|
34
|
+
page: Page;
|
|
35
|
+
}
|
|
36
|
+
export interface ExploreOptions {
|
|
37
|
+
driver: BrowserDriver;
|
|
38
|
+
actions: Actions;
|
|
39
|
+
client: VisionClient;
|
|
40
|
+
ledger: Ledger;
|
|
41
|
+
config: Config;
|
|
42
|
+
/** Resolved run URL — the structural origin bound for `navigate`. */
|
|
43
|
+
targetUrl: string;
|
|
44
|
+
logger?: Logger;
|
|
45
|
+
/**
|
|
46
|
+
* Directed-task text (verify --app): replaces the free-probe goal in the
|
|
47
|
+
* prompt. The substrate stays observation/act machinery either way.
|
|
48
|
+
*/
|
|
49
|
+
task?: string;
|
|
50
|
+
/** Epoch-ms wall-clock bound — checked each step; 'timeout' on expiry. */
|
|
51
|
+
deadlineAt?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Lane-side expected-state predicate evaluated on each fresh observation
|
|
54
|
+
* before the model call — a satisfied marker stops the loop without
|
|
55
|
+
* spending another call ('expectation'). Throwing degrades to a note.
|
|
56
|
+
*/
|
|
57
|
+
expectation?: (ctx: ExpectationContext) => Promise<boolean>;
|
|
58
|
+
/** Lane step-cap override; defaults to config.explore.maxSteps. */
|
|
59
|
+
maxSteps?: number;
|
|
60
|
+
/** Lane spend-cap override; defaults to config.explore.budgetUsd. */
|
|
61
|
+
budgetUsd?: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Free-explore act policy (U4b): observe → propose → bound → execute →
|
|
65
|
+
* repeat, with no recorded flow. Every proposal is validated against the
|
|
66
|
+
* action vocabulary, the same-origin navigation bound, and the input caps
|
|
67
|
+
* before it reaches the driver — model output is a proposal, never an
|
|
68
|
+
* instruction (R8). The loop halts on done, maxSteps, budget, stall, or an
|
|
69
|
+
* execution error; it never throws.
|
|
70
|
+
*/
|
|
71
|
+
export declare function runExplore(opts: ExploreOptions): Promise<ExploreResult>;
|
|
72
|
+
/**
|
|
73
|
+
* Tolerant parse of the explore proposal, mirroring Engine's `_parseAction`
|
|
74
|
+
* shape: strict JSON first, the {"click": "(x,y)"} variant, then a bare
|
|
75
|
+
* coordinate extraction for specialist models. Returns undefined when
|
|
76
|
+
* nothing usable can be recovered — the caller counts it as a step.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseExploreAction(content: string): ExploreAction | undefined;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { fnv1a } from '../cache/fingerprint.js';
|
|
2
|
+
import { buildExploreMessages, exploreActionSchema, } from './prompts.js';
|
|
3
|
+
/** Model-typed text is bounded before it reaches the driver. */
|
|
4
|
+
const MAX_TYPE_CHARS = 500;
|
|
5
|
+
/** Explore never waits more than 5s per step — long waits burn the cap. */
|
|
6
|
+
const MAX_WAIT_MS = 5_000;
|
|
7
|
+
/** Scroll deltas are clamped so a model can't request absurd jumps. */
|
|
8
|
+
const MAX_SCROLL_PX = 3_000;
|
|
9
|
+
/** Same page signature this many times total → 'stalled' (catches ping-pong, not just idle). */
|
|
10
|
+
const STALL_LIMIT = 3;
|
|
11
|
+
/**
|
|
12
|
+
* Explore needs form submit and focus movement, not arbitrary chords —
|
|
13
|
+
* anything outside this list is dropped from a pressKeys proposal.
|
|
14
|
+
*/
|
|
15
|
+
const ALLOWED_KEYS = new Set([
|
|
16
|
+
'Enter',
|
|
17
|
+
'Tab',
|
|
18
|
+
'Escape',
|
|
19
|
+
'Backspace',
|
|
20
|
+
'Delete',
|
|
21
|
+
'ArrowUp',
|
|
22
|
+
'ArrowDown',
|
|
23
|
+
'ArrowLeft',
|
|
24
|
+
'ArrowRight',
|
|
25
|
+
'Home',
|
|
26
|
+
'End',
|
|
27
|
+
'Space',
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Free-explore act policy (U4b): observe → propose → bound → execute →
|
|
31
|
+
* repeat, with no recorded flow. Every proposal is validated against the
|
|
32
|
+
* action vocabulary, the same-origin navigation bound, and the input caps
|
|
33
|
+
* before it reaches the driver — model output is a proposal, never an
|
|
34
|
+
* instruction (R8). The loop halts on done, maxSteps, budget, stall, or an
|
|
35
|
+
* execution error; it never throws.
|
|
36
|
+
*/
|
|
37
|
+
export async function runExplore(opts) {
|
|
38
|
+
const { driver, actions, client, ledger, config, logger } = opts;
|
|
39
|
+
const maxSteps = opts.maxSteps ?? config.explore.maxSteps;
|
|
40
|
+
const laneBudget = opts.budgetUsd ?? config.explore.budgetUsd ?? config.budgetUsd;
|
|
41
|
+
const costStart = ledger.visionCostUsd;
|
|
42
|
+
// Non-http(s) targets (file:// demos) get a no-navigate policy — 'null'
|
|
43
|
+
// origin cannot be compared, so navigate proposals are always refused.
|
|
44
|
+
const targetOrigin = httpOrigin(opts.targetUrl);
|
|
45
|
+
const steps = [];
|
|
46
|
+
const priorActs = [];
|
|
47
|
+
const notes = [];
|
|
48
|
+
const visited = new Set();
|
|
49
|
+
const signatures = new Map();
|
|
50
|
+
let visionCalls = 0;
|
|
51
|
+
const note = (message, context) => {
|
|
52
|
+
notes.push({ stage: 'explore', message, ...(context !== undefined ? { context } : {}) });
|
|
53
|
+
logger?.debug(`explore: ${message}${context !== undefined ? ` (${context})` : ''}`);
|
|
54
|
+
};
|
|
55
|
+
const finish = (stopReason) => {
|
|
56
|
+
let finalUrl;
|
|
57
|
+
try {
|
|
58
|
+
finalUrl = driver.rawPage.url();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// A dead page leaves no final URL — evidence, not a crash.
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
steps,
|
|
65
|
+
visited: visited.size,
|
|
66
|
+
stopReason,
|
|
67
|
+
visionCalls,
|
|
68
|
+
visionCostUsd: ledger.visionCostUsd - costStart,
|
|
69
|
+
notes,
|
|
70
|
+
finalUrl,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const budgetExhausted = () => ledger.replayOnly ||
|
|
74
|
+
!ledger.canSpend(0.001) ||
|
|
75
|
+
(laneBudget !== undefined && ledger.visionCostUsd - costStart >= laneBudget);
|
|
76
|
+
let observation;
|
|
77
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
78
|
+
if (budgetExhausted())
|
|
79
|
+
return finish('budget');
|
|
80
|
+
if (opts.deadlineAt !== undefined && Date.now() >= opts.deadlineAt) {
|
|
81
|
+
return finish('timeout');
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
observation = observation ?? (await driver.observe({ grid: true }));
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
note('observe failed', e.message);
|
|
88
|
+
return finish('error');
|
|
89
|
+
}
|
|
90
|
+
const url = driver.rawPage.url();
|
|
91
|
+
visited.add(url);
|
|
92
|
+
const sig = fnv1a(`${url}\n${observation.a11yYaml}`);
|
|
93
|
+
const seen = (signatures.get(sig) ?? 0) + 1;
|
|
94
|
+
signatures.set(sig, seen);
|
|
95
|
+
if (seen >= STALL_LIMIT) {
|
|
96
|
+
note('page state unchanged — stopping');
|
|
97
|
+
return finish('stalled');
|
|
98
|
+
}
|
|
99
|
+
// Lane-side expected state is checked on the fresh observation *before*
|
|
100
|
+
// spending a model call — a satisfied marker ends the loop for free.
|
|
101
|
+
if (opts.expectation !== undefined) {
|
|
102
|
+
try {
|
|
103
|
+
if (await opts.expectation({ observation, url, page: driver.rawPage })) {
|
|
104
|
+
return finish('expectation');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
note('expectation check failed', e.message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let response;
|
|
112
|
+
try {
|
|
113
|
+
response = await client.complete({
|
|
114
|
+
model: config.model,
|
|
115
|
+
messages: buildExploreMessages(observation, priorActs, opts.task),
|
|
116
|
+
schema: exploreActionSchema,
|
|
117
|
+
provider: config.provider,
|
|
118
|
+
kind: 'explore',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
note('model call threw', e.message);
|
|
123
|
+
return finish('error');
|
|
124
|
+
}
|
|
125
|
+
ledger.recordCall(response.cost);
|
|
126
|
+
visionCalls++;
|
|
127
|
+
if (laneBudget !== undefined && ledger.visionCostUsd - costStart > laneBudget) {
|
|
128
|
+
ledger.flagBudgetExceeded();
|
|
129
|
+
}
|
|
130
|
+
const action = parseExploreAction(response.content);
|
|
131
|
+
if (action === undefined) {
|
|
132
|
+
note('unparseable proposal', response.content.slice(0, 160));
|
|
133
|
+
steps.push({ action: 'noop', url, note: 'unparseable proposal' });
|
|
134
|
+
priorActs.push({ action: { action: 'fail', reasoning: 'unparseable' }, url, note: 'unparseable proposal' });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (action.action === 'done') {
|
|
138
|
+
steps.push({ action: 'done', url });
|
|
139
|
+
return finish('done');
|
|
140
|
+
}
|
|
141
|
+
const bounded = boundAction(action, url, targetOrigin, observation);
|
|
142
|
+
let nextUrl = url;
|
|
143
|
+
let stepNote = bounded.refusal;
|
|
144
|
+
if (bounded.refusal === undefined) {
|
|
145
|
+
try {
|
|
146
|
+
observation = await executeExploreAction(actions, bounded.action);
|
|
147
|
+
nextUrl = driver.rawPage.url();
|
|
148
|
+
if (httpOrigin(nextUrl) === targetOrigin) {
|
|
149
|
+
visited.add(nextUrl);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
// A click or submit can leave the target without a navigate
|
|
153
|
+
// proposal — the origin bound applies to wherever an act
|
|
154
|
+
// lands, not just to navigate. Discard the foreign
|
|
155
|
+
// observation and return to the last in-origin page; if we
|
|
156
|
+
// can't get back, the pass stops rather than act off-origin.
|
|
157
|
+
note('act left target origin — returning', nextUrl);
|
|
158
|
+
stepNote = 'refused: left target origin';
|
|
159
|
+
observation = undefined;
|
|
160
|
+
nextUrl = url;
|
|
161
|
+
try {
|
|
162
|
+
await driver.goto(url);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
note('return to target origin failed', e.message);
|
|
166
|
+
steps.push({ action: bounded.action.action, url, note: stepNote });
|
|
167
|
+
priorActs.push({ action: bounded.action, url, note: stepNote });
|
|
168
|
+
return finish('error');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
// A failed act (nav timeout, detached node, dead page) is evidence of
|
|
174
|
+
// a broken app, not a loop failure — journal it and let the model
|
|
175
|
+
// pick a different probe next step.
|
|
176
|
+
note(`act ${bounded.action.action} failed`, e.message);
|
|
177
|
+
stepNote = `failed: ${e.message.slice(0, 80)}`;
|
|
178
|
+
observation = undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
note(`act refused: ${bounded.refusal}`, bounded.action.url ?? bounded.action.action);
|
|
183
|
+
observation = undefined;
|
|
184
|
+
}
|
|
185
|
+
steps.push({
|
|
186
|
+
action: bounded.action.action,
|
|
187
|
+
url: nextUrl,
|
|
188
|
+
...(stepNote !== undefined ? { note: stepNote } : {}),
|
|
189
|
+
});
|
|
190
|
+
priorActs.push({
|
|
191
|
+
action: bounded.action,
|
|
192
|
+
url: nextUrl,
|
|
193
|
+
...(stepNote !== undefined ? { note: stepNote } : {}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return finish('max-steps');
|
|
197
|
+
}
|
|
198
|
+
/** Parse an http(s) origin; anything else (file://, parse failure) → null. */
|
|
199
|
+
function httpOrigin(url) {
|
|
200
|
+
try {
|
|
201
|
+
const parsed = new URL(url);
|
|
202
|
+
if (!/^https?:$/.test(parsed.protocol))
|
|
203
|
+
return undefined;
|
|
204
|
+
return parsed.origin;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Structural bounds applied to every proposal before it reaches the driver
|
|
212
|
+
* (R3, R7, R8): same-origin navigation, typed-text truncation, key
|
|
213
|
+
* allowlist, scroll/wait clamps. Returns either the bounded action or a
|
|
214
|
+
* refusal string — refused proposals still consume a step so the loop
|
|
215
|
+
* cannot spin on refusals for free.
|
|
216
|
+
*/
|
|
217
|
+
function boundAction(action, currentUrl, targetOrigin, observation) {
|
|
218
|
+
switch (action.action) {
|
|
219
|
+
case 'navigate': {
|
|
220
|
+
const raw = typeof action.url === 'string' ? action.url.trim() : '';
|
|
221
|
+
if (raw === '')
|
|
222
|
+
return { action, refusal: 'navigate without url' };
|
|
223
|
+
let resolved;
|
|
224
|
+
try {
|
|
225
|
+
resolved = new URL(raw, currentUrl);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return { action, refusal: 'unparseable url' };
|
|
229
|
+
}
|
|
230
|
+
if (!/^https?:$/.test(resolved.protocol)) {
|
|
231
|
+
return { action, refusal: `refused scheme: ${resolved.protocol}` };
|
|
232
|
+
}
|
|
233
|
+
if (targetOrigin === undefined || resolved.origin !== targetOrigin) {
|
|
234
|
+
return { action, refusal: 'refused: cross-origin' };
|
|
235
|
+
}
|
|
236
|
+
return { action: { ...action, url: resolved.href } };
|
|
237
|
+
}
|
|
238
|
+
case 'type': {
|
|
239
|
+
const text = (action.text ?? '').slice(0, MAX_TYPE_CHARS);
|
|
240
|
+
return { action: { ...action, text } };
|
|
241
|
+
}
|
|
242
|
+
case 'pressKeys': {
|
|
243
|
+
const keys = (action.keys ?? []).filter((k) => ALLOWED_KEYS.has(k));
|
|
244
|
+
if (keys.length === 0)
|
|
245
|
+
return { action, refusal: 'no allowed keys' };
|
|
246
|
+
return { action: { ...action, keys } };
|
|
247
|
+
}
|
|
248
|
+
case 'scroll': {
|
|
249
|
+
const clamp = (v) => Math.max(-MAX_SCROLL_PX, Math.min(MAX_SCROLL_PX, Number.isFinite(v) ? v : 0));
|
|
250
|
+
return { action: { ...action, dx: clamp(action.dx), dy: clamp(action.dy) } };
|
|
251
|
+
}
|
|
252
|
+
case 'wait': {
|
|
253
|
+
const ms = Math.max(0, Math.min(MAX_WAIT_MS, Number.isFinite(action.ms) ? action.ms : 0));
|
|
254
|
+
return { action: { ...action, ms } };
|
|
255
|
+
}
|
|
256
|
+
case 'click': {
|
|
257
|
+
const x = Number.isFinite(action.x) ? Math.round(action.x) : undefined;
|
|
258
|
+
const y = Number.isFinite(action.y) ? Math.round(action.y) : undefined;
|
|
259
|
+
if (x === undefined ||
|
|
260
|
+
y === undefined ||
|
|
261
|
+
x < 0 ||
|
|
262
|
+
y < 0 ||
|
|
263
|
+
x > observation.width ||
|
|
264
|
+
y > observation.height) {
|
|
265
|
+
return { action, refusal: 'click coordinates out of viewport' };
|
|
266
|
+
}
|
|
267
|
+
return { action: { ...action, x, y } };
|
|
268
|
+
}
|
|
269
|
+
default:
|
|
270
|
+
return { action, refusal: `unsupported action: ${action.action}` };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function executeExploreAction(actions, action) {
|
|
274
|
+
switch (action.action) {
|
|
275
|
+
case 'click':
|
|
276
|
+
return actions.click(action.x, action.y);
|
|
277
|
+
case 'type':
|
|
278
|
+
return actions.type(action.text ?? '');
|
|
279
|
+
case 'pressKeys':
|
|
280
|
+
return actions.pressKeys(action.keys ?? []);
|
|
281
|
+
case 'scroll':
|
|
282
|
+
return actions.scroll(action.dx ?? 0, action.dy ?? 0);
|
|
283
|
+
case 'wait':
|
|
284
|
+
return actions.wait(action.ms ?? 0);
|
|
285
|
+
case 'navigate':
|
|
286
|
+
return actions.navigate(action.url);
|
|
287
|
+
default:
|
|
288
|
+
return actions.wait(0);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Tolerant parse of the explore proposal, mirroring Engine's `_parseAction`
|
|
293
|
+
* shape: strict JSON first, the {"click": "(x,y)"} variant, then a bare
|
|
294
|
+
* coordinate extraction for specialist models. Returns undefined when
|
|
295
|
+
* nothing usable can be recovered — the caller counts it as a step.
|
|
296
|
+
*/
|
|
297
|
+
export function parseExploreAction(content) {
|
|
298
|
+
const coerce = (parsed) => {
|
|
299
|
+
const action = String(parsed.action ?? '');
|
|
300
|
+
if (!['click', 'type', 'pressKeys', 'scroll', 'wait', 'navigate', 'done', 'fail'].includes(action)) {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
let x = typeof parsed.x === 'number' ? parsed.x : undefined;
|
|
304
|
+
let y = typeof parsed.y === 'number' ? parsed.y : undefined;
|
|
305
|
+
if (x === undefined || y === undefined) {
|
|
306
|
+
const coord = content.match(/\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?/);
|
|
307
|
+
if (coord) {
|
|
308
|
+
x = Number(coord[1]);
|
|
309
|
+
y = Number(coord[2]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
action: action,
|
|
314
|
+
...(x !== undefined ? { x } : {}),
|
|
315
|
+
...(y !== undefined ? { y } : {}),
|
|
316
|
+
...(typeof parsed.text === 'string' ? { text: parsed.text } : {}),
|
|
317
|
+
...(Array.isArray(parsed.keys) ? { keys: parsed.keys.map((k) => String(k)) } : {}),
|
|
318
|
+
...(typeof parsed.dx === 'number' ? { dx: parsed.dx } : {}),
|
|
319
|
+
...(typeof parsed.dy === 'number' ? { dy: parsed.dy } : {}),
|
|
320
|
+
...(typeof parsed.ms === 'number' ? { ms: parsed.ms } : {}),
|
|
321
|
+
...(typeof parsed.url === 'string' ? { url: parsed.url } : {}),
|
|
322
|
+
reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : '',
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(content);
|
|
327
|
+
const variantKey = [
|
|
328
|
+
'click',
|
|
329
|
+
'type',
|
|
330
|
+
'pressKeys',
|
|
331
|
+
'scroll',
|
|
332
|
+
'wait',
|
|
333
|
+
'navigate',
|
|
334
|
+
'done',
|
|
335
|
+
'fail',
|
|
336
|
+
].find((k) => k in parsed);
|
|
337
|
+
if (parsed.action === undefined && variantKey !== undefined) {
|
|
338
|
+
const v = parsed[variantKey];
|
|
339
|
+
const out = { action: variantKey };
|
|
340
|
+
if (typeof v === 'object' && v !== null)
|
|
341
|
+
Object.assign(out, v);
|
|
342
|
+
else if (typeof v === 'string') {
|
|
343
|
+
const coord = v.match(/\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?/);
|
|
344
|
+
if (coord) {
|
|
345
|
+
out.x = Number(coord[1]);
|
|
346
|
+
out.y = Number(coord[2]);
|
|
347
|
+
}
|
|
348
|
+
else if (variantKey === 'navigate') {
|
|
349
|
+
out.url = v;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
out.text = v;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (typeof parsed.reasoning === 'string')
|
|
356
|
+
out.reasoning = parsed.reasoning;
|
|
357
|
+
return coerce(out);
|
|
358
|
+
}
|
|
359
|
+
return coerce(parsed);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
const coord = content.match(/\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)?/);
|
|
363
|
+
if (coord) {
|
|
364
|
+
return {
|
|
365
|
+
action: 'click',
|
|
366
|
+
x: Math.round(Number(coord[1])),
|
|
367
|
+
y: Math.round(Number(coord[2])),
|
|
368
|
+
reasoning: `coordinate-only response: ${content.slice(0, 120)}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
}
|
package/dist/engine/loop.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface VisionClient {
|
|
|
24
24
|
model: string;
|
|
25
25
|
}>;
|
|
26
26
|
}
|
|
27
|
-
export interface
|
|
27
|
+
export interface UiDriverApi {
|
|
28
28
|
click(x: number, y: number): Promise<Observation>;
|
|
29
29
|
type(text: string): Promise<Observation>;
|
|
30
30
|
pressKeys(keys: string[]): Promise<Observation>;
|
|
@@ -99,7 +99,7 @@ export declare class Engine {
|
|
|
99
99
|
get assertEntries(): CachedAssert[];
|
|
100
100
|
get visionCalls(): number;
|
|
101
101
|
get cacheStats(): CacheStats;
|
|
102
|
-
record(instruction: string,
|
|
102
|
+
record(instruction: string, uiApi?: UiDriverApi, options?: RecordOptions): Promise<RunResult>;
|
|
103
103
|
replay(flow: FlowCache, options?: ReplayOptions): Promise<RunResult>;
|
|
104
104
|
/**
|
|
105
105
|
* Resolve a single element for the `td.find()` DSL (R13). When `cached` is
|