teamshare-bridge 0.21.28 → 0.21.30
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/dist/cli/commands/browse.js +454 -152
- package/dist/cli/commands/browse.js.map +1 -1
- package/dist/cli/commands/research.js +3 -0
- package/dist/cli/commands/research.js.map +1 -1
- package/dist/lib/browser/screen-change.d.ts +43 -0
- package/dist/lib/browser/screen-change.js +122 -0
- package/dist/lib/browser/screen-change.js.map +1 -0
- package/package.json +1 -1
|
@@ -61,6 +61,7 @@ const launch_1 = require("../../lib/browser/launch");
|
|
|
61
61
|
const session_1 = require("../../lib/browser/session");
|
|
62
62
|
const personal_fields_1 = require("../../lib/browser/personal-fields");
|
|
63
63
|
const human_input_guard_1 = require("../../lib/browser/human-input-guard");
|
|
64
|
+
const screen_change_1 = require("../../lib/browser/screen-change");
|
|
64
65
|
/** Create a fingerprint from the current element list. */
|
|
65
66
|
function createFingerprint(elements) {
|
|
66
67
|
const groups = new Map();
|
|
@@ -116,12 +117,51 @@ function calculateChangeScore(prev, curr) {
|
|
|
116
117
|
}
|
|
117
118
|
return score;
|
|
118
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* True when the QUESTION SET changed - a new/removed question, different
|
|
122
|
+
* question text, or different option text. This is the signal to abort a stale
|
|
123
|
+
* LLM call / discard its answer. It deliberately ignores checked-state-only
|
|
124
|
+
* changes (a human answering) and low-level noise (timers, animations) because
|
|
125
|
+
* those never alter question text or the group set - so a countdown can't
|
|
126
|
+
* trigger a false "screen changed".
|
|
127
|
+
*/
|
|
128
|
+
function questionSetChanged(prev, curr) {
|
|
129
|
+
const prevMap = new Map(prev.map((fp) => [fp.groupId, fp]));
|
|
130
|
+
const currMap = new Map(curr.map((fp) => [fp.groupId, fp]));
|
|
131
|
+
for (const gid of currMap.keys())
|
|
132
|
+
if (!prevMap.has(gid))
|
|
133
|
+
return true;
|
|
134
|
+
for (const gid of prevMap.keys())
|
|
135
|
+
if (!currMap.has(gid))
|
|
136
|
+
return true;
|
|
137
|
+
for (const [gid, c] of currMap) {
|
|
138
|
+
const p = prevMap.get(gid);
|
|
139
|
+
if (!p)
|
|
140
|
+
continue;
|
|
141
|
+
if (p.questionText !== c.questionText)
|
|
142
|
+
return true;
|
|
143
|
+
if (p.options.join('|') !== c.options.join('|'))
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
119
148
|
/** Fast path (easy objective questions): small reasoning budget. */
|
|
120
149
|
const FAST_MAX_TOKENS = 2048;
|
|
121
150
|
/** Deep path (hard coding questions): enough tokens to emit a full solution. */
|
|
122
151
|
const DEEP_MAX_TOKENS = 16384;
|
|
123
|
-
/** Attempt-1 ceiling:
|
|
124
|
-
const FAST_ATTEMPT_TIMEOUT_MS =
|
|
152
|
+
/** Attempt-1 ceiling: most calls finish in 28-60s, so 120s avoids escalating. */
|
|
153
|
+
const FAST_ATTEMPT_TIMEOUT_MS = 120_000;
|
|
154
|
+
/**
|
|
155
|
+
* Streaming watchdogs. `firstToken` = nothing arrived at all (a silently
|
|
156
|
+
* stalled request); `idle` = the stream went quiet mid-answer. Tuned to match
|
|
157
|
+
* the attempt budget so a slow-but-working call is never killed early.
|
|
158
|
+
*/
|
|
159
|
+
const FAST_FIRST_TOKEN_TIMEOUT_MS = 60_000;
|
|
160
|
+
const FAST_IDLE_TIMEOUT_MS = 60_000;
|
|
161
|
+
const DEEP_FIRST_TOKEN_TIMEOUT_MS = 90_000;
|
|
162
|
+
const DEEP_IDLE_TIMEOUT_MS = 60_000;
|
|
163
|
+
/** Pause after an answering action, so the human can see/navigate (ms). */
|
|
164
|
+
const ANSWER_PAUSE_MS = 6_000;
|
|
125
165
|
/**
|
|
126
166
|
* Rule 0 is swapped per attempt: the fast path forbids long deliberation, the
|
|
127
167
|
* deep path (entered when the fast attempt fails) explicitly allows it so hard
|
|
@@ -316,90 +356,55 @@ async function instrumentPage(page) {
|
|
|
316
356
|
`);
|
|
317
357
|
}
|
|
318
358
|
catch { /* non-critical */ }
|
|
319
|
-
// Register CDP event: Page.frameNavigated (URL/navigation changes)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
359
|
+
// Register CDP event: Page.frameNavigated (URL/navigation changes). Bind
|
|
360
|
+
// ONCE per page object - instrumentPage also runs on re-attach.
|
|
361
|
+
//
|
|
362
|
+
// The MutationObserver is DOCUMENT-scoped: a full navigation wipes it. It used
|
|
363
|
+
// to be injected only here (attach time, frequently on about:blank), so after
|
|
364
|
+
// the human navigated to the real page the heartbeat's DOM-change path was
|
|
365
|
+
// dead for the rest of the run - a question swap during a 50-120s vision call
|
|
366
|
+
// went undetected and its stale answer was applied to the next screen. We now
|
|
367
|
+
// re-inject whenever the MAIN frame finishes navigating; subframe/iframe
|
|
368
|
+
// navigations (ads, widgets) are ignored.
|
|
369
|
+
if (!page._frameNavBound) {
|
|
370
|
+
page._frameNavBound = true;
|
|
371
|
+
onCdpEvent(page, 'Page.frameNavigated', (params) => {
|
|
372
|
+
if (!(0, screen_change_1.isMainFrameNavigation)(params))
|
|
373
|
+
return;
|
|
374
|
+
page._cdpNavigated = Date.now();
|
|
375
|
+
scheduleObserverReinject(page);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
await reinjectObserver(page);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Re-inject the document-scoped MutationObserver into the page's CURRENT
|
|
382
|
+
* document. Idempotent: the injected script early-returns when its Symbol key
|
|
383
|
+
* already exists. Must run again after every (main-frame) navigation.
|
|
384
|
+
*/
|
|
385
|
+
async function reinjectObserver(page) {
|
|
328
386
|
try {
|
|
329
|
-
await cdpEval(page,
|
|
330
|
-
(() => {
|
|
331
|
-
const KEY = Symbol('__ts_obs');
|
|
332
|
-
if (window[KEY]) return 'already attached';
|
|
333
|
-
const IGNORE = /timer|countdown|elapsed|clock|animation|animate|transition|spinner|progress|loading|toast|notification|alert|banner|snackbar|cookie|consent|modal|overlay|popup|tooltip|badge|tag|avatar|icon|svg|img|video|audio|canvas|font|stylesheet|script|meta|link|head/i;
|
|
334
|
-
let timer = null;
|
|
335
|
-
let lastText = '';
|
|
336
|
-
function getText() {
|
|
337
|
-
const els = document.querySelectorAll('input[type="radio"],input[type="checkbox"],input[type="text"],input[type="email"],input[type="password"],textarea,label,button');
|
|
338
|
-
return [...els].map(e => {
|
|
339
|
-
const t = (e.innerText || e.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 200);
|
|
340
|
-
return e.type + '|' + t + '|' + (e.checked || false);
|
|
341
|
-
}).join('\\n');
|
|
342
|
-
}
|
|
343
|
-
function check() {
|
|
344
|
-
try {
|
|
345
|
-
const curr = getText();
|
|
346
|
-
if (curr === lastText) return;
|
|
347
|
-
const prev = lastText;
|
|
348
|
-
lastText = curr;
|
|
349
|
-
const prevGroups = prev ? prev.split('\\n').filter(Boolean) : [];
|
|
350
|
-
const currGroups = curr.split('\\n').filter(Boolean);
|
|
351
|
-
const prevSet = new Set(prevGroups);
|
|
352
|
-
const currSet = new Set(currGroups);
|
|
353
|
-
let removed = 0, changed = 0;
|
|
354
|
-
for (const g of prevGroups) {
|
|
355
|
-
if (!currSet.has(g)) {
|
|
356
|
-
if (prevGroups.indexOf(g) < currGroups.length) changed++;
|
|
357
|
-
else removed++;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
const added = currGroups.filter(g => !prevSet.has(g)).length;
|
|
361
|
-
const score = added * 10 + removed * 10 + changed * 8;
|
|
362
|
-
if (score >= 15) {
|
|
363
|
-
window[KEY].changed = Date.now();
|
|
364
|
-
}
|
|
365
|
-
} catch {}
|
|
366
|
-
}
|
|
367
|
-
const obs = new MutationObserver((mutations) => {
|
|
368
|
-
let hasRelevant = false;
|
|
369
|
-
for (const m of mutations) {
|
|
370
|
-
if (m.target instanceof Element) {
|
|
371
|
-
const tag = m.target.tagName;
|
|
372
|
-
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'HEAD') continue;
|
|
373
|
-
const cls = m.target.className || '';
|
|
374
|
-
if (typeof cls === 'string' && IGNORE.test(cls)) continue;
|
|
375
|
-
const id = m.target.id || '';
|
|
376
|
-
if (IGNORE.test(id)) continue;
|
|
377
|
-
if (m.type === 'characterData' && m.target.parentElement) {
|
|
378
|
-
const ptag = m.target.parentElement.tagName;
|
|
379
|
-
if (ptag === 'SCRIPT' || ptag === 'STYLE') continue;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
hasRelevant = true;
|
|
383
|
-
}
|
|
384
|
-
if (hasRelevant) {
|
|
385
|
-
clearTimeout(timer);
|
|
386
|
-
timer = setTimeout(check, 500);
|
|
387
|
-
}
|
|
388
|
-
});
|
|
389
|
-
obs.observe(document.body || document.documentElement, {
|
|
390
|
-
childList: true, subtree: true, characterData: true
|
|
391
|
-
});
|
|
392
|
-
window[KEY] = { observer: obs, changed: 0 };
|
|
393
|
-
lastText = getText();
|
|
394
|
-
return 'ok';
|
|
395
|
-
})()
|
|
396
|
-
`);
|
|
387
|
+
await cdpEval(page, (0, screen_change_1.buildObserverScript)());
|
|
397
388
|
console.log('[browse] MutationObserver injected (stealth, debounced, filtered)');
|
|
398
389
|
}
|
|
399
390
|
catch (err) {
|
|
400
391
|
console.error(`[browse] MutationObserver injection failed: ${(0, config_1.msg)(err)}`);
|
|
401
392
|
}
|
|
402
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Debounced observer re-inject after a main-frame navigation: `frameNavigated`
|
|
396
|
+
* can fire before the new document is ready, and rapid redirects would
|
|
397
|
+
* otherwise inject several times.
|
|
398
|
+
*/
|
|
399
|
+
function scheduleObserverReinject(page) {
|
|
400
|
+
if (page._reinjectTimer)
|
|
401
|
+
clearTimeout(page._reinjectTimer);
|
|
402
|
+
page._reinjectTimer = setTimeout(() => {
|
|
403
|
+
page._reinjectTimer = undefined;
|
|
404
|
+
if (!page.disconnected)
|
|
405
|
+
void reinjectObserver(page);
|
|
406
|
+
}, 600);
|
|
407
|
+
}
|
|
403
408
|
async function cmdBrowse(flags) {
|
|
404
409
|
const autoAnswer = flags.get('auto-answer') === 'true';
|
|
405
410
|
if (!autoAnswer) {
|
|
@@ -464,16 +469,26 @@ async function cmdBrowse(flags) {
|
|
|
464
469
|
// the fast retry below abandons a slow easy answer at ~45s and escalates.
|
|
465
470
|
const timeoutSec = Math.min(Math.max(parseInt(flags.get('timeout') ?? '300', 10), 30), 600);
|
|
466
471
|
const timeoutMs = timeoutSec * 1000;
|
|
467
|
-
const quality = Math.min(Math.max(parseInt(flags.get('quality') ?? '
|
|
472
|
+
const quality = Math.min(Math.max(parseInt(flags.get('quality') ?? '82', 10), 50), 100);
|
|
468
473
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
474
|
+
* Screenshot detail: the viewport is captured at native resolution and only
|
|
475
|
+
* scaled down when BOTH sides exceed this many px. Default 1600 keeps
|
|
476
|
+
* question/option text crisp for the vision model; pass `--max-edge 0` for
|
|
477
|
+
* native full resolution, or a smaller value for speed. Clicks are unaffected
|
|
478
|
+
* (actions use element coordinates, not image pixels).
|
|
472
479
|
*/
|
|
473
|
-
const
|
|
480
|
+
const maxEdgeRaw = parseInt(flags.get('max-edge') ?? '1600', 10);
|
|
481
|
+
const SCREENSHOT_MAX_EDGE = Number.isFinite(maxEdgeRaw)
|
|
482
|
+
? Math.max(0, Math.min(maxEdgeRaw, 4096))
|
|
483
|
+
: 1600;
|
|
474
484
|
const maxConsecutiveFailures = parseInt(flags.get('max-consecutive-failures') ?? '0', 10); // 0 = unlimited
|
|
475
485
|
const charDelayMs = Math.min(Math.max(parseInt(flags.get('char-delay') ?? '150', 10), 5), 200);
|
|
476
486
|
const optimize = flags.has('optimize');
|
|
487
|
+
/**
|
|
488
|
+
* Redundancy pre-check off by default: it costs an EXTRA LLM round-trip per
|
|
489
|
+
* action (a big latency tax). Opt in with --verify-actions.
|
|
490
|
+
*/
|
|
491
|
+
const verifyActions = flags.has('verify-actions');
|
|
477
492
|
const stealth = flags.has('stealth');
|
|
478
493
|
const hybrid = flags.has('hybrid');
|
|
479
494
|
const autoLaunch = flags.has('auto-launch');
|
|
@@ -577,19 +592,28 @@ async function cmdBrowse(flags) {
|
|
|
577
592
|
/** A 401/403 from the model API: terminal, retrying cannot succeed. */
|
|
578
593
|
let fatalAuthError = false;
|
|
579
594
|
const maxRetries = 5;
|
|
595
|
+
/**
|
|
596
|
+
* Click health. Some custom widgets ignore synthetic CDP mouse events; we
|
|
597
|
+
* detect "no visible change" and retry with a real DOM click, then cool the
|
|
598
|
+
* element down (it is still retried on a later pass).
|
|
599
|
+
*/
|
|
600
|
+
const clickFailures = new Map();
|
|
601
|
+
const clickCooldown = new Map();
|
|
602
|
+
/** "done but unanswered" verification retries for the current screen. */
|
|
603
|
+
let doneRetries = 0;
|
|
580
604
|
/** How often to re-check which tab is active (ms). */
|
|
581
605
|
const TAB_CHECK_MS = 2000;
|
|
582
606
|
let lastTabCheck = 0;
|
|
583
607
|
let consecutiveScreenshotFailures = 0;
|
|
584
608
|
let reattachFailures = 0;
|
|
585
609
|
// Latency observability: rolling average of vision-call latency plus
|
|
586
|
-
// abort/timeout counts, so the operator can see how slow the model is.
|
|
587
|
-
const llmStats = { calls: 0, ok: 0, screenAborts: 0, timeouts: 0, totalMs: 0 };
|
|
610
|
+
// abort/timeout/stall counts, so the operator can see how slow the model is.
|
|
611
|
+
const llmStats = { calls: 0, ok: 0, screenAborts: 0, timeouts: 0, stalls: 0, totalMs: 0 };
|
|
588
612
|
const logLlmStats = () => {
|
|
589
613
|
if (llmStats.calls === 0 || llmStats.calls % 5 !== 0)
|
|
590
614
|
return;
|
|
591
615
|
const avg = Math.round(llmStats.totalMs / llmStats.calls / 1000);
|
|
592
|
-
console.log(`[browse] LLM latency: avg ${avg}s over ${llmStats.calls} call(s) — ${llmStats.ok} ok, ${llmStats.screenAborts} screen-abort, ${llmStats.timeouts} timeout`);
|
|
616
|
+
console.log(`[browse] LLM latency: avg ${avg}s over ${llmStats.calls} call(s) — ${llmStats.ok} ok, ${llmStats.screenAborts} screen-abort, ${llmStats.timeouts} timeout, ${llmStats.stalls} stall`);
|
|
593
617
|
};
|
|
594
618
|
/**
|
|
595
619
|
* (Re)attach to a tab. A closed or switched tab used to leave the loop
|
|
@@ -686,10 +710,14 @@ async function cmdBrowse(flags) {
|
|
|
686
710
|
const currentFingerprint = createFingerprint(elements);
|
|
687
711
|
if (lastFingerprint) {
|
|
688
712
|
const changeScore = calculateChangeScore(lastFingerprint, currentFingerprint);
|
|
689
|
-
if (changeScore >= 15) {
|
|
713
|
+
if (questionSetChanged(lastFingerprint, currentFingerprint) || changeScore >= 15) {
|
|
690
714
|
console.log(`[browse] Screen changed (score: ${changeScore}) — resetting state`);
|
|
691
715
|
codeConfirmedOptimal = false;
|
|
692
716
|
consecutiveFailures = 0;
|
|
717
|
+
// A new screen resets per-screen bookkeeping.
|
|
718
|
+
doneRetries = 0;
|
|
719
|
+
clickFailures.clear();
|
|
720
|
+
clickCooldown.clear();
|
|
693
721
|
}
|
|
694
722
|
}
|
|
695
723
|
lastFingerprint = currentFingerprint;
|
|
@@ -702,20 +730,17 @@ async function cmdBrowse(flags) {
|
|
|
702
730
|
/** The page changed under the LLM: re-analyze immediately, no backoff. */
|
|
703
731
|
let screenChangedDuringLlm = false;
|
|
704
732
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
705
|
-
// Fast-first ladder: attempt 1 uses a small budget +
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
733
|
+
// Fast-first ladder: attempt 1 uses a small token budget; attempts 2+
|
|
734
|
+
// switch to the DEEP budget (full timeout + tokens) for hard coding
|
|
735
|
+
// questions. Both use the SAME full-detail screenshot (maxEdge/quality)
|
|
736
|
+
// so text is always legible; `max_tokens`/timeout are ceilings, so the
|
|
709
737
|
// deep path never slows a question that answers on the fast path.
|
|
710
738
|
const deep = attempt > 1;
|
|
711
739
|
const attemptTimeoutMs = deep ? timeoutMs : Math.min(FAST_ATTEMPT_TIMEOUT_MS, timeoutMs);
|
|
712
740
|
if (deep && attempt === 2) {
|
|
713
741
|
console.log('[browse] Fast attempt did not answer — escalating to the deep budget (more time + tokens)');
|
|
714
742
|
}
|
|
715
|
-
|
|
716
|
-
? ((await captureScreenshot(page, quality)) ?? screenshot)
|
|
717
|
-
: screenshot;
|
|
718
|
-
result = await analyzeScreenshot(baseUrl, apiKey, model, attemptShot, elements, pageContext, attemptTimeoutMs, attempt, maxRetries, page, lastFingerprint, { maxTokens: deep ? DEEP_MAX_TOKENS : FAST_MAX_TOKENS, deep });
|
|
743
|
+
result = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, attemptTimeoutMs, attempt, maxRetries, page, lastFingerprint, { maxTokens: deep ? DEEP_MAX_TOKENS : FAST_MAX_TOKENS, deep });
|
|
719
744
|
llmCallCount++;
|
|
720
745
|
llmStats.calls++;
|
|
721
746
|
llmStats.totalMs += result.durationMs;
|
|
@@ -793,8 +818,12 @@ async function cmdBrowse(flags) {
|
|
|
793
818
|
const freshFingerprint = createFingerprint(freshElements);
|
|
794
819
|
if (lastFingerprint) {
|
|
795
820
|
const changeScore = calculateChangeScore(lastFingerprint, freshFingerprint);
|
|
796
|
-
|
|
797
|
-
|
|
821
|
+
// Discard a (costly) LLM answer when the QUESTION SET changed under
|
|
822
|
+
// it (its answer is stale), or on a material whole-screen change.
|
|
823
|
+
// A pure question-text swap scores only 8 on the generic scale, so
|
|
824
|
+
// the targeted check catches what the score alone missed.
|
|
825
|
+
if (questionSetChanged(lastFingerprint, freshFingerprint) || changeScore >= 40) {
|
|
826
|
+
console.log(`[browse] Question set changed during LLM call (score: ${changeScore}) — skipping actions, re-analyzing`);
|
|
798
827
|
lastFingerprint = freshFingerprint;
|
|
799
828
|
lastScreenshot = freshScreenshot;
|
|
800
829
|
await sleep(intervalMs);
|
|
@@ -815,28 +844,33 @@ async function cmdBrowse(flags) {
|
|
|
815
844
|
let wroteCode = false;
|
|
816
845
|
for (const act of result.actions) {
|
|
817
846
|
if (act.action === 'done') {
|
|
818
|
-
// Validate: are there actually unanswered questions?
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
executedAny =
|
|
847
|
+
// Validate: are there actually unanswered questions? (The heuristic is
|
|
848
|
+
// authoritative — a "done" while questions are open means the earlier
|
|
849
|
+
// clicks did not register, NOT that the gate is wrong.)
|
|
850
|
+
if (hasUnansweredQuestions(elements) && doneRetries < 1) {
|
|
851
|
+
doneRetries++;
|
|
852
|
+
console.log(`[browse] LLM says done but questions remain unanswered — asking once more`);
|
|
853
|
+
const followUp = await analyzeScreenshot(baseUrl, apiKey, model, screenshot, elements, pageContext, timeoutMs, 1, 1, page, lastFingerprint,
|
|
854
|
+
// Fast budget: this is a short "act now" nudge, not a hard problem.
|
|
855
|
+
{ maxTokens: FAST_MAX_TOKENS, deep: false });
|
|
856
|
+
const followActions = followUp.actions.filter((a) => a.action !== 'done');
|
|
857
|
+
if (followActions.length > 0) {
|
|
858
|
+
// Execute the follow-up actions here — the old code reassigned
|
|
859
|
+
// `result` inside the for-of, so these were silently discarded and
|
|
860
|
+
// the loop spun on the same unanswered question forever.
|
|
861
|
+
console.log(`[browse] Follow-up: executing ${followActions.length} action(s)`);
|
|
862
|
+
for (const fa of followActions) {
|
|
863
|
+
await (hybrid ? executeActionHybrid : executeAction)(page, fa, elements, charDelayMs);
|
|
864
|
+
actionCount++;
|
|
865
|
+
executedAny = true;
|
|
866
|
+
await sleep(ANSWER_PAUSE_MS);
|
|
837
867
|
}
|
|
868
|
+
continue; // re-analyze the screen after acting
|
|
838
869
|
}
|
|
839
|
-
|
|
870
|
+
console.log(`[browse] LLM confirms done after verification`);
|
|
871
|
+
}
|
|
872
|
+
else if (hasUnansweredQuestions(elements)) {
|
|
873
|
+
console.log(`[browse] Questions still open after verification — leaving them for the model/next screen`);
|
|
840
874
|
}
|
|
841
875
|
// All questions genuinely answered
|
|
842
876
|
if (continuous && !executedAny) {
|
|
@@ -877,8 +911,10 @@ async function cmdBrowse(flags) {
|
|
|
877
911
|
continue;
|
|
878
912
|
}
|
|
879
913
|
}
|
|
880
|
-
//
|
|
881
|
-
const redundancy =
|
|
914
|
+
// Redundancy pre-check (extra LLM round-trip) — only when opted in.
|
|
915
|
+
const redundancy = verifyActions
|
|
916
|
+
? await isActionRedundant(baseUrl, apiKey, model, page, elements, act, timeoutMs)
|
|
917
|
+
: { decision: 'proceed' };
|
|
882
918
|
if (redundancy.decision === 'skip') {
|
|
883
919
|
continue;
|
|
884
920
|
}
|
|
@@ -975,6 +1011,15 @@ async function cmdBrowse(flags) {
|
|
|
975
1011
|
else {
|
|
976
1012
|
console.log(`[browse] Action: ${act.action} - ${act.reasoning || ''}`);
|
|
977
1013
|
}
|
|
1014
|
+
// Element cooled down after repeated non-responsive clicks: skip it for
|
|
1015
|
+
// this pass (it is retried on a later pass).
|
|
1016
|
+
if (act.action === 'click' && act.elementIndex !== undefined) {
|
|
1017
|
+
const coolUntil = clickCooldown.get(act.elementIndex) ?? 0;
|
|
1018
|
+
if (coolUntil > Date.now()) {
|
|
1019
|
+
console.log(`[browse] Skipping element ${act.elementIndex} — no response to earlier clicks (will retry later)`);
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
978
1023
|
// Human-like hesitation before acting (200-600ms)
|
|
979
1024
|
if (act.action === 'click' || act.action === 'type' || act.action === 'write_code') {
|
|
980
1025
|
await sleep(200 + Math.random() * 400);
|
|
@@ -985,9 +1030,42 @@ async function cmdBrowse(flags) {
|
|
|
985
1030
|
if (act.action === 'write_code' || act.action === 'type') {
|
|
986
1031
|
wroteCode = true;
|
|
987
1032
|
}
|
|
988
|
-
//
|
|
1033
|
+
// Verify a click actually changed something. Some custom widgets ignore
|
|
1034
|
+
// synthetic CDP mouse events, which is why a question stayed "unanswered"
|
|
1035
|
+
// no matter how often the model clicked it. Retry with a real DOM click,
|
|
1036
|
+
// then cool the element down after 3 fruitless attempts (retried later).
|
|
1037
|
+
if (act.action === 'click' && act.elementIndex !== undefined) {
|
|
1038
|
+
const idx = act.elementIndex;
|
|
1039
|
+
await sleep(400);
|
|
1040
|
+
try {
|
|
1041
|
+
const after = await extractElements(page);
|
|
1042
|
+
const score = calculateChangeScore(createFingerprint(elements), createFingerprint(after));
|
|
1043
|
+
if (score < 15) {
|
|
1044
|
+
const n = (clickFailures.get(idx) ?? 0) + 1;
|
|
1045
|
+
clickFailures.set(idx, n);
|
|
1046
|
+
console.log(`[browse] Click on element ${idx} produced no visible change (${n}/3)`);
|
|
1047
|
+
const el = elements.find((e) => e.i === idx);
|
|
1048
|
+
if (el && n <= 2) {
|
|
1049
|
+
const ok = await domClickAt(page, el.x, el.y);
|
|
1050
|
+
console.log(`[browse] DOM-click fallback on element ${idx}: ${ok ? 'dispatched' : 'failed'}`);
|
|
1051
|
+
}
|
|
1052
|
+
else if (el && n >= 3) {
|
|
1053
|
+
clickCooldown.set(idx, Date.now() + 30_000);
|
|
1054
|
+
clickFailures.delete(idx);
|
|
1055
|
+
console.log(`[browse] Element ${idx} not responding — skipping for 30s`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
clickFailures.delete(idx);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
/* verification is best-effort */
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
// Pause after answering so the human can read/navigate (was 2s: too short).
|
|
989
1067
|
if (act.action === 'click' || act.action === 'type' || act.action === 'write_code') {
|
|
990
|
-
await sleep(
|
|
1068
|
+
await sleep(ANSWER_PAUSE_MS);
|
|
991
1069
|
}
|
|
992
1070
|
}
|
|
993
1071
|
// Optimization pass: if --optimize and code was written, ask LLM to optimize it
|
|
@@ -1559,6 +1637,32 @@ async function extractElements(page) {
|
|
|
1559
1637
|
return [];
|
|
1560
1638
|
}
|
|
1561
1639
|
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Fallback click: dispatch a REAL DOM click at (x, y) via
|
|
1642
|
+
* document.elementFromPoint + the nearest interactive ancestor. Custom widgets
|
|
1643
|
+
* that ignore synthetic CDP mouse events usually respond to this.
|
|
1644
|
+
*/
|
|
1645
|
+
async function domClickAt(page, x, y) {
|
|
1646
|
+
try {
|
|
1647
|
+
const r = await cdpEval(page, `
|
|
1648
|
+
(() => {
|
|
1649
|
+
const el = document.elementFromPoint(${Math.round(x)}, ${Math.round(y)});
|
|
1650
|
+
if (!el) return false;
|
|
1651
|
+
const t = (el.closest && el.closest('button,label,a,[role],[onclick],input,li,span')) || el;
|
|
1652
|
+
try { if (t.focus) t.focus(); } catch (e) {}
|
|
1653
|
+
try {
|
|
1654
|
+
if (typeof t.click === 'function') { t.click(); return true; }
|
|
1655
|
+
} catch (e) {}
|
|
1656
|
+
t.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
|
1657
|
+
return true;
|
|
1658
|
+
})()
|
|
1659
|
+
`);
|
|
1660
|
+
return r?.result?.value === true;
|
|
1661
|
+
}
|
|
1662
|
+
catch {
|
|
1663
|
+
return false;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1562
1666
|
async function executeAction(page, act, elements, charDelayMs) {
|
|
1563
1667
|
try {
|
|
1564
1668
|
switch (act.action) {
|
|
@@ -2519,6 +2623,26 @@ async function extractPageContext(page) {
|
|
|
2519
2623
|
break;
|
|
2520
2624
|
}
|
|
2521
2625
|
}
|
|
2626
|
+
// FULL PAGE CONTENT (2026-09): the model must be fully informed from the
|
|
2627
|
+
// PAGE TEXT, not just the screenshot or the element list. Prefer the main
|
|
2628
|
+
// content region, else the body; collapse whitespace and cap the size.
|
|
2629
|
+
try {
|
|
2630
|
+
const root =
|
|
2631
|
+
document.querySelector('main, article, [role="main"]') || document.body;
|
|
2632
|
+
let text = (root && (root.innerText || root.textContent)) || '';
|
|
2633
|
+
text = text
|
|
2634
|
+
.replace(/[ \\t\\u00a0]+/g, ' ')
|
|
2635
|
+
.replace(/\\n\\s*\\n+/g, '\\n')
|
|
2636
|
+
.trim();
|
|
2637
|
+
if (text) {
|
|
2638
|
+
const CAP = 12000;
|
|
2639
|
+
ctx.push('Page content:');
|
|
2640
|
+
ctx.push(text.slice(0, CAP));
|
|
2641
|
+
if (text.length > CAP) {
|
|
2642
|
+
ctx.push('... (page content truncated at ' + CAP + ' chars)');
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
} catch (e) { /* best-effort */ }
|
|
2522
2646
|
return ctx.join('\\n');
|
|
2523
2647
|
})()
|
|
2524
2648
|
`;
|
|
@@ -2740,6 +2864,169 @@ function extractScatteredActions(str) {
|
|
|
2740
2864
|
}
|
|
2741
2865
|
return actions;
|
|
2742
2866
|
}
|
|
2867
|
+
/**
|
|
2868
|
+
* Extract the model text from a non-streaming chat-completions JSON body
|
|
2869
|
+
* (handles content arrays and reasoning_content on reasoning models).
|
|
2870
|
+
*/
|
|
2871
|
+
function extractModelText(json) {
|
|
2872
|
+
let content = json?.choices?.[0]?.message?.content;
|
|
2873
|
+
if (Array.isArray(content)) {
|
|
2874
|
+
content = content.filter((c) => c?.type === 'text').map((c) => c.text).join('');
|
|
2875
|
+
}
|
|
2876
|
+
if (typeof content !== 'string' || !content) {
|
|
2877
|
+
const reasoning = json?.choices?.[0]?.message?.reasoning_content;
|
|
2878
|
+
if (typeof reasoning === 'string' && reasoning)
|
|
2879
|
+
content = reasoning;
|
|
2880
|
+
}
|
|
2881
|
+
return typeof content === 'string' ? content : '';
|
|
2882
|
+
}
|
|
2883
|
+
/**
|
|
2884
|
+
* POST /chat/completions with `stream: true` and read the SSE body.
|
|
2885
|
+
*
|
|
2886
|
+
* Non-streaming never resolves until the provider finishes the whole answer, so
|
|
2887
|
+
* a long reasoning response looked like a 150s+ "hang" with zero feedback. With
|
|
2888
|
+
* streaming we see the first token, log its latency, and can catch a genuinely
|
|
2889
|
+
* stalled request (no bytes on the wire at all). `: PROCESSING` keepalives are
|
|
2890
|
+
* NOT tokens but DO prove the connection is alive, so they reset the watchdog;
|
|
2891
|
+
* only real wire silence trips it.
|
|
2892
|
+
*
|
|
2893
|
+
* The caller's `signal` (screen-change / overall timeout) still aborts this.
|
|
2894
|
+
*/
|
|
2895
|
+
async function streamChatCompletion(opts) {
|
|
2896
|
+
const ac = new AbortController();
|
|
2897
|
+
const forwardAbort = () => ac.abort();
|
|
2898
|
+
if (opts.signal.aborted)
|
|
2899
|
+
ac.abort();
|
|
2900
|
+
else
|
|
2901
|
+
opts.signal.addEventListener('abort', forwardAbort, { once: true });
|
|
2902
|
+
let stalled = null;
|
|
2903
|
+
let sawToken = false;
|
|
2904
|
+
let watchdog = null;
|
|
2905
|
+
const start = Date.now();
|
|
2906
|
+
/**
|
|
2907
|
+
* Any bytes on the wire (including `: PROCESSING` keepalive comments) prove
|
|
2908
|
+
* the connection is alive, so they RESET the watchdog. This provider sends
|
|
2909
|
+
* keepalives every few seconds and only emits real tokens much later - the
|
|
2910
|
+
* old watchdog ignored comments and killed a healthy request at 60s
|
|
2911
|
+
* ("LLM stalled (no token received)" while the model was actively working).
|
|
2912
|
+
* The stall now means genuine wire silence: no bytes at all.
|
|
2913
|
+
*/
|
|
2914
|
+
const arm = () => {
|
|
2915
|
+
if (watchdog)
|
|
2916
|
+
clearTimeout(watchdog);
|
|
2917
|
+
watchdog = setTimeout(() => {
|
|
2918
|
+
stalled = sawToken ? 'idle' : 'first-token';
|
|
2919
|
+
ac.abort();
|
|
2920
|
+
}, sawToken ? opts.idleTimeoutMs : opts.firstTokenTimeoutMs);
|
|
2921
|
+
};
|
|
2922
|
+
const cleanup = () => {
|
|
2923
|
+
if (watchdog)
|
|
2924
|
+
clearTimeout(watchdog);
|
|
2925
|
+
watchdog = null;
|
|
2926
|
+
opts.signal.removeEventListener('abort', forwardAbort);
|
|
2927
|
+
};
|
|
2928
|
+
arm();
|
|
2929
|
+
try {
|
|
2930
|
+
const res = await fetch(opts.url, {
|
|
2931
|
+
method: 'POST',
|
|
2932
|
+
signal: ac.signal,
|
|
2933
|
+
headers: {
|
|
2934
|
+
'Content-Type': 'application/json',
|
|
2935
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
2936
|
+
'api-key': opts.apiKey,
|
|
2937
|
+
Accept: 'text/event-stream',
|
|
2938
|
+
},
|
|
2939
|
+
body: JSON.stringify(opts.body),
|
|
2940
|
+
});
|
|
2941
|
+
if (!res.ok) {
|
|
2942
|
+
const errorText = await res.text().catch(() => '');
|
|
2943
|
+
cleanup();
|
|
2944
|
+
return { kind: 'http', status: res.status, errorText };
|
|
2945
|
+
}
|
|
2946
|
+
const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
|
|
2947
|
+
// Provider ignored stream:true (plain JSON) — parse it whole.
|
|
2948
|
+
if (!contentType.includes('event-stream') || !res.body) {
|
|
2949
|
+
const json = await res.json().catch(() => null);
|
|
2950
|
+
cleanup();
|
|
2951
|
+
return { kind: 'ok', content: extractModelText(json) };
|
|
2952
|
+
}
|
|
2953
|
+
const reader = res.body.getReader();
|
|
2954
|
+
const decoder = new TextDecoder();
|
|
2955
|
+
let buffer = '';
|
|
2956
|
+
let content = '';
|
|
2957
|
+
let reasoning = '';
|
|
2958
|
+
for (;;) {
|
|
2959
|
+
const { done, value } = await reader.read();
|
|
2960
|
+
if (done)
|
|
2961
|
+
break;
|
|
2962
|
+
// Any bytes at all (comment keepalives included) = the connection is
|
|
2963
|
+
// alive, so reset the stall watchdog.
|
|
2964
|
+
arm();
|
|
2965
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2966
|
+
let nl;
|
|
2967
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
2968
|
+
const raw = buffer.slice(0, nl).trim();
|
|
2969
|
+
buffer = buffer.slice(nl + 1);
|
|
2970
|
+
if (!raw || raw.startsWith(':'))
|
|
2971
|
+
continue; // ": PROCESSING" keepalive
|
|
2972
|
+
if (!raw.startsWith('data:'))
|
|
2973
|
+
continue;
|
|
2974
|
+
const data = raw.slice(5).trim();
|
|
2975
|
+
if (data === '[DONE]') {
|
|
2976
|
+
cleanup();
|
|
2977
|
+
return { kind: 'ok', content: content || reasoning };
|
|
2978
|
+
}
|
|
2979
|
+
let j;
|
|
2980
|
+
try {
|
|
2981
|
+
j = JSON.parse(data);
|
|
2982
|
+
}
|
|
2983
|
+
catch {
|
|
2984
|
+
continue;
|
|
2985
|
+
}
|
|
2986
|
+
const delta = j?.choices?.[0]?.delta;
|
|
2987
|
+
const dc = delta?.content;
|
|
2988
|
+
const dr = delta?.reasoning_content;
|
|
2989
|
+
if (typeof dc === 'string' && dc) {
|
|
2990
|
+
content += dc;
|
|
2991
|
+
if (!sawToken) {
|
|
2992
|
+
sawToken = true;
|
|
2993
|
+
opts.onFirstToken?.(Date.now() - start);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
else if (Array.isArray(dc)) {
|
|
2997
|
+
const t = dc.map((x) => x?.text ?? '').join('');
|
|
2998
|
+
if (t) {
|
|
2999
|
+
content += t;
|
|
3000
|
+
if (!sawToken) {
|
|
3001
|
+
sawToken = true;
|
|
3002
|
+
opts.onFirstToken?.(Date.now() - start);
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
if (typeof dr === 'string' && dr) {
|
|
3007
|
+
reasoning += dr;
|
|
3008
|
+
if (!sawToken) {
|
|
3009
|
+
sawToken = true;
|
|
3010
|
+
opts.onFirstToken?.(Date.now() - start);
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
cleanup();
|
|
3016
|
+
return { kind: 'ok', content: content || reasoning };
|
|
3017
|
+
}
|
|
3018
|
+
catch (err) {
|
|
3019
|
+
cleanup();
|
|
3020
|
+
if (stalled) {
|
|
3021
|
+
return {
|
|
3022
|
+
kind: 'stalled',
|
|
3023
|
+
phase: stalled,
|
|
3024
|
+
elapsedMs: Date.now() - start,
|
|
3025
|
+
};
|
|
3026
|
+
}
|
|
3027
|
+
throw err; // caller abort (screen change / timeout) or network error
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
2743
3030
|
async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, elements, pageContext, timeoutMs, attempt, maxAttempts, page, lastFingerprint, budget = {
|
|
2744
3031
|
maxTokens: DEEP_MAX_TOKENS,
|
|
2745
3032
|
deep: false,
|
|
@@ -2822,7 +3109,7 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
|
|
|
2822
3109
|
],
|
|
2823
3110
|
temperature: 0.1,
|
|
2824
3111
|
max_tokens: budget.maxTokens,
|
|
2825
|
-
stream:
|
|
3112
|
+
stream: true,
|
|
2826
3113
|
};
|
|
2827
3114
|
const controller = new AbortController();
|
|
2828
3115
|
// Distinguish a genuine timeout from the heartbeat cancelling a stale request
|
|
@@ -2835,6 +3122,7 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
|
|
|
2835
3122
|
controller.abort();
|
|
2836
3123
|
}, timeoutMs);
|
|
2837
3124
|
// Heartbeat: check for screen changes every 5s, abort LLM if page changed
|
|
3125
|
+
let heartbeatTicks = 0;
|
|
2838
3126
|
const heartbeat = setInterval(async () => {
|
|
2839
3127
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
2840
3128
|
// Check for screen change via CDP navigation event or MutationObserver flag
|
|
@@ -2871,14 +3159,26 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
|
|
|
2871
3159
|
}
|
|
2872
3160
|
catch { /* page disconnected */ }
|
|
2873
3161
|
}
|
|
2874
|
-
|
|
3162
|
+
// Fallback (2026-09): the in-page observer is document-scoped (re-injected on
|
|
3163
|
+
// navigation, but a same-document SPA swap may not have ticked it yet) and a
|
|
3164
|
+
// continuously-mutating page can starve its debounce. So every ~10s verify the
|
|
3165
|
+
// fingerprint directly even when no flag fired - a question swap during a
|
|
3166
|
+
// 50-120s vision call must never slip through unnoticed.
|
|
3167
|
+
heartbeatTicks++;
|
|
3168
|
+
const periodicCheck = !screenChanged && heartbeatTicks % 2 === 0;
|
|
3169
|
+
if ((screenChanged || periodicCheck) && lastFingerprint && page) {
|
|
2875
3170
|
// Verify with fingerprint comparison before aborting
|
|
2876
3171
|
try {
|
|
2877
3172
|
const freshElements = await extractElements(page);
|
|
2878
3173
|
const freshFingerprint = createFingerprint(freshElements);
|
|
2879
3174
|
const changeScore = calculateChangeScore(lastFingerprint, freshFingerprint);
|
|
2880
|
-
|
|
2881
|
-
|
|
3175
|
+
// Abort on a real question change even when the aggregate score is below
|
|
3176
|
+
// the generic threshold (a pure question-text swap scores only 8), while
|
|
3177
|
+
// still ignoring checked-only / timer noise.
|
|
3178
|
+
if (questionSetChanged(lastFingerprint, freshFingerprint) || changeScore >= 15) {
|
|
3179
|
+
console.log(screenChanged
|
|
3180
|
+
? `[browse] Fingerprint confirms change (score: ${changeScore}) — aborting LLM (${elapsed}s)`
|
|
3181
|
+
: `[browse] Periodic fingerprint found change (score: ${changeScore}) — aborting LLM (${elapsed}s)`);
|
|
2882
3182
|
abortReason = 'screen_change';
|
|
2883
3183
|
controller.abort();
|
|
2884
3184
|
return;
|
|
@@ -2895,22 +3195,27 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
|
|
|
2895
3195
|
const contextKB = Math.round(pageContext.length / 1024);
|
|
2896
3196
|
console.log(`[browse] LLM request: ${model} | ${screenshotKB}KB image | ${contextKB}KB context | ${elements.length} elements | timeout ${Math.round(timeoutMs / 1000)}s | attempt ${attempt}/${maxAttempts}`);
|
|
2897
3197
|
try {
|
|
2898
|
-
const
|
|
2899
|
-
|
|
3198
|
+
const outcome = await streamChatCompletion({
|
|
3199
|
+
url: `${baseUrl.replace(/\/$/, '')}/chat/completions`,
|
|
3200
|
+
apiKey,
|
|
3201
|
+
body,
|
|
2900
3202
|
signal: controller.signal,
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
},
|
|
2906
|
-
body: JSON.stringify(body),
|
|
3203
|
+
firstTokenTimeoutMs: budget.deep
|
|
3204
|
+
? DEEP_FIRST_TOKEN_TIMEOUT_MS
|
|
3205
|
+
: FAST_FIRST_TOKEN_TIMEOUT_MS,
|
|
3206
|
+
idleTimeoutMs: budget.deep ? DEEP_IDLE_TIMEOUT_MS : FAST_IDLE_TIMEOUT_MS,
|
|
3207
|
+
onFirstToken: (ms) => console.log(`[browse] first token in ${Math.round(ms / 1000)}s (${budget.deep ? 'deep' : 'fast'})`),
|
|
2907
3208
|
});
|
|
2908
|
-
if (
|
|
2909
|
-
|
|
2910
|
-
|
|
3209
|
+
if (outcome.kind === 'stalled') {
|
|
3210
|
+
console.error(`[browse] LLM stalled (${outcome.phase === 'first-token' ? 'no token received' : 'stream went idle'}) after ${Math.round((outcome.elapsedMs ?? 0) / 1000)}s — aborting`);
|
|
3211
|
+
return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'stalled' };
|
|
3212
|
+
}
|
|
3213
|
+
if (outcome.kind === 'http') {
|
|
3214
|
+
const status = outcome.status ?? 0;
|
|
3215
|
+
console.error(`[browse] LLM HTTP ${status}: ${(outcome.errorText ?? '').slice(0, 200)}`);
|
|
2911
3216
|
// 401/403 will never succeed on retry — surface a clear, actionable
|
|
2912
3217
|
// diagnosis instead of burning 5 attempts x N rounds.
|
|
2913
|
-
if (
|
|
3218
|
+
if (status === 401 || status === 403) {
|
|
2914
3219
|
console.error(`[browse] Model auth failed for ${baseUrl} (model ${model}). The API key is invalid, ` +
|
|
2915
3220
|
`expired, or belongs to a different provider. Fix the provider key on the run form ` +
|
|
2916
3221
|
`(or the agent's provider key) and re-run.`);
|
|
@@ -2918,19 +3223,8 @@ async function analyzeScreenshot(baseUrl, apiKey, model, screenshotBase64, eleme
|
|
|
2918
3223
|
}
|
|
2919
3224
|
return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'http_error' };
|
|
2920
3225
|
}
|
|
2921
|
-
const
|
|
2922
|
-
|
|
2923
|
-
if (Array.isArray(content)) {
|
|
2924
|
-
content = content.filter((c) => c.type === 'text').map((c) => c.text).join('');
|
|
2925
|
-
}
|
|
2926
|
-
// Reasoning models (MiMo) put thinking in reasoning_content; fall back to it
|
|
2927
|
-
if (typeof content !== 'string' || !content) {
|
|
2928
|
-
const reasoning = json.choices?.[0]?.message?.reasoning_content;
|
|
2929
|
-
if (typeof reasoning === 'string' && reasoning) {
|
|
2930
|
-
content = reasoning;
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
if (typeof content !== 'string' || !content) {
|
|
3226
|
+
const content = outcome.content ?? '';
|
|
3227
|
+
if (!content) {
|
|
2934
3228
|
console.error('[browse] Empty content from LLM');
|
|
2935
3229
|
return { actions: [], attempt, durationMs: Date.now() - startTime, error: 'empty' };
|
|
2936
3230
|
}
|
|
@@ -2993,9 +3287,17 @@ async function analyzeScreenshotWithPrompt(baseUrl, apiKey, model, screenshotBas
|
|
|
2993
3287
|
optAbortReason = 'timeout';
|
|
2994
3288
|
controller.abort();
|
|
2995
3289
|
}, timeoutMs);
|
|
2996
|
-
// Heartbeat: check for screen changes every 5s, abort if page changed
|
|
3290
|
+
// Heartbeat: check for screen changes every 5s, abort if page changed.
|
|
3291
|
+
// The observer flag lives in the current document, so any navigation (which
|
|
3292
|
+
// wipes it) must also abort - check `_cdpNavigated` too, as in the main call.
|
|
2997
3293
|
const heartbeat = setInterval(async () => {
|
|
2998
3294
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
3295
|
+
if (page?._cdpNavigated && page._cdpNavigated > startTime) {
|
|
3296
|
+
console.log(`[browse] CDP navigation detected in opt pass (${elapsed}s) — aborting`);
|
|
3297
|
+
optAbortReason = 'screen_change';
|
|
3298
|
+
controller.abort();
|
|
3299
|
+
return;
|
|
3300
|
+
}
|
|
2999
3301
|
try {
|
|
3000
3302
|
const flagResult = await cdpEval(page, `
|
|
3001
3303
|
(() => {
|