Skip to content

Commit 257ea70

Browse files
lmorchardclaude
andcommitted
fix(webAgent): refresh snapshot after a stale-ref tool error
On a recoverable "Invalid element reference" error the loop returned needsPageSnapshot:false, so no fresh snapshot was ever added — the model was stranded on a known-bad snapshot. The recovery prompt tells the model to "wait for the next page snapshot (it will arrive automatically)"; it never did, so the model confabulated refs off a frozen view until the consecutive-error budget killed the run. Observed on Magento (Zoo onestopshop) guest checkout, but the deadlock is general to any recoverable ref error on a client-rendered page. Force a snapshot refresh on the retry only for stale/invalid-ref errors (the one case where the model's view is known-bad and the prompt promised a refresh); other recoverable errors keep prior behavior. The existing consecutive-error budget bounds any refresh loop. Scoped via shouldRefreshSnapshotAfterError, which keys on the same phrase prompts.ts uses. Validated: with this refresh, checkout reps that threw 7-15 invalid-ref errors recover and pass instead of deadlocking at 5 consecutive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9b13ad6 commit 257ea70

2 files changed

Lines changed: 73 additions & 4 deletions

File tree

packages/core/src/webAgent.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,24 @@ export function hasInteractiveRefs(tree: string): boolean {
276276
return INTERACTIVE_REF_RE.test(tree);
277277
}
278278

279+
// A stale/invalid element ref means the DOM changed under the agent and its
280+
// cached refs are gone. This is the exact phrase the recovery prompt keys on
281+
// (see prompts.ts — "Invalid element reference … does not exist on the current
282+
// page"); keep the two in sync.
283+
const STALE_REF_ERROR_RE = /invalid element reference|does not exist on the current page/i;
284+
285+
/**
286+
* After a recoverable tool error, decide whether to force a page-snapshot
287+
* refresh on the retry. Only a stale/invalid-ref error warrants it: that is the
288+
* one case where the model's cached snapshot is known-bad, and the recovery
289+
* prompt has already promised the model a fresh snapshot will arrive. Other
290+
* recoverable errors keep the prior behavior (no forced refresh). The loop's
291+
* consecutive-error budget bounds any refresh loop.
292+
*/
293+
export function shouldRefreshSnapshotAfterError(error: unknown): boolean {
294+
return error instanceof Error && STALE_REF_ERROR_RE.test(error.message);
295+
}
296+
279297
/**
280298
* Simplified WebAgent with core execution logic
281299
*/
@@ -799,9 +817,18 @@ export class WebAgent {
799817
};
800818
}
801819

802-
// Add error feedback and retry
820+
// Add error feedback and retry. On a stale/invalid-ref error the
821+
// model's cached snapshot is known-bad and the recovery prompt has
822+
// promised it a fresh one, so force a snapshot refresh; otherwise
823+
// keep the prior no-refresh behavior. Without this, a recoverable
824+
// ref error strands the agent on a frozen snapshot — it "waits" for
825+
// a refresh that never comes and confabulates refs until the
826+
// consecutive-error budget kills the run.
803827
this.addErrorFeedback(error);
804-
return { flow: "next" as const, needsPageSnapshot: false };
828+
return {
829+
flow: "next" as const,
830+
needsPageSnapshot: shouldRefreshSnapshotAfterError(error),
831+
};
805832
}
806833
},
807834
);

packages/core/test/webAgent.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2-
import { WebAgent, WebAgentOptions, hasInteractiveRefs } from "../src/webAgent.js";
2+
import {
3+
WebAgent,
4+
WebAgentOptions,
5+
hasInteractiveRefs,
6+
shouldRefreshSnapshotAfterError,
7+
} from "../src/webAgent.js";
38
import { InvalidHostnameError } from "../src/security/actionFirewall.js";
49
import {
510
AriaBrowser,
@@ -12,7 +17,12 @@ import { WebAgentEventEmitter, WebAgentEventType } from "../src/events.js";
1217
import { LanguageModel, streamText } from "ai";
1318
import { Logger } from "../src/loggers/types.js";
1419
import { generateTextWithRetry } from "../src/utils/retry.js";
15-
import { PlanningError, BrowserDisconnectedError } from "../src/errors.js";
20+
import {
21+
PlanningError,
22+
BrowserDisconnectedError,
23+
ToolExecutionError,
24+
InvalidRefException,
25+
} from "../src/errors.js";
1626
import {
1727
wrapExternalContentWithWarning,
1828
ExternalContentLabel,
@@ -5128,3 +5138,35 @@ describe("hasInteractiveRefs (SPA snapshot readiness guard)", () => {
51285138
expect(hasInteractiveRefs(`- 'button "Save: now" [ref=E1]'`)).toBe(true);
51295139
});
51305140
});
5141+
5142+
describe("shouldRefreshSnapshotAfterError (recover from stale refs)", () => {
5143+
it("refreshes on an InvalidRefException (the stale-ref signal)", () => {
5144+
expect(shouldRefreshSnapshotAfterError(new InvalidRefException("E44"))).toBe(true);
5145+
});
5146+
5147+
it("refreshes on a ToolExecutionError carrying the invalid-ref message", () => {
5148+
// This is the actual type on the recoverable-error retry path: webActionTools
5149+
// catches the BrowserException and re-throws it as a ToolExecutionError.
5150+
const err = new ToolExecutionError(
5151+
"Invalid element reference 'E44'. The element does not exist on the current page.",
5152+
{ action: "fill", ref: "E44" },
5153+
);
5154+
expect(shouldRefreshSnapshotAfterError(err)).toBe(true);
5155+
});
5156+
5157+
it("matches the 'does not exist on the current page' phrasing", () => {
5158+
expect(
5159+
shouldRefreshSnapshotAfterError(new Error("Element does not exist on the current page")),
5160+
).toBe(true);
5161+
});
5162+
5163+
it("does NOT refresh on an unrelated recoverable tool error", () => {
5164+
const err = new ToolExecutionError("Click timed out after 5000ms", { action: "click" });
5165+
expect(shouldRefreshSnapshotAfterError(err)).toBe(false);
5166+
});
5167+
5168+
it("does not throw on non-Error values", () => {
5169+
expect(shouldRefreshSnapshotAfterError(undefined)).toBe(false);
5170+
expect(shouldRefreshSnapshotAfterError("Invalid element reference 'E1'")).toBe(false);
5171+
});
5172+
});

0 commit comments

Comments
 (0)