@enricai/barnacle 1.6.14 → 1.7.1
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 +3 -5
- package/dist/api/schemas/submissions.d.ts +8 -8
- package/dist/config.d.ts +23 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/lib/bedrock.d.ts +2 -2
- package/dist/lib/bedrock.d.ts.map +1 -1
- package/dist/lib/bedrock.js.map +1 -1
- package/dist/lib/llm/anthropic-client.d.ts +12 -0
- package/dist/lib/llm/anthropic-client.d.ts.map +1 -1
- package/dist/lib/llm/anthropic-client.js +25 -0
- package/dist/lib/llm/anthropic-client.js.map +1 -1
- package/dist/lib/telemetry/beacon-capture.d.ts +4 -3
- package/dist/lib/telemetry/beacon-capture.d.ts.map +1 -1
- package/dist/lib/telemetry/beacon-capture.js.map +1 -1
- package/dist/lib/telemetry/reconciliation-record.d.ts +15 -33
- package/dist/lib/telemetry/reconciliation-record.d.ts.map +1 -1
- package/dist/lib/telemetry/reconciliation-record.js +11 -49
- package/dist/lib/telemetry/reconciliation-record.js.map +1 -1
- package/dist/lib/telemetry/submission-capture.d.ts +8 -13
- package/dist/lib/telemetry/submission-capture.d.ts.map +1 -1
- package/dist/lib/telemetry/submission-capture.js +2 -4
- package/dist/lib/telemetry/submission-capture.js.map +1 -1
- package/dist/lib/telemetry/submission-reader.d.ts +3 -4
- package/dist/lib/telemetry/submission-reader.d.ts.map +1 -1
- package/dist/lib/telemetry/submission-reader.js +3 -4
- package/dist/lib/telemetry/submission-reader.js.map +1 -1
- package/dist/plugins/config-plugin.d.ts.map +1 -1
- package/dist/plugins/config-plugin.js +22 -0
- package/dist/plugins/config-plugin.js.map +1 -1
- package/dist/plugins/loader.d.ts +0 -7
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +0 -9
- package/dist/plugins/loader.js.map +1 -1
- package/dist/scraper/deep-locator-actuate.d.ts +24 -19
- package/dist/scraper/deep-locator-actuate.d.ts.map +1 -1
- package/dist/scraper/deep-locator-actuate.js +68 -23
- package/dist/scraper/deep-locator-actuate.js.map +1 -1
- package/dist/scraper/deep-locator-scan.d.ts +20 -0
- package/dist/scraper/deep-locator-scan.d.ts.map +1 -1
- package/dist/scraper/deep-locator-scan.js +25 -0
- package/dist/scraper/deep-locator-scan.js.map +1 -1
- package/dist/scraper/flow-runner.d.ts +50 -10
- package/dist/scraper/flow-runner.d.ts.map +1 -1
- package/dist/scraper/flow-runner.js +239 -97
- package/dist/scraper/flow-runner.js.map +1 -1
- package/dist/scripts/recon-browser.d.ts +5 -4
- package/dist/scripts/recon-browser.d.ts.map +1 -1
- package/dist/scripts/recon-browser.js +8 -5
- package/dist/scripts/recon-browser.js.map +1 -1
- package/dist/scripts/recon-generate.d.ts +2 -2
- package/dist/scripts/recon-generate.d.ts.map +1 -1
- package/dist/scripts/recon-generate.js +18 -85
- package/dist/scripts/recon-generate.js.map +1 -1
- package/dist/scripts/recon-shared.d.ts +0 -10
- package/dist/scripts/recon-shared.d.ts.map +1 -1
- package/dist/scripts/recon-shared.js +0 -11
- package/dist/scripts/recon-shared.js.map +1 -1
- package/dist/site-plugin.d.ts +3 -3
- package/dist/testing/coverage-guard-suite.d.mts +7 -7
- package/dist/testing/coverage-guard-suite.mjs +5 -5
- package/package.json +3 -1
- package/dist/scripts/migrate-telemetry-dir-names.d.ts +0 -57
- package/dist/scripts/migrate-telemetry-dir-names.d.ts.map +0 -1
- package/dist/scripts/migrate-telemetry-dir-names.js +0 -156
- package/dist/scripts/migrate-telemetry-dir-names.js.map +0 -1
|
@@ -77,7 +77,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
77
77
|
const node_fs_1 = require("node:fs");
|
|
78
78
|
const node_os_1 = require("node:os");
|
|
79
79
|
const node_path_1 = require("node:path");
|
|
80
|
-
const
|
|
80
|
+
const ai_1 = require("ai");
|
|
81
81
|
const config_1 = require("../config");
|
|
82
82
|
const errors_1 = require("../lib/errors");
|
|
83
83
|
const error_messages_1 = require("../lib/llm/judges/error-messages");
|
|
@@ -480,7 +480,7 @@ exports.TRAILING_GRACE_WINDOW = 2;
|
|
|
480
480
|
* means no injection surface. Runs in browser context and returns a typed-narrow
|
|
481
481
|
* shape via Runtime.callFunctionOn.
|
|
482
482
|
*/
|
|
483
|
-
const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "" }; const t = b.innerText || ""; return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200) }; })()`;
|
|
483
|
+
const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { html: 0, text: "", values: "" }; const t = b.innerText || ""; const controls = Array.from(b.querySelectorAll("input, textarea, select")).filter((el) => el.offsetParent !== null); const values = controls.map((el) => { if (el.type === "checkbox" || el.type === "radio") return el.checked ? "1" : "0"; return el.value || ""; }).join("|").slice(0, 2000); return { html: (b.outerHTML || "").length, text: t.length + ":" + t.slice(0, 200), values }; })()`;
|
|
484
484
|
/**
|
|
485
485
|
* Captures the pre/post signal triple the submit-verify cascade diffs.
|
|
486
486
|
* Accepts the optional `page` so a resolved child `FrameTarget` whose
|
|
@@ -491,6 +491,7 @@ const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { ht
|
|
|
491
491
|
async function snapshotPage(target, signalCounter, page) {
|
|
492
492
|
let bodyHtmlLength = 0;
|
|
493
493
|
let visibleTextSignature = "";
|
|
494
|
+
let formValueSignature = "";
|
|
494
495
|
try {
|
|
495
496
|
const result = await target.evaluate(DOM_SNAPSHOT_EXPR);
|
|
496
497
|
if (result !== null &&
|
|
@@ -501,6 +502,8 @@ async function snapshotPage(target, signalCounter, page) {
|
|
|
501
502
|
typeof result.text === "string") {
|
|
502
503
|
bodyHtmlLength = result.html;
|
|
503
504
|
visibleTextSignature = result.text;
|
|
505
|
+
const rawValues = result.values;
|
|
506
|
+
formValueSignature = typeof rawValues === "string" ? rawValues : "";
|
|
504
507
|
}
|
|
505
508
|
}
|
|
506
509
|
catch {
|
|
@@ -519,6 +522,7 @@ async function snapshotPage(target, signalCounter, page) {
|
|
|
519
522
|
url,
|
|
520
523
|
bodyHtmlLength,
|
|
521
524
|
visibleTextSignature,
|
|
525
|
+
formValueSignature,
|
|
522
526
|
};
|
|
523
527
|
}
|
|
524
528
|
/**
|
|
@@ -906,11 +910,15 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
906
910
|
* to the network/URL signal — which correctly verifies navigation but
|
|
907
911
|
* false-negatives client-side view swaps. This gate fills that gap.
|
|
908
912
|
*
|
|
909
|
-
* **Threshold rationale:** 5000B is 10× the trivial boundary (500B)
|
|
910
|
-
* safely distinguishes real view swaps (measured case: +49518B) from
|
|
913
|
+
* **Threshold rationale:** default 5000B is 10× the trivial boundary (500B)
|
|
914
|
+
* and safely distinguishes real view swaps (measured case: +49518B) from
|
|
911
915
|
* validation-error reflows (typically < 2KB per codebase evidence at
|
|
912
916
|
* line 2040-2044: error text capped at 200 chars, container markup adds
|
|
913
|
-
* 150-350B per error, worst-case ~10 errors = ~3500B max).
|
|
917
|
+
* 150-350B per error, worst-case ~10 errors = ~3500B max). Some sites render
|
|
918
|
+
* much smaller popups/comboboxes whose open-click grows the DOM by only a
|
|
919
|
+
* few hundred bytes; `config.scraper.viewSwapMinBytesThreshold`
|
|
920
|
+
* (`VIEW_SWAP_MIN_BYTES` env var) lets a deployment lower the threshold
|
|
921
|
+
* without regressing the shared default for other sites.
|
|
914
922
|
*
|
|
915
923
|
* **Scope guards:** Excludes final/submit steps (those require real network
|
|
916
924
|
* per isSubmitRevealedInvalid + LLM submit judge) and advance-pattern steps
|
|
@@ -918,7 +926,7 @@ function isDomOnlyAdvanceVerified(params) {
|
|
|
918
926
|
* to avoid wizard-ATS autosave-vs-transition ambiguity).
|
|
919
927
|
*/
|
|
920
928
|
function isClickViewSwapVerified(params) {
|
|
921
|
-
const VIEW_SWAP_MIN_BYTES =
|
|
929
|
+
const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
|
|
922
930
|
const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, } = params;
|
|
923
931
|
if (resolvedAction?.method !== "click")
|
|
924
932
|
return false;
|
|
@@ -972,6 +980,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
|
|
|
972
980
|
const bytesDelta = post.bodyHtmlLength - pre.bodyHtmlLength;
|
|
973
981
|
const networkDelta = post.networkCount - pre.networkCount;
|
|
974
982
|
const textChanged = post.visibleTextSignature !== pre.visibleTextSignature;
|
|
983
|
+
const valueChanged = post.formValueSignature !== pre.formValueSignature;
|
|
975
984
|
const windowCaptures = recentCaptureMeta.slice(preMetaLength);
|
|
976
985
|
const observations = [];
|
|
977
986
|
if (networkDelta === 0 && bytesDelta >= 500) {
|
|
@@ -986,6 +995,9 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
|
|
|
986
995
|
if (textChanged && networkDelta === 0 && bytesDelta < 500) {
|
|
987
996
|
observations.push("visible-text-changed-without-network: page reflowed slightly (likely tooltip/focus state)");
|
|
988
997
|
}
|
|
998
|
+
if (valueChanged && !textChanged && networkDelta === 0) {
|
|
999
|
+
observations.push("form-value-changed-without-network: a visible input/textarea/select value changed with no other observable effect (plain field fill)");
|
|
1000
|
+
}
|
|
989
1001
|
return observations.join(" | ");
|
|
990
1002
|
}
|
|
991
1003
|
/**
|
|
@@ -1036,7 +1048,7 @@ function shouldSkipTechnique(params) {
|
|
|
1036
1048
|
// structured-click / observe-act-exclude re-resolves the SAME
|
|
1037
1049
|
// light-DOM-only view of the page and would no-op identically, so skip
|
|
1038
1050
|
// straight to deep-submit-locator (attempt 2) instead. llm-rephrase
|
|
1039
|
-
// (attempt
|
|
1051
|
+
// (attempt 4) is never skipped — a differently-worded instruction is still
|
|
1040
1052
|
// a distinct attempt worth trying if the deep locator also fails. Gated on
|
|
1041
1053
|
// submitShapedStep: the deep submit-control locator ranks submit-shaped
|
|
1042
1054
|
// candidates only, so on a non-submit control (e.g. a radio/checkbox) it
|
|
@@ -1143,8 +1155,8 @@ function isAdvanceStalled(params) {
|
|
|
1143
1155
|
return networkFired;
|
|
1144
1156
|
}
|
|
1145
1157
|
/**
|
|
1146
|
-
* Lazy Anthropic client for attempt
|
|
1147
|
-
* deployment is Bedrock-only (no ANTHROPIC_API_KEY) — attempt
|
|
1158
|
+
* Lazy Anthropic client for attempt 5's rephrase. Returns null when the
|
|
1159
|
+
* deployment is Bedrock-only (no ANTHROPIC_API_KEY) — attempt 5 then becomes
|
|
1148
1160
|
* a no-op and the executor escalates straight to the failure dump. The other
|
|
1149
1161
|
* three attempts already cover the lion's share of recovery.
|
|
1150
1162
|
*/
|
|
@@ -1165,7 +1177,7 @@ function anthropicModelName() {
|
|
|
1165
1177
|
*/
|
|
1166
1178
|
const REPHRASE_SYSTEM_PROMPT = "You rewrite a failed Stagehand act() instruction so the next attempt targets a different element than those already tried. If no different element exists on the page that could plausibly unblock the flow, return outcome=impossible. Never re-propose a selector or wording from the lists labeled SELECTORS ALREADY TRIED or INSTRUCTION TEXT ALREADY TRIED.";
|
|
1167
1179
|
/**
|
|
1168
|
-
* Attempt-
|
|
1180
|
+
* Attempt-5 of the step-healing cascade: when three mechanical retry variations
|
|
1169
1181
|
* all fail, this is the last resort before the step is declared terminal. Exported
|
|
1170
1182
|
* so tests can inject a fake capture sink without touching the browser session.
|
|
1171
1183
|
*/
|
|
@@ -1219,13 +1231,21 @@ priorAttempts,
|
|
|
1219
1231
|
* "click harder" into "fill the actually-missing field." Telemetry
|
|
1220
1232
|
* the engine already captures but never showed the LLM until now.
|
|
1221
1233
|
*/
|
|
1222
|
-
gaEventEvidence
|
|
1234
|
+
gaEventEvidence,
|
|
1235
|
+
/**
|
|
1236
|
+
* Anthropic client for the modal-priority judge inside
|
|
1237
|
+
* `renderUnfocusedObserve`. Separate from `client` (the ai-SDK model used
|
|
1238
|
+
* for the rephrase call itself) because that judge pipeline is unrelated
|
|
1239
|
+
* to which provider generates the rephrase, and null on a Bedrock-only
|
|
1240
|
+
* deployment falls back to `renderUnfocusedObserve`'s source-order default.
|
|
1241
|
+
*/
|
|
1242
|
+
judgeClient) {
|
|
1223
1243
|
const candidateList = observeCandidates
|
|
1224
1244
|
.slice(0, 12)
|
|
1225
1245
|
.map((a, i) => `${i + 1}. ${a.description} — ${a.selector}`)
|
|
1226
1246
|
.join("\n");
|
|
1227
1247
|
const unfocusedList = unfocusedObserve
|
|
1228
|
-
? await renderUnfocusedObserve(unfocusedObserve, { client, captureFn })
|
|
1248
|
+
? await renderUnfocusedObserve(unfocusedObserve, { client: judgeClient, captureFn })
|
|
1229
1249
|
: "";
|
|
1230
1250
|
const triedList = triedSelectors.length > 0 ? triedSelectors.join("\n") : "(none)";
|
|
1231
1251
|
const reasonList = failureReasons.map((r, i) => `attempt ${i + 1}: ${r}`).join("\n");
|
|
@@ -1297,35 +1317,28 @@ PAGE TRANSITION + VALIDATOR TELEMETRY (parsed from Google Analytics Measurement
|
|
|
1297
1317
|
${gaEventEvidence || "(none)"}
|
|
1298
1318
|
|
|
1299
1319
|
Rewrite the instruction so a Stagehand act() call can resolve it unambiguously to a different element than the ones already tried. Keep it short — one sentence, natural language, no quotes around it. If the invalid-fields section above names a field AND the interactive-targets section below lists clickable options inside that same container, your rewrite picks one of those options (e.g. for a yes/no question rendered as two radio labels, choose the candidate-favorable answer — 'No' for "do you have a non-compete", 'Yes' for "are you authorized to work", 'Prefer not to say' for demographic disclosures) rather than retrying the original click. If the unfocused observe section shows an open modal/dialog with a Save or Close action, your rewrite invokes that action so the underlying form can clear its blocking state. Set outcome to "impossible" with a one-line reason only when the original instruction's element does not exist on the current page and no redirect target is available.`;
|
|
1300
|
-
const model =
|
|
1320
|
+
const model = client.modelId;
|
|
1301
1321
|
const t0 = performance.now();
|
|
1302
1322
|
try {
|
|
1303
|
-
const response = await
|
|
1304
|
-
model,
|
|
1305
|
-
|
|
1323
|
+
const response = await (0, ai_1.generateObject)({
|
|
1324
|
+
model: client,
|
|
1325
|
+
maxOutputTokens: 400,
|
|
1306
1326
|
system: REPHRASE_SYSTEM_PROMPT,
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
format: (0, zod_1.zodOutputFormat)(schemas_1.REPHRASE_RESPONSE_SCHEMA),
|
|
1310
|
-
},
|
|
1327
|
+
prompt,
|
|
1328
|
+
schema: schemas_1.REPHRASE_RESPONSE_SCHEMA,
|
|
1311
1329
|
});
|
|
1312
1330
|
const latencyMs = performance.now() - t0;
|
|
1313
|
-
const parsed = response.
|
|
1314
|
-
if (parsed === null) {
|
|
1315
|
-
throw new Error("structured-output enabled but parsed_output is null");
|
|
1316
|
-
}
|
|
1317
|
-
const textBlock = response.content.find((b) => b.type === "text");
|
|
1318
|
-
const rawText = textBlock?.type === "text" ? textBlock.text : "";
|
|
1331
|
+
const parsed = response.object;
|
|
1319
1332
|
await captureFn({
|
|
1320
1333
|
callId: (0, node_crypto_1.randomUUID)(),
|
|
1321
1334
|
callType: call_types_1.CALL_TYPE_RECON_REPHRASE,
|
|
1322
1335
|
model,
|
|
1323
1336
|
systemPrompt: REPHRASE_SYSTEM_PROMPT,
|
|
1324
1337
|
userContent: prompt,
|
|
1325
|
-
responseContent:
|
|
1338
|
+
responseContent: JSON.stringify(parsed),
|
|
1326
1339
|
parsedOk: true,
|
|
1327
|
-
inputTokens: response.usage
|
|
1328
|
-
outputTokens: response.usage
|
|
1340
|
+
inputTokens: response.usage.inputTokens ?? null,
|
|
1341
|
+
outputTokens: response.usage.outputTokens ?? null,
|
|
1329
1342
|
latencyMs,
|
|
1330
1343
|
success: true,
|
|
1331
1344
|
errorMessage: null,
|
|
@@ -2273,9 +2286,13 @@ const UPLOAD_NETWORK_POLL_INTERVAL_MS = 250;
|
|
|
2273
2286
|
* wizard lands on the Apply page, so a single probe races that mount and the
|
|
2274
2287
|
* primitive wrongly falls into the click-to-surface path (or skips). Only
|
|
2275
2288
|
* reached on `upload:true` steps, so it never slows a page with no upload step.
|
|
2289
|
+
* The poll exits on first success, so raising the attempt count via
|
|
2290
|
+
* `config.scraper.uploadWidgetRenderAttempts` (`UPLOAD_WIDGET_RENDER_ATTEMPTS`
|
|
2291
|
+
* env var) costs nothing extra for sites that mount promptly — only sites
|
|
2292
|
+
* whose upload field mounts conditionally (e.g. only after a prior step
|
|
2293
|
+
* commits) and needs a longer window should override the default.
|
|
2276
2294
|
*/
|
|
2277
2295
|
const UPLOAD_WIDGET_RENDER_INTERVAL_MS = 600;
|
|
2278
|
-
const UPLOAD_WIDGET_RENDER_ATTEMPTS = 17;
|
|
2279
2296
|
/**
|
|
2280
2297
|
* URL substrings that mark a POST as an actual resume/attachment upload rather
|
|
2281
2298
|
* than coincidental traffic (analytics beacons, `/interruption_check`,
|
|
@@ -2385,7 +2402,10 @@ async function tryUploadPrimitive(params) {
|
|
|
2385
2402
|
// already rendered it pay nothing.
|
|
2386
2403
|
let inputCount = 0;
|
|
2387
2404
|
try {
|
|
2388
|
-
inputCount = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0, {
|
|
2405
|
+
inputCount = await pollEnumerate(page, target, "document.querySelectorAll('input[type=file]').length", (n) => (n ?? 0) > 0, {
|
|
2406
|
+
attempts: config_1.config.scraper.uploadWidgetRenderAttempts,
|
|
2407
|
+
intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS,
|
|
2408
|
+
});
|
|
2389
2409
|
}
|
|
2390
2410
|
catch (err) {
|
|
2391
2411
|
logger.warn(`upload primitive: file-input probe threw: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
@@ -2534,7 +2554,7 @@ async function surfaceAndUpload(params) {
|
|
|
2534
2554
|
return { present: false };
|
|
2535
2555
|
})()`;
|
|
2536
2556
|
const gate = await pollEnumerate(page, target, targetExpr, (r) => r?.present === true, {
|
|
2537
|
-
attempts:
|
|
2557
|
+
attempts: config_1.config.scraper.uploadWidgetRenderAttempts,
|
|
2538
2558
|
intervalMs: UPLOAD_WIDGET_RENDER_INTERVAL_MS,
|
|
2539
2559
|
});
|
|
2540
2560
|
if (!gate?.present) {
|
|
@@ -2843,6 +2863,24 @@ function resolveDeepLocatorActuation(step) {
|
|
|
2843
2863
|
function normalizeFieldLabel(text) {
|
|
2844
2864
|
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
2845
2865
|
}
|
|
2866
|
+
/**
|
|
2867
|
+
* Parses a fill/select step into the field it names plus the value to write,
|
|
2868
|
+
* or `null` for any other step shape (click, a select whose question is
|
|
2869
|
+
* un-quoted, etc). Shared by both the observe-empty deepLocator fallback and
|
|
2870
|
+
* the field-label-first routing ahead of Stagehand's own act()/observe()
|
|
2871
|
+
* resolution — see `findDeepLocatorCandidateByFieldLabel`'s docblock for why
|
|
2872
|
+
* the field LABEL (not the value) is what a caller matches candidates
|
|
2873
|
+
* against.
|
|
2874
|
+
*/
|
|
2875
|
+
function parseFieldLabelTarget(step) {
|
|
2876
|
+
const fillStep = parseFillStep(step);
|
|
2877
|
+
if (fillStep)
|
|
2878
|
+
return { kind: "fill", fieldLabel: fillStep.fieldLabel, value: fillStep.value };
|
|
2879
|
+
const selectStep = parseSelectStep(step);
|
|
2880
|
+
return selectStep?.questionLabel
|
|
2881
|
+
? { kind: "select", fieldLabel: selectStep.questionLabel, value: selectStep.option }
|
|
2882
|
+
: null;
|
|
2883
|
+
}
|
|
2846
2884
|
/**
|
|
2847
2885
|
* Finds the candidate whose accessible name identifies the named field —
|
|
2848
2886
|
* `fieldLabel` (from {@link parseFillStep}/{@link parseSelectStep}), NOT the
|
|
@@ -3675,15 +3713,15 @@ async function tryRadioPrimitive(params) {
|
|
|
3675
3713
|
const { option, questionLabel } = parsed;
|
|
3676
3714
|
const optLabel = `option "${option.slice(0, 40)}"${questionLabel ? `, question "${questionLabel.slice(0, 40)}"` : ""}`;
|
|
3677
3715
|
// Phase 1 (browser): find radio GROUPS and their options. A group is a
|
|
3678
|
-
// `<fieldset>` / `[role=radiogroup]` / `[class*='RadioGroup']`
|
|
3679
|
-
// radios. Question label = the group's legend / associated label; each option
|
|
3716
|
+
// `<fieldset>` / `[role=radiogroup]` / `[role=group]` / `[class*='RadioGroup']`
|
|
3717
|
+
// container with radios. Question label = the group's legend / associated label; each option
|
|
3680
3718
|
// = the `<label for=radio-id>` text. Deterministic unique option-text match
|
|
3681
3719
|
// commits immediately (no LLM). Else return groups for the LLM picker.
|
|
3682
3720
|
// `commit` uses the React-safe native `checked` setter so MUI/React registers
|
|
3683
3721
|
// the change, then reports `ok` = post-commit `radio.checked && !invalid`.
|
|
3684
3722
|
const enumerateExpr = `((option) => {
|
|
3685
3723
|
const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
|
|
3686
|
-
|
|
3724
|
+
const groupEls = Array.from(document.querySelectorAll("fieldset,[role='radiogroup'],[role='group'],[class*='RadioGroup']"))
|
|
3687
3725
|
.filter((g) => g.querySelector("input[type=radio]"));
|
|
3688
3726
|
if (groupEls.length === 0) return { groupPresent: false };
|
|
3689
3727
|
const groupLabel = (g) => {
|
|
@@ -3881,7 +3919,7 @@ async function applyRadioSelection(target, gi, ri, hint) {
|
|
|
3881
3919
|
// Tier C — legacy synthetic-events backstop (native setter + bubbling events).
|
|
3882
3920
|
const applyExpr = `((gi, ri) => {
|
|
3883
3921
|
const isInvalid = ${INVALID_MARKER_EL_EXPR};
|
|
3884
|
-
const groupEls = Array.from(document.querySelectorAll("fieldset,[role='radiogroup'],[class*='RadioGroup']"))
|
|
3922
|
+
const groupEls = Array.from(document.querySelectorAll("fieldset,[role='radiogroup'],[role='group'],[class*='RadioGroup']"))
|
|
3885
3923
|
.filter((g) => g.querySelector("input[type=radio]"));
|
|
3886
3924
|
const grp = groupEls[gi];
|
|
3887
3925
|
if (!grp) return { ok: false };
|
|
@@ -4174,10 +4212,15 @@ async function verifyDomEffect(target, action) {
|
|
|
4174
4212
|
// Same trust-boundary rationale as the click case below: xpath is from
|
|
4175
4213
|
// Stagehand's resolved selector, JSON.stringify produces a safe JS string
|
|
4176
4214
|
// literal, and the expression cannot inject behavior through xpath content.
|
|
4177
|
-
|
|
4215
|
+
// Custom (non-native-<select>) comboboxes — MUI/React-Select/Radix/Headless
|
|
4216
|
+
// UI-style widgets — always fail the native-<select>-only check below,
|
|
4217
|
+
// misreporting genuinely-successful selections as unverified. Fall back to
|
|
4218
|
+
// reading the combobox's own displayed/aria value when the resolved element
|
|
4219
|
+
// isn't a real <select>, mirroring what a real ARIA combobox exposes.
|
|
4220
|
+
const exprNative = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; if (!el || el.tagName !== "SELECT") return null; const opt = el.options[el.selectedIndex]; if (!opt) return { value: "", label: "", text: "" }; return { value: (opt.value || "").trim(), label: (opt.label || "").trim(), text: (opt.textContent || "").trim() }; })()`;
|
|
4178
4221
|
let selected = null;
|
|
4179
4222
|
try {
|
|
4180
|
-
const result = await target.evaluate(
|
|
4223
|
+
const result = await target.evaluate(exprNative);
|
|
4181
4224
|
if (result !== null &&
|
|
4182
4225
|
typeof result === "object" &&
|
|
4183
4226
|
"value" in result &&
|
|
@@ -4189,6 +4232,42 @@ async function verifyDomEffect(target, action) {
|
|
|
4189
4232
|
catch {
|
|
4190
4233
|
return false;
|
|
4191
4234
|
}
|
|
4235
|
+
if (!selected) {
|
|
4236
|
+
// MUI-style comboboxes render role="combobox" on a wrapper element with a
|
|
4237
|
+
// nested (often hidden/readonly) `<input value="...">` carrying the real
|
|
4238
|
+
// selected value — neither the wrapper nor the resolved element necessarily
|
|
4239
|
+
// has a usable `.value` itself, so search the element, its combobox
|
|
4240
|
+
// ancestor, and — since some resolved xpaths land on a sibling label rather
|
|
4241
|
+
// than the combobox itself — the nearest combobox within the same
|
|
4242
|
+
// field-group container, for a nested input value, falling back to visible
|
|
4243
|
+
// text or an aria label/valuetext.
|
|
4244
|
+
const exprCustom = `(() => {
|
|
4245
|
+
const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
|
|
4246
|
+
const el = r.singleNodeValue;
|
|
4247
|
+
if (!el) return null;
|
|
4248
|
+
let combo = (el.closest && el.closest('[role="combobox"]')) || null;
|
|
4249
|
+
if (!combo) {
|
|
4250
|
+
const container = el.closest && (el.closest('[class*="Container"]') || el.parentElement);
|
|
4251
|
+
combo = container && container.querySelector && container.querySelector('[role="combobox"]');
|
|
4252
|
+
}
|
|
4253
|
+
combo = combo || el;
|
|
4254
|
+
const nestedInput = combo.querySelector && combo.querySelector('input');
|
|
4255
|
+
const value = (nestedInput && nestedInput.value) || el.value || "";
|
|
4256
|
+
const text = (combo.textContent || el.textContent || "").trim();
|
|
4257
|
+
const ariaText = (el.getAttribute && (el.getAttribute('aria-label') || el.getAttribute('aria-valuetext'))) || "";
|
|
4258
|
+
return { value: String(value).trim(), text: String(text || ariaText).trim() };
|
|
4259
|
+
})()`;
|
|
4260
|
+
try {
|
|
4261
|
+
const customResult = await target.evaluate(exprCustom);
|
|
4262
|
+
if (customResult !== null && typeof customResult === "object") {
|
|
4263
|
+
const custom = customResult;
|
|
4264
|
+
selected = { value: custom.value ?? "", label: "", text: custom.text ?? "" };
|
|
4265
|
+
}
|
|
4266
|
+
}
|
|
4267
|
+
catch {
|
|
4268
|
+
return false;
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4192
4271
|
if (!selected)
|
|
4193
4272
|
return false;
|
|
4194
4273
|
const want = expected.toLowerCase();
|
|
@@ -4564,6 +4643,36 @@ async function resolveDeepLocatorCandidatesWithWidening(page, frameSelector, ins
|
|
|
4564
4643
|
const widened = await (0, deep_locator_candidates_1.resolveDeepLocatorCandidates)(page, frameSelector, "*", instruction, timeoutOptions);
|
|
4565
4644
|
return { candidates: widened, innerSelector: "*" };
|
|
4566
4645
|
}
|
|
4646
|
+
/**
|
|
4647
|
+
* Resolves and actuates a fill/select step's NAMED FIELD through the
|
|
4648
|
+
* deterministic accessible-name match (`findDeepLocatorCandidateByFieldLabel`)
|
|
4649
|
+
* against the frame's own deepLocator scan, independent of what Stagehand's
|
|
4650
|
+
* `act()`/`observe()` resolved. A same-frame accessible-name label match
|
|
4651
|
+
* cannot confuse a City input for an unrelated header search box the way
|
|
4652
|
+
* Stagehand's semantic resolution can — Stagehand's own candidate is only a
|
|
4653
|
+
* fallback for the caller to use when this returns `no-match` or
|
|
4654
|
+
* `not-fill-or-select`, never the first attempt for a step that names a
|
|
4655
|
+
* field. `triedSelectors` (already-attempted selectors from prior
|
|
4656
|
+
* cascade attempts) is excluded so a step that already tried and failed on
|
|
4657
|
+
* this candidate doesn't just re-pick it.
|
|
4658
|
+
*/
|
|
4659
|
+
async function tryDeterministicFieldLabelActuation(params) {
|
|
4660
|
+
const { page, frameTarget, step, triedSelectors, timeoutOptions } = params;
|
|
4661
|
+
const fieldTarget = parseFieldLabelTarget(step);
|
|
4662
|
+
if (!fieldTarget)
|
|
4663
|
+
return { kind: "not-fill-or-select" };
|
|
4664
|
+
const { candidates, innerSelector } = await resolveDeepLocatorCandidatesWithWidening(page, frameTarget.frameSelector, step, timeoutOptions);
|
|
4665
|
+
const unexcluded = candidates.filter((c) => !triedSelectors.includes(c.selector));
|
|
4666
|
+
const matched = findDeepLocatorCandidateByFieldLabel(unexcluded, fieldTarget.fieldLabel);
|
|
4667
|
+
if (!matched)
|
|
4668
|
+
return { kind: "no-match", fieldTarget };
|
|
4669
|
+
const actuated = fieldTarget.kind === "fill"
|
|
4670
|
+
? await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget.frameSelector, innerSelector, matched.index, fieldTarget.value, { frameTarget })
|
|
4671
|
+
: await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget.frameSelector, innerSelector, matched.index, fieldTarget.value, { frameTarget });
|
|
4672
|
+
return actuated
|
|
4673
|
+
? { kind: "actuated", matched, fieldTarget }
|
|
4674
|
+
: { kind: "actuation-failed", matched, fieldTarget };
|
|
4675
|
+
}
|
|
4567
4676
|
/**
|
|
4568
4677
|
* Adapts `resolveDeepLocatorCandidatesWithWidening` results into
|
|
4569
4678
|
* `Action`-shaped evidence for `rephraseWithLLM`, which only knows about
|
|
@@ -4674,7 +4783,7 @@ async function probeStepBeforeAttempts(params) {
|
|
|
4674
4783
|
}
|
|
4675
4784
|
}
|
|
4676
4785
|
async function executeStepWithHealing(params) {
|
|
4677
|
-
const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
|
|
4786
|
+
const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
|
|
4678
4787
|
// Mutable (not the destructured const above) so a lost frame-attach race
|
|
4679
4788
|
// can be upgraded in place once the OOPIF attaches later in the cascade —
|
|
4680
4789
|
// see reresolveFrameTargetIfLost below. Every existing reference in this
|
|
@@ -4988,6 +5097,7 @@ async function executeStepWithHealing(params) {
|
|
|
4988
5097
|
url: page.url(),
|
|
4989
5098
|
bodyHtmlLength: 0,
|
|
4990
5099
|
visibleTextSignature: "",
|
|
5100
|
+
formValueSignature: "",
|
|
4991
5101
|
};
|
|
4992
5102
|
attempts.push({
|
|
4993
5103
|
attempt: 0,
|
|
@@ -5089,6 +5199,57 @@ async function executeStepWithHealing(params) {
|
|
|
5089
5199
|
// from DOM re-read. Captured here so both branches of the cascade write to it.
|
|
5090
5200
|
let resolvedAction = null;
|
|
5091
5201
|
try {
|
|
5202
|
+
// Field-label-first routing: for a frame-scoped fill/select step,
|
|
5203
|
+
// Stagehand's own act()/observe() can resolve a real, in-DOM, plausible-
|
|
5204
|
+
// looking control that is nonetheless the WRONG one (a City field
|
|
5205
|
+
// resolving to an unrelated header search box whose accessible name
|
|
5206
|
+
// overlaps semantically with "city/location" is the motivating case).
|
|
5207
|
+
// A same-frame accessible-name match against the field's own label
|
|
5208
|
+
// can't make that mistake, so it's tried BEFORE any Stagehand
|
|
5209
|
+
// act/observe call for every attempt, not only once observe() has
|
|
5210
|
+
// already come back empty (bugfix-001's finding: observe() returning a
|
|
5211
|
+
// wrong-but-nonempty candidate was exactly what let the phantom-fill
|
|
5212
|
+
// through the old empty-candidates-only gate). Falls through to the
|
|
5213
|
+
// existing Stagehand-driven attempt when no candidate names the field
|
|
5214
|
+
// at all — this only preempts Stagehand's resolution, it never expands
|
|
5215
|
+
// what counts as a match.
|
|
5216
|
+
const fieldLabelOutcome = attempt !== 3 && frameTarget?.frame
|
|
5217
|
+
? await tryDeterministicFieldLabelActuation({
|
|
5218
|
+
page,
|
|
5219
|
+
frameTarget,
|
|
5220
|
+
step,
|
|
5221
|
+
triedSelectors,
|
|
5222
|
+
timeoutOptions: { frameTarget },
|
|
5223
|
+
})
|
|
5224
|
+
: { kind: "not-fill-or-select" };
|
|
5225
|
+
if (fieldLabelOutcome.kind === "actuated") {
|
|
5226
|
+
record.technique = "deep-locator-field-label";
|
|
5227
|
+
record.instruction = `deepLocator: ${fieldLabelOutcome.matched.accessibleText || "(no accessible text)"}`;
|
|
5228
|
+
triedSelectors.push(fieldLabelOutcome.matched.selector);
|
|
5229
|
+
record.triedSelectors = [fieldLabelOutcome.matched.selector];
|
|
5230
|
+
record.actResultSuccess = true;
|
|
5231
|
+
record.actResultDescription = `deepLocator ${fieldLabelOutcome.fieldTarget.kind === "fill" ? "filled" : "selected"} "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" with "${fieldLabelOutcome.fieldTarget.value}"`;
|
|
5232
|
+
// Same rationale as the pre-existing candidates===0 branch below:
|
|
5233
|
+
// the write + read-back the actuation helper already performed IS
|
|
5234
|
+
// the verification signal — no resolvedAction/network/url verifier
|
|
5235
|
+
// can score a `deeplocator=` selector.
|
|
5236
|
+
record.verifiedBy = "dom";
|
|
5237
|
+
attempts.push(record);
|
|
5238
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.actResultDescription}`);
|
|
5239
|
+
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
5240
|
+
return "completed";
|
|
5241
|
+
}
|
|
5242
|
+
if (fieldLabelOutcome.kind === "actuation-failed") {
|
|
5243
|
+
triedSelectors.push(fieldLabelOutcome.matched.selector);
|
|
5244
|
+
record.triedSelectors = [fieldLabelOutcome.matched.selector];
|
|
5245
|
+
const failureMessage = `deepLocator: ${fieldLabelOutcome.fieldTarget.kind} on "${fieldLabelOutcome.matched.accessibleText || fieldLabelOutcome.matched.selector}" did not verify (read-back mismatch or rejected write)`;
|
|
5246
|
+
record.actResultSuccess = false;
|
|
5247
|
+
record.errorMessage = failureMessage;
|
|
5248
|
+
attempts.push(record);
|
|
5249
|
+
failureReasons.push(failureMessage);
|
|
5250
|
+
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
|
|
5251
|
+
continue;
|
|
5252
|
+
}
|
|
5092
5253
|
if (attempt === 1) {
|
|
5093
5254
|
record.technique = "act-string";
|
|
5094
5255
|
record.instruction = step;
|
|
@@ -5267,58 +5428,19 @@ async function executeStepWithHealing(params) {
|
|
|
5267
5428
|
const deepLocatorInnerSelector = deepLocatorResolution?.innerSelector ?? deep_locator_scan_1.INTERACTIVE_CANDIDATE_SELECTOR;
|
|
5268
5429
|
const deepLocatorCandidates = (deepLocatorResolution?.candidates ?? []).filter((c) => !triedSelectors.includes(c.selector));
|
|
5269
5430
|
if (candidates.length === 0 && deepLocatorCandidates.length > 0) {
|
|
5270
|
-
//
|
|
5271
|
-
//
|
|
5272
|
-
//
|
|
5273
|
-
//
|
|
5274
|
-
//
|
|
5275
|
-
//
|
|
5276
|
-
//
|
|
5277
|
-
//
|
|
5278
|
-
//
|
|
5279
|
-
//
|
|
5280
|
-
const
|
|
5281
|
-
const selectStep = fillStep ? null : parseSelectStep(step);
|
|
5282
|
-
const fieldTarget = fillStep
|
|
5283
|
-
? { kind: "fill", fieldLabel: fillStep.fieldLabel, value: fillStep.value }
|
|
5284
|
-
: selectStep?.questionLabel
|
|
5285
|
-
? { kind: "select", fieldLabel: selectStep.questionLabel, value: selectStep.option }
|
|
5286
|
-
: null;
|
|
5431
|
+
// Field-label routing for a fill/select-shaped step already ran
|
|
5432
|
+
// (and returned/continued on a MATCH) in the field-label-first
|
|
5433
|
+
// pre-check above, before this attempt's own act/observe call —
|
|
5434
|
+
// see `tryDeterministicFieldLabelActuation`. Reaching this
|
|
5435
|
+
// `if (fieldTarget)` means the pre-check found no candidate
|
|
5436
|
+
// naming the field either — the walk below scores by the
|
|
5437
|
+
// instruction's quoted VALUE (see parseFillStep's docblock), so
|
|
5438
|
+
// every candidate ties at score 0 and it would otherwise click
|
|
5439
|
+
// whichever sits first in DOM order (the bug report's wizard
|
|
5440
|
+
// 'Close' mis-click). Refuse instead of guessing.
|
|
5441
|
+
const fieldTarget = parseFieldLabelTarget(step);
|
|
5287
5442
|
if (fieldTarget) {
|
|
5288
|
-
const
|
|
5289
|
-
if (!matched) {
|
|
5290
|
-
const failureMessage = `deepLocator: no candidate matched field "${fieldTarget.fieldLabel}" (refusing to click an unrelated control)`;
|
|
5291
|
-
record.actResultSuccess = false;
|
|
5292
|
-
record.errorMessage = failureMessage;
|
|
5293
|
-
attempts.push(record);
|
|
5294
|
-
failureReasons.push(failureMessage);
|
|
5295
|
-
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
|
|
5296
|
-
continue;
|
|
5297
|
-
}
|
|
5298
|
-
triedSelectors.push(matched.selector);
|
|
5299
|
-
record.triedSelectors = [matched.selector];
|
|
5300
|
-
const actuated = fieldTarget.kind === "fill"
|
|
5301
|
-
? await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, matched.index, fieldTarget.value, { frameTarget })
|
|
5302
|
-
: await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, matched.index, fieldTarget.value, { frameTarget });
|
|
5303
|
-
if (actuated) {
|
|
5304
|
-
record.instruction = `deepLocator: ${matched.accessibleText || "(no accessible text)"}`;
|
|
5305
|
-
record.actResultSuccess = true;
|
|
5306
|
-
record.actResultDescription = `deepLocator ${fieldTarget.kind === "fill" ? "filled" : "selected"} "${matched.accessibleText || matched.selector}" with "${fieldTarget.value}"`;
|
|
5307
|
-
// verifyDomEffect can't resolve a `deeplocator=` selector (see
|
|
5308
|
-
// deep-locator-actuate.ts's module docblock: target.locator()
|
|
5309
|
-
// has no meaning for cross-origin OOPIF hop notation) — the
|
|
5310
|
-
// write + read-back fillDeepLocatorCandidate/
|
|
5311
|
-
// selectDeepLocatorCandidateOption already performed IS the
|
|
5312
|
-
// verification signal, so record it directly instead of
|
|
5313
|
-
// synthesizing a resolvedAction that a verifier can only ever
|
|
5314
|
-
// score false for.
|
|
5315
|
-
record.verifiedBy = "dom";
|
|
5316
|
-
attempts.push(record);
|
|
5317
|
-
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${record.actResultDescription}`);
|
|
5318
|
-
trajectory?.push({ stepIndex, verifiedBy: "dom" });
|
|
5319
|
-
return "completed";
|
|
5320
|
-
}
|
|
5321
|
-
const failureMessage = `deepLocator: ${fieldTarget.kind} on "${matched.accessibleText || matched.selector}" did not verify (read-back mismatch or rejected write)`;
|
|
5443
|
+
const failureMessage = `deepLocator: no candidate matched field "${fieldTarget.fieldLabel}" (refusing to click an unrelated control)`;
|
|
5322
5444
|
record.actResultSuccess = false;
|
|
5323
5445
|
record.errorMessage = failureMessage;
|
|
5324
5446
|
attempts.push(record);
|
|
@@ -5326,6 +5448,7 @@ async function executeStepWithHealing(params) {
|
|
|
5326
5448
|
logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
|
|
5327
5449
|
continue;
|
|
5328
5450
|
}
|
|
5451
|
+
const selectStep = parseSelectStep(step);
|
|
5329
5452
|
// A select step whose question is phrased un-quoted (parseSelectStep
|
|
5330
5453
|
// returns `questionLabel: null`) has no fieldLabel to route through
|
|
5331
5454
|
// findDeepLocatorCandidateByFieldLabel above, so ranking below falls
|
|
@@ -5709,8 +5832,8 @@ async function executeStepWithHealing(params) {
|
|
|
5709
5832
|
}
|
|
5710
5833
|
else {
|
|
5711
5834
|
record.technique = "llm-rephrase";
|
|
5712
|
-
if (!
|
|
5713
|
-
record.errorMessage = "no
|
|
5835
|
+
if (!rephraseModel) {
|
|
5836
|
+
record.errorMessage = "no rephrase model configured; skipping rephrase";
|
|
5714
5837
|
}
|
|
5715
5838
|
else if (hasBillingErrorBeenLogged()) {
|
|
5716
5839
|
// Telemetry-driven skip: when Anthropic billing has already been
|
|
@@ -5758,7 +5881,7 @@ async function executeStepWithHealing(params) {
|
|
|
5758
5881
|
instruction: a.instruction,
|
|
5759
5882
|
verdict: a.errorMessage ?? failureReasons[i] ?? null,
|
|
5760
5883
|
}));
|
|
5761
|
-
const rephrased = await rephraseWithLLM(
|
|
5884
|
+
const rephrased = await rephraseWithLLM(rephraseModel, step, triedSelectors, candidates, failureReasons, captureFn, livePageEvidence, unfocused, submitFailureList, priorAttemptsForPrompt, gaEventList, anthropic);
|
|
5762
5885
|
if (!rephrased) {
|
|
5763
5886
|
record.errorMessage = "llm declined to rephrase or returned outcome=impossible";
|
|
5764
5887
|
}
|
|
@@ -5889,7 +6012,19 @@ async function executeStepWithHealing(params) {
|
|
|
5889
6012
|
networkDelta: post.networkCount - pre.networkCount,
|
|
5890
6013
|
bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
|
|
5891
6014
|
});
|
|
5892
|
-
|
|
6015
|
+
// Form-value-diff signal: `visibleTextSignature` never reflects a plain
|
|
6016
|
+
// <input>/<textarea>/<select>'s `value` property, so a fill with no
|
|
6017
|
+
// secondary UI side effect (no toggle, no formatted display) had ZERO
|
|
6018
|
+
// verification signal — every cascade attempt reported "no observable
|
|
6019
|
+
// effect" even though the value genuinely landed. Scoped to state-class
|
|
6020
|
+
// actions (fill/check/etc.), same as domVerified, so it never lets an
|
|
6021
|
+
// advance/submit step ride a stray value mutation elsewhere on the page.
|
|
6022
|
+
const formValueVerified = isStateClass && post.formValueSignature !== pre.formValueSignature;
|
|
6023
|
+
let verified = networkIsRealAdvance ||
|
|
6024
|
+
urlChanged ||
|
|
6025
|
+
domVerifiedForStep ||
|
|
6026
|
+
clickViewSwapVerified ||
|
|
6027
|
+
formValueVerified;
|
|
5893
6028
|
// Final-step submit-verification gate. Replaces the deterministic
|
|
5894
6029
|
// submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
|
|
5895
6030
|
// multi-signal evidence (network captures, page URL/title, DOM
|
|
@@ -6008,7 +6143,11 @@ async function executeStepWithHealing(params) {
|
|
|
6008
6143
|
? "url"
|
|
6009
6144
|
: networkFired
|
|
6010
6145
|
? "network"
|
|
6011
|
-
:
|
|
6146
|
+
: domVerifiedForStep
|
|
6147
|
+
? "dom"
|
|
6148
|
+
: formValueVerified
|
|
6149
|
+
? "form-value"
|
|
6150
|
+
: "dom";
|
|
6012
6151
|
}
|
|
6013
6152
|
// N+16 probe: Stagehand's CDP click sometimes lands on the button without
|
|
6014
6153
|
// triggering React's SyntheticEvent layer (or jQuery delegated handlers).
|
|
@@ -6095,6 +6234,7 @@ async function executeStepWithHealing(params) {
|
|
|
6095
6234
|
const retryUrlChanged = retryPost.url !== pre.url;
|
|
6096
6235
|
const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
|
|
6097
6236
|
const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
|
|
6237
|
+
const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
|
|
6098
6238
|
// RC2: an advance/`kind=click` "Next" that only grew the DOM
|
|
6099
6239
|
// (validation errors rendered) with NO network/URL change is a
|
|
6100
6240
|
// validation-blocked no-op, not a real transition — but the
|
|
@@ -6150,6 +6290,7 @@ async function executeStepWithHealing(params) {
|
|
|
6150
6290
|
retryUrlChanged ||
|
|
6151
6291
|
retryHtmlDelta !== 0 ||
|
|
6152
6292
|
retryTextChanged ||
|
|
6293
|
+
retryFormValueChanged ||
|
|
6153
6294
|
checkboxStateVerified);
|
|
6154
6295
|
// Apply the same submit-endpoint gate the primary verifier uses.
|
|
6155
6296
|
// Without this, the n+16 fallback would still ride past a
|
|
@@ -6223,7 +6364,7 @@ async function executeStepWithHealing(params) {
|
|
|
6223
6364
|
failureReasons.push(`n+16 fallback: submit-judge-rejected: ${judgeVerdict.reason}`);
|
|
6224
6365
|
}
|
|
6225
6366
|
}
|
|
6226
|
-
logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} verified=${retryVerified}`);
|
|
6367
|
+
logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} formValueChanged=${retryFormValueChanged} verified=${retryVerified}`);
|
|
6227
6368
|
if (retryVerified) {
|
|
6228
6369
|
if (record.verifiedBy === null) {
|
|
6229
6370
|
record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
|
|
@@ -6488,7 +6629,7 @@ async function waitForSpaReady(page, logger, opts = {}) {
|
|
|
6488
6629
|
* than let the flow report phantom success.
|
|
6489
6630
|
*/
|
|
6490
6631
|
async function runHealingFlow(deps) {
|
|
6491
|
-
const { stagehand, page, steps, logger, anthropic, uploadFixture, maxFlowMs } = deps;
|
|
6632
|
+
const { stagehand, page, steps, logger, anthropic, rephraseModel, uploadFixture, maxFlowMs } = deps;
|
|
6492
6633
|
const counter = { n: 0 };
|
|
6493
6634
|
const signalCounter = { n: 0 };
|
|
6494
6635
|
const recentCaptures = [];
|
|
@@ -6540,6 +6681,7 @@ async function runHealingFlow(deps) {
|
|
|
6540
6681
|
recentCaptures,
|
|
6541
6682
|
recentCaptureMeta,
|
|
6542
6683
|
anthropic,
|
|
6684
|
+
rephraseModel,
|
|
6543
6685
|
logger,
|
|
6544
6686
|
uploadFixture,
|
|
6545
6687
|
frameTarget,
|