@enricai/barnacle 1.9.12 → 1.10.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.
@@ -5572,6 +5572,114 @@ async function executeStepWithHealing(params) {
5572
5572
  // observable effect (see classifyPhantomClick). Reroutes attempt 2 to
5573
5573
  // deep-submit-locator instead of observe-act, since re-resolving via the
5574
5574
  // same light-DOM view cannot reach a target the resolver can't see.
5575
+ // Shared deepLocator actionable-candidate walk, invoked by BOTH the
5576
+ // observe-blind attempt-2/4 branch and the phantom-driven `trusted-click-retry`
5577
+ // branch. Walks ranked `candidates`, skipping wizard-exit and not-actionable
5578
+ // ones, actuating the first that sticks (`clickFirstActionableCandidate`), then
5579
+ // synthesizes a `resolvedAction` the standard verifier consumes. Returns
5580
+ // `resolvedAction` on success, or `null` on failure (the caller `continue`s).
5581
+ // `preferTrustedClick` forwards to `clickDeepLocatorCandidate` so the retry
5582
+ // path activates via the trusted CDP click; `labelPrefix` distinguishes the
5583
+ // two callers in the audit record. On failure the running `triedSelectors`
5584
+ // (which already holds prior attempts' resolved xpath) is written to
5585
+ // `record.triedSelectors`, not just this walk's own — so a walk that resolved
5586
+ // only deny-listed candidates still exposes a prior xpath to
5587
+ // `shouldSkipTechnique`'s `anyXpathResolved` gate, keeping `structured-click`
5588
+ // reachable at attempt 3.
5589
+ const runDeepLocatorClickWalk = async (args) => {
5590
+ const { candidates, innerSelector, preferTrustedClick, labelPrefix, record, attempt, pre } = args;
5591
+ const attemptTriedSelectors = [];
5592
+ const denyCandidate = (candidate) => {
5593
+ const denied = isWizardExitAction(candidate.accessibleText, wizardExitButtonLabels);
5594
+ if (denied) {
5595
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: refused wizard-exit control: "${candidate.accessibleText.slice(0, 60)}"`);
5596
+ }
5597
+ return denied;
5598
+ };
5599
+ const actuation = resolveDeepLocatorActuation(step);
5600
+ const isSelectionIntentClick = actuation.kind === "click" && parseSelectStep(step) !== null;
5601
+ const baselineSelectionCount = isSelectionIntentClick
5602
+ ? selectionCountFromSignature(pre.visibleTextSignature)
5603
+ : null;
5604
+ const readSelectionCount = isSelectionIntentClick
5605
+ ? async () => {
5606
+ try {
5607
+ const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5608
+ return selectionCountFromSignature(snap.visibleTextSignature);
5609
+ }
5610
+ catch {
5611
+ return null;
5612
+ }
5613
+ }
5614
+ : undefined;
5615
+ const cascadeResult = await (0, deep_locator_click_1.clickFirstActionableCandidate)(candidates, async (candidate) => {
5616
+ attemptTriedSelectors.push(candidate.selector);
5617
+ if (actuation.kind === "select") {
5618
+ const verified = await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget?.frameSelector, innerSelector, candidate.index, actuation.value, { frameTarget });
5619
+ // selectDeepLocatorCandidateOption never throws on an ordinary failed
5620
+ // write (see deep-locator-actuate.ts's writeAndVerify) — it resolves
5621
+ // `false` for both a rejected selectOption() and a read-back mismatch.
5622
+ // clickFirstActionableCandidate infers success from "didn't throw", so
5623
+ // a `false` here must become a throw or the walk would wrongly report
5624
+ // this candidate as actuated.
5625
+ if (!verified)
5626
+ throw new Error("-32000 Node does not have a layout object");
5627
+ }
5628
+ else if (actuation.kind === "fill") {
5629
+ const verified = await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget?.frameSelector, innerSelector, candidate.index, actuation.value, { frameTarget });
5630
+ if (!verified)
5631
+ throw new Error("-32000 Node does not have a layout object");
5632
+ }
5633
+ else {
5634
+ await (0, deep_locator_candidates_1.clickDeepLocatorCandidate)(page, frameTarget?.frameSelector, innerSelector, candidate.index, {
5635
+ frameTarget,
5636
+ preferTrustedClick,
5637
+ });
5638
+ }
5639
+ }, { denyCandidate, readSelectionCount, baselineSelectionCount })
5640
+ .then((outcome) => ({ outcome, error: null }))
5641
+ .catch((error) => ({ outcome: null, error }));
5642
+ triedSelectors.push(...attemptTriedSelectors);
5643
+ if (cascadeResult.outcome?.clicked && cascadeResult.outcome.candidate) {
5644
+ const acted = cascadeResult.outcome.candidate;
5645
+ record.triedSelectors = [...attemptTriedSelectors];
5646
+ const verb = actuation.kind === "select" ? "selected" : actuation.kind === "fill" ? "filled" : "clicked";
5647
+ record.instruction = `${labelPrefix}: ${acted.accessibleText || "(no accessible text)"}`;
5648
+ record.actResultSuccess = true;
5649
+ record.actResultDescription = `${labelPrefix} ${verb} "${acted.accessibleText || acted.selector}"`;
5650
+ // Synthesize an action matching the actuation kind so downstream
5651
+ // verification (network/url/dom — STATE_CLASS_METHODS treats
5652
+ // fill/selectOption as DOM-verifiable, not click) treats this exactly like
5653
+ // any other resolved action — same idiom as deep-submit-locator/structured-click.
5654
+ const resolvedAction = actuation.kind === "click"
5655
+ ? { selector: acted.selector, description: record.actResultDescription, method: "click" }
5656
+ : {
5657
+ selector: acted.selector,
5658
+ description: record.actResultDescription,
5659
+ method: actuation.kind === "select" ? "selectOption" : "fill",
5660
+ arguments: [actuation.value],
5661
+ };
5662
+ return { resolvedAction };
5663
+ }
5664
+ // Failure: carry the running triedSelectors (prior xpath + this walk's own)
5665
+ // into the record so anyXpathResolved stays true even when every resolved
5666
+ // candidate was deny-listed (attemptTriedSelectors would be empty then).
5667
+ record.triedSelectors = [...triedSelectors];
5668
+ const counterStalledCount = cascadeResult.outcome?.counterStalledSelectors.length ?? 0;
5669
+ const failureMessage = cascadeResult.error
5670
+ ? `${labelPrefix}: ${actuation.kind} threw ${(0, errors_1.toErrorMessage)(cascadeResult.error)}`
5671
+ : counterStalledCount > 0
5672
+ ? `${labelPrefix}: clicked ${counterStalledCount} candidate(s) but the "N selected" counter never rose (no selection registered)`
5673
+ : attemptTriedSelectors.length > 0
5674
+ ? `${labelPrefix}: no actionable candidate (${attemptTriedSelectors.length} not-actionable)`
5675
+ : `${labelPrefix}: no actionable candidate (every candidate refused by the wizard-exit deny-list)`;
5676
+ record.actResultSuccess = false;
5677
+ record.errorMessage = failureMessage;
5678
+ attempts.push(record);
5679
+ failureReasons.push(failureMessage);
5680
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5681
+ return { resolvedAction: null };
5682
+ };
5575
5683
  let phantomClickAfterAttempt1 = false;
5576
5684
  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
5577
5685
  // Telemetry-driven technique-skip: when a cascade technique's
@@ -5583,7 +5691,9 @@ async function executeStepWithHealing(params) {
5583
5691
  const wouldBeTechnique = attempt === 2
5584
5692
  ? phantomClickAfterAttempt1 && (isFinalStep || submitStep)
5585
5693
  ? "deep-submit-locator"
5586
- : "observe-act"
5694
+ : phantomClickAfterAttempt1
5695
+ ? "trusted-click-retry"
5696
+ : "observe-act"
5587
5697
  : attempt === 3
5588
5698
  ? "structured-click"
5589
5699
  : attempt === 4
@@ -5842,6 +5952,62 @@ async function executeStepWithHealing(params) {
5842
5952
  }
5843
5953
  }
5844
5954
  }
5955
+ else if (attempt === 2 && phantomClickAfterAttempt1 && !(isFinalStep || submitStep)) {
5956
+ // Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
5957
+ // Stagehand reported success but pre/post showed zero effect. On a
5958
+ // design-system widget (React synthetic-event delegation, custom
5959
+ // event-bound wrappers) the likely cause is that the attempt-1 click was
5960
+ // an in-page, `isTrusted=false` activation the handler ignores — the
5961
+ // element resolves fine, but only a REAL user gesture registers. So
5962
+ // re-resolve the target from the step's tagged phrases and re-click it
5963
+ // via the trusted CDP path (`clickDeepLocatorCandidate` →
5964
+ // `deepLocator().nth().click()`, an `Input.dispatchMouseEvent`). This is
5965
+ // the non-submit sibling of the deep-submit-locator escalation above.
5966
+ record.technique = "trusted-click-retry";
5967
+ // Carry forward any selector prior attempts already resolved (e.g.
5968
+ // attempt-1 act-string's xpath) so a trusted-click-retry that can't
5969
+ // resolve a deepLocator candidate doesn't erase the xpath signal
5970
+ // structured-click's precondition (shouldSkipTechnique) depends on —
5971
+ // this attempt is an ADDITIONAL recovery, not a replacement that
5972
+ // demotes the rest of the ladder.
5973
+ if (!frameTarget?.frame) {
5974
+ const failureMessage = "trusted-click-retry: no frame seam available for a trusted deepLocator click";
5975
+ record.actResultSuccess = false;
5976
+ record.errorMessage = failureMessage;
5977
+ record.triedSelectors = [...triedSelectors];
5978
+ attempts.push(record);
5979
+ failureReasons.push(failureMessage);
5980
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5981
+ continue;
5982
+ }
5983
+ await reresolveFrameTargetIfLost();
5984
+ const { candidates: retryCandidates, innerSelector: retryInnerSelector } = await resolveDeepLocatorCandidatesWithWidening(page, frameTarget.frameSelector, step, {
5985
+ frameTarget,
5986
+ });
5987
+ const deepLocatorCandidates = retryCandidates.filter((c) => !triedSelectors.includes(c.selector));
5988
+ if (deepLocatorCandidates.length === 0) {
5989
+ const failureMessage = "trusted-click-retry: no deepLocator candidate resolved for the phantomed target";
5990
+ record.actResultSuccess = false;
5991
+ record.errorMessage = failureMessage;
5992
+ record.triedSelectors = [...triedSelectors];
5993
+ attempts.push(record);
5994
+ failureReasons.push(failureMessage);
5995
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
5996
+ continue;
5997
+ }
5998
+ const { resolvedAction: retryResolvedAction } = await runDeepLocatorClickWalk({
5999
+ candidates: deepLocatorCandidates,
6000
+ innerSelector: retryInnerSelector,
6001
+ preferTrustedClick: true,
6002
+ labelPrefix: "trusted-click-retry",
6003
+ record,
6004
+ attempt,
6005
+ pre,
6006
+ });
6007
+ if (!retryResolvedAction)
6008
+ continue;
6009
+ resolvedAction = retryResolvedAction;
6010
+ }
5845
6011
  else if (attempt === 2 || attempt === 4) {
5846
6012
  record.technique = attempt === 2 ? "observe-act" : "observe-act-exclude";
5847
6013
  const observeOptions = attempt === 4 && triedSelectors.length > 0
@@ -5929,128 +6095,26 @@ async function executeStepWithHealing(params) {
5929
6095
  continue;
5930
6096
  }
5931
6097
  }
5932
- // Actionable-candidate walk: a top pick that rejects with the CDP
5933
- // `-32000 Node does not have a layout object` error (an unrendered
5934
- // node) or is refused by the wizard-exit deny-list costs only that
5935
- // one candidate, not the whole attempt the next ranked candidate
5936
- // is tried instead. `attemptTriedSelectors` parallels the walk's own
5937
- // click attempts as they happen (not just on a successful return)
5938
- // so a click that throws a REAL error (e.g. a wedged
5939
- // `WatchdogTimeoutError`) still feeds attempt 4's exclusion filter.
5940
- const attemptTriedSelectors = [];
5941
- const denyCandidate = (candidate) => {
5942
- const denied = isWizardExitAction(candidate.accessibleText, wizardExitButtonLabels);
5943
- if (denied) {
5944
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: refused wizard-exit control: "${candidate.accessibleText.slice(0, 60)}"`);
5945
- }
5946
- return denied;
5947
- };
5948
- // Intent discrimination: observe()'s Stagehand-resolved action
5949
- // carries its own method + fill/select arguments (see attempt 1's
5950
- // `target.method === "fill"` handling above); the deepLocator
5951
- // fallback has no such resolved action, so the step prose is the
5952
- // only place the fill/select value can come from. Derived once per
5953
- // attempt (not per candidate) — every candidate in this walk is a
5954
- // guess at the SAME step's target, so they all actuate the same way.
5955
- const actuation = resolveDeepLocatorActuation(step);
5956
- // Next-best-candidate recovery for a markerless multi-select phantom:
5957
- // a click-to-toggle option (not a native <select> write, which
5958
- // already read-back-verifies) whose "N selected" counter doesn't rise
5959
- // means the click landed but didn't register — so the walk should try
5960
- // the next real option before this attempt is scored a failure and a
5961
- // replan is spent. Only wired for a selection-intent CLICK step; the
5962
- // reader re-reads the same counter `pre` captured (via
5963
- // `selectionCountFromSignature`) so no extra pre-snapshot is paid, and
5964
- // returns `null` on any frame hiccup so a read failure never converts
5965
- // to a walk-stopping throw. Non-selection steps pass `undefined` and
5966
- // the walk behaves exactly as before.
5967
- const isSelectionIntentClick = actuation.kind === "click" && parseSelectStep(step) !== null;
5968
- const baselineSelectionCount = isSelectionIntentClick
5969
- ? selectionCountFromSignature(pre.visibleTextSignature)
5970
- : null;
5971
- const readSelectionCount = isSelectionIntentClick
5972
- ? async () => {
5973
- try {
5974
- const snap = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5975
- return selectionCountFromSignature(snap.visibleTextSignature);
5976
- }
5977
- catch {
5978
- return null;
5979
- }
5980
- }
5981
- : undefined;
5982
- const cascadeResult = await (0, deep_locator_click_1.clickFirstActionableCandidate)(deepLocatorCandidates, async (candidate) => {
5983
- attemptTriedSelectors.push(candidate.selector);
5984
- if (actuation.kind === "select") {
5985
- const verified = await (0, deep_locator_actuate_1.selectDeepLocatorCandidateOption)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, actuation.value, { frameTarget });
5986
- // selectDeepLocatorCandidateOption never throws on an ordinary
5987
- // failed write (see deep-locator-actuate.ts's writeAndVerify) —
5988
- // it resolves `false` for both a rejected selectOption() and a
5989
- // read-back mismatch. clickFirstActionableCandidate infers
5990
- // success from "didn't throw", so a `false` here must become a
5991
- // throw or the walk would wrongly report this candidate as
5992
- // actuated.
5993
- if (!verified)
5994
- throw new Error("-32000 Node does not have a layout object");
5995
- }
5996
- else if (actuation.kind === "fill") {
5997
- const verified = await (0, deep_locator_actuate_1.fillDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, actuation.value, { frameTarget });
5998
- if (!verified)
5999
- throw new Error("-32000 Node does not have a layout object");
6000
- }
6001
- else {
6002
- await (0, deep_locator_candidates_1.clickDeepLocatorCandidate)(page, frameTarget?.frameSelector, deepLocatorInnerSelector, candidate.index, { frameTarget });
6003
- }
6004
- }, { denyCandidate, readSelectionCount, baselineSelectionCount })
6005
- .then((outcome) => ({ outcome, error: null }))
6006
- .catch((error) => ({ outcome: null, error }));
6007
- triedSelectors.push(...attemptTriedSelectors);
6008
- record.triedSelectors = [...attemptTriedSelectors];
6009
- if (cascadeResult.outcome?.clicked && cascadeResult.outcome.candidate) {
6010
- const acted = cascadeResult.outcome.candidate;
6011
- const verb = actuation.kind === "select"
6012
- ? "selected"
6013
- : actuation.kind === "fill"
6014
- ? "filled"
6015
- : "clicked";
6016
- record.instruction = `deepLocator: ${acted.accessibleText || "(no accessible text)"}`;
6017
- record.actResultSuccess = true;
6018
- record.actResultDescription = `deepLocator ${verb} "${acted.accessibleText || acted.selector}"`;
6019
- // Synthesize an action matching the actuation kind so downstream
6020
- // verification (network/url/dom — STATE_CLASS_METHODS treats
6021
- // fill/selectOption as DOM-verifiable, not click) treats this
6022
- // exactly like any other resolved action — same idiom as
6023
- // deep-submit-locator/structured-click.
6024
- resolvedAction =
6025
- actuation.kind === "click"
6026
- ? {
6027
- selector: acted.selector,
6028
- description: record.actResultDescription,
6029
- method: "click",
6030
- }
6031
- : {
6032
- selector: acted.selector,
6033
- description: record.actResultDescription,
6034
- method: actuation.kind === "select" ? "selectOption" : "fill",
6035
- arguments: [actuation.value],
6036
- };
6037
- }
6038
- else {
6039
- const counterStalledCount = cascadeResult.outcome?.counterStalledSelectors.length ?? 0;
6040
- const failureMessage = cascadeResult.error
6041
- ? `deepLocator: ${actuation.kind} threw ${(0, errors_1.toErrorMessage)(cascadeResult.error)}`
6042
- : counterStalledCount > 0
6043
- ? `deepLocator: clicked ${counterStalledCount} candidate(s) but the "N selected" counter never rose (no selection registered)`
6044
- : attemptTriedSelectors.length > 0
6045
- ? `deepLocator: no actionable candidate (${attemptTriedSelectors.length} not-actionable)`
6046
- : "deepLocator: no actionable candidate (every candidate refused by the wizard-exit deny-list)";
6047
- record.actResultSuccess = false;
6048
- record.errorMessage = failureMessage;
6049
- attempts.push(record);
6050
- failureReasons.push(failureMessage);
6051
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt}: ${failureMessage}`);
6098
+ // Actionable-candidate walk (shared with the trusted-click-retry
6099
+ // branch): a top pick that rejects with the CDP `-32000 Node does not
6100
+ // have a layout object` error (an unrendered node) or is refused by
6101
+ // the wizard-exit deny-list costs only that one candidate, not the
6102
+ // whole attempt — the next ranked candidate is tried instead. The
6103
+ // observe-act path activates via the synthetic fast click
6104
+ // (preferTrustedClick: false); the trusted CDP click is the
6105
+ // phantom-retry escalation.
6106
+ const { resolvedAction: walkResolvedAction } = await runDeepLocatorClickWalk({
6107
+ candidates: deepLocatorCandidates,
6108
+ innerSelector: deepLocatorInnerSelector,
6109
+ preferTrustedClick: false,
6110
+ labelPrefix: "deepLocator",
6111
+ record,
6112
+ attempt,
6113
+ pre,
6114
+ });
6115
+ if (!walkResolvedAction)
6052
6116
  continue;
6053
- }
6117
+ resolvedAction = walkResolvedAction;
6054
6118
  }
6055
6119
  else if (candidates.length === 0) {
6056
6120
  record.errorMessage = "observe returned no candidates";
@@ -7149,7 +7213,7 @@ async function executeStepWithHealing(params) {
7149
7213
  const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
7150
7214
  const escalationTarget = isFinalStep || submitStep
7151
7215
  ? "escalating attempt 2 to deep-submit-locator"
7152
- : "non-submit step — leaving the normal structured-click/observe-act-exclude ladder intact";
7216
+ : "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)";
7153
7217
  logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} phantom click detected on attempt 1 (${record.technique}): reported success with no network/url/dom change${suppressedCount !== undefined ? `; ${suppressedCount} AISDK elementId errors suppressed this session (corroborating, not causal)` : ""} — ${escalationTarget}`);
7154
7218
  }
7155
7219
  const postAttemptInvalidCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));