Repository navigation
Name recurring failure modes for agents (issue #465) #487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| // Issue #465 §1 / #454: pages with anti-debugging protection reload | ||
| // themselves whenever the debugger attaches, so repeated reads keep | ||
| // failing with the same identity error. The streak rule must name the | ||
| // condition only after demonstrated repetition — and only on the same | ||
| // page: maintainer review added that navigating the same tab to three | ||
| // sites (one transient failure each) must not falsely diagnose the | ||
| // third site. | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
| import { | ||
| nextObserveIdentityStreak, | ||
| OBSERVE_IDENTITY_LOOP_FAILURES, | ||
| observeIdentityReloadLoopError, | ||
| } from "../observation"; | ||
| import { OBSERVE_IDENTITY_CHANGED } from "../vom/capture-coordinator"; | ||
|
|
||
| const SITE_A = "https://site-a.example/search"; | ||
| const SITE_B = "https://site-b.example/search"; | ||
| const SITE_C = "https://site-c.example/search"; | ||
|
|
||
| describe("observe identity reload-loop streak", () => { | ||
| it("does not flag an isolated failure", () => { | ||
| const first = nextObserveIdentityStreak(undefined, SITE_A, 1_000, 120_000); | ||
| expect(first.isReloadLoop).toBe(false); | ||
| expect(first.streak).toEqual({ | ||
| url: SITE_A, | ||
| count: 1, | ||
| firstAt: 1_000, | ||
| lastAt: 1_000, | ||
| }); | ||
| }); | ||
|
|
||
| it("needs consecutive same-page failures inside the window before naming anti-debugging", () => { | ||
| const first = nextObserveIdentityStreak(undefined, SITE_A, 0, 120_000); | ||
| const second = nextObserveIdentityStreak(first.streak, SITE_A, 3_000, 120_000); | ||
| expect(second.isReloadLoop).toBe(false); | ||
| expect(second.streak).toEqual({ | ||
| url: SITE_A, | ||
| count: 2, | ||
| firstAt: 0, | ||
| lastAt: 3_000, | ||
| }); | ||
| const third = nextObserveIdentityStreak(second.streak, SITE_A, 6_000, 120_000); | ||
| expect(third.isReloadLoop).toBe(true); | ||
| expect(third.streak.count).toBe(OBSERVE_IDENTITY_LOOP_FAILURES); | ||
| }); | ||
|
|
||
| it("a navigation to another site restarts the streak (regression: one transient failure on each of three sites must never diagnose site three)", () => { | ||
| let next = nextObserveIdentityStreak(undefined, SITE_A, 0, 120_000); | ||
| expect(next.isReloadLoop).toBe(false); | ||
| next = nextObserveIdentityStreak(next.streak, SITE_B, 1_000, 120_000); | ||
| expect(next.isReloadLoop).toBe(false); | ||
| const third = nextObserveIdentityStreak(next.streak, SITE_C, 2_000, 120_000); | ||
| // The third site has failed only once; the diagnosis must never carry | ||
| // across navigations. | ||
| expect(third.isReloadLoop).toBe(false); | ||
| expect(third.streak).toEqual({ | ||
| url: SITE_C, | ||
| count: 1, | ||
| firstAt: 2_000, | ||
| lastAt: 2_000, | ||
| }); | ||
| }); | ||
|
|
||
| it("failures spread wider than the window restart the streak", () => { | ||
| const first = nextObserveIdentityStreak(undefined, SITE_A, 0, 120_000); | ||
| const next = nextObserveIdentityStreak(first.streak, SITE_A, 120_001, 120_000); | ||
| expect(next.isReloadLoop).toBe(false); | ||
| expect(next.streak.count).toBe(1); | ||
| // And a later failure still counts alone, so slow pages never trigger it. | ||
| const again = nextObserveIdentityStreak(next.streak, SITE_A, 130_000, 120_000); | ||
| expect(again.isReloadLoop).toBe(false); | ||
| expect(again.streak.count).toBe(2); | ||
| }); | ||
|
|
||
| it("repeated reloads of the same page keep being detected after site switches", () => { | ||
| // The streak carries per page: two failures on site A are forgotten | ||
| // on navigation, then a genuine loop on the new site is diagnosed | ||
| // once three failures accumulate on *it*. | ||
| let next = nextObserveIdentityStreak(undefined, SITE_A, 0, 120_000); | ||
| next = nextObserveIdentityStreak(next.streak, SITE_A, 1_000, 120_000); | ||
| next = nextObserveIdentityStreak(next.streak, SITE_B, 2_000, 120_000); // navigate away | ||
| expect(next.isReloadLoop).toBe(false); // previous site's failures are not carried | ||
| next = nextObserveIdentityStreak(next.streak, SITE_B, 3_000, 120_000); | ||
| expect(next.isReloadLoop).toBe(false); | ||
| next = nextObserveIdentityStreak(next.streak, SITE_B, 4_000, 120_000); | ||
| expect(next.isReloadLoop).toBe(true); | ||
| expect(next.streak.url).toBe(SITE_B); | ||
| }); | ||
|
|
||
| it("defaults the streak window to the published constant", () => { | ||
| const first = nextObserveIdentityStreak(undefined, SITE_A, 0); | ||
| expect(first.streak.count).toBe(1); | ||
| }); | ||
|
|
||
| it("keeps the stable anti-debugging marker importable for detection", () => { | ||
| expect(OBSERVE_IDENTITY_CHANGED).toContain("observation document identity changed"); | ||
| }); | ||
| }); | ||
|
|
||
| // Handler-level plumbing (maintainer review, P1): `ResolvedTargetTab.url` | ||
| // is optional, and a failure on a tab whose URL is unavailable must not | ||
| // count toward a page-specific streak — the original error is preserved. | ||
| describe("observe identity reload-loop error plumbing", () => { | ||
| const ctxOf = (sessionId: string) => | ||
| ({ sessionId }) as unknown as Parameters<typeof observeIdentityReloadLoopError>[0]; | ||
| const identityFailure = () => new Error(OBSERVE_IDENTITY_CHANGED); | ||
|
|
||
| it("never counts failures with an unavailable URL toward a streak", () => { | ||
| const ctx = ctxOf("sess-nourl"); | ||
| // If these were counted anywhere shared, the third failure would flip | ||
| // to the anti-debugging error; each must instead round-trip to the | ||
| // plain identity-changed cdp_failed path. | ||
| for (const url of [undefined, "", undefined]) { | ||
| expect(observeIdentityReloadLoopError(ctx, 7, url, identityFailure())).toBeNull(); | ||
| } | ||
| }); | ||
|
|
||
| it("still names the loop once a URL is known, with the page-scoped reason", () => { | ||
| const ctx = ctxOf("sess-withurl"); | ||
| expect(observeIdentityReloadLoopError(ctx, 11, SITE_A, identityFailure())).toBeNull(); | ||
| expect(observeIdentityReloadLoopError(ctx, 11, SITE_A, identityFailure())).toBeNull(); | ||
| const third = observeIdentityReloadLoopError(ctx, 11, SITE_A, identityFailure()); | ||
| expect(third).not.toBeNull(); | ||
| expect(third?.code).toBe("cdp_failed"); | ||
| expect(third?.data?.reason).toBe("observe_identity_reload_loop"); | ||
| expect(third?.message).toContain(OBSERVE_IDENTITY_CHANGED); | ||
| }); | ||
|
|
||
| it("an unavailable URL does not seed a later same-tab streak", () => { | ||
| const ctx = ctxOf("sess-seed"); | ||
| expect(observeIdentityReloadLoopError(ctx, 5, undefined, identityFailure())).toBeNull(); | ||
| // The tab resolves to a URL afterwards: only its own failures count. | ||
| expect(observeIdentityReloadLoopError(ctx, 5, SITE_B, identityFailure())).toBeNull(); | ||
| }); | ||
|
|
||
| it("keeps unrelated errors untouched", () => { | ||
| const ctx = ctxOf("sess-other"); | ||
| expect(observeIdentityReloadLoopError(ctx, 3, SITE_C, new Error("boom"))).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,7 +70,11 @@ import { | |
| import { lookupRefTarget, resolveSnapshotRef } from "./snapshot-ref"; | ||
| import { captureControlledViewport } from "./viewport-screenshot"; | ||
| import { type CapturedNode, type CapturedSurfaceProbe, probeHoverSurfaces } from "./vom/capture"; | ||
| import { semanticCapture, withObservationFacts } from "./vom/capture-coordinator"; | ||
| import { | ||
| OBSERVE_IDENTITY_CHANGED, | ||
| semanticCapture, | ||
| withObservationFacts, | ||
| } from "./vom/capture-coordinator"; | ||
| import type { FrameDocument as CapturedFrameDocument } from "./vom/frame-document"; | ||
| import { withOverlayBypass } from "./vom/hover-perception"; | ||
| import { probeTooltipNames } from "./vom/name-enrichment"; | ||
|
|
@@ -1078,6 +1082,92 @@ async function buildVomObservation( | |
| }); | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Anti-debugging reload-loop detection (issues #454 / #465). Sites that | ||
| // detect the debugger attach reload themselves, so every repeated read | ||
| // fails with the same identity error while the page keeps churning. | ||
| // After consecutive identity failures on the same tab inside a short | ||
| // window we name the condition with a stable reason instead of letting | ||
| // the agent retry (and never point it at a reload). | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| interface ObserveIdentityStreak { | ||
| url: string; | ||
| count: number; | ||
| firstAt: number; | ||
| lastAt: number; | ||
| } | ||
|
|
||
| const observeIdentityStreaks = new Map<string, ObserveIdentityStreak>(); | ||
|
|
||
| /** Identity failures spread wider than this never count as a reload loop. */ | ||
| export const OBSERVE_IDENTITY_LOOP_WINDOW_MS = 120_000; | ||
|
|
||
| /** Consecutive identity failures before the failure is named anti-debugging. */ | ||
| export const OBSERVE_IDENTITY_LOOP_FAILURES = 3; | ||
|
|
||
| /** Identity-failure bookkeeping key for one session's tab. */ | ||
| function observeIdentityKey(sessionId: string, tabId: number): string { | ||
| return `${sessionId}:${tabId}`; | ||
| } | ||
|
|
||
| /** | ||
| * Pure rule so the streak logic is testable without a browser: a failure | ||
| * joins the previous streak only when it is on the **same page** (identical | ||
| * URL) and happens within `windowMs` of the streak's first failure; a | ||
| * navigation to another site or an expired window restarts the counter | ||
| * (maintainer review of issue #465: one transient failure each on three | ||
| * different sites must not falsely diagnose the third site). | ||
| */ | ||
| export function nextObserveIdentityStreak( | ||
| previous: ObserveIdentityStreak | undefined, | ||
| url: string, | ||
| failureAt: number, | ||
| windowMs: number = OBSERVE_IDENTITY_LOOP_WINDOW_MS, | ||
| ): { streak: ObserveIdentityStreak; isReloadLoop: boolean } { | ||
| const continues = | ||
| previous !== undefined && previous.url === url && failureAt - previous.firstAt <= windowMs; | ||
| const streak: ObserveIdentityStreak = continues | ||
| ? { | ||
| url, | ||
| count: previous!.count + 1, | ||
| firstAt: previous!.firstAt, | ||
| lastAt: failureAt, | ||
| } | ||
| : { url, count: 1, firstAt: failureAt, lastAt: failureAt }; | ||
| return { streak, isReloadLoop: streak.count >= OBSERVE_IDENTITY_LOOP_FAILURES }; | ||
| } | ||
|
|
||
| /** | ||
| * Turn a repeated observation identity failure into the distinct | ||
| * anti-debugging error; returns null while the failure still looks | ||
| * transient so the normal `cdp_failed` path applies. | ||
| * | ||
| * The streak is page-scoped, so an observation whose target could not | ||
| * be resolved to a URL never counts toward one — that failure keeps | ||
| * the plain identity-changed error (maintainer review of issue #465, | ||
| * P1: `ResolvedTargetTab.url` is optional). Exported for tests. | ||
| */ | ||
| export function observeIdentityReloadLoopError( | ||
| ctx: SessionContext, | ||
| tabId: number, | ||
| tabUrl: string | undefined, | ||
| err: unknown, | ||
| ): RpcError | null { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| if (!message.includes(OBSERVE_IDENTITY_CHANGED)) return null; | ||
| if (!tabUrl) return null; | ||
| const key = observeIdentityKey(ctx.sessionId, tabId); | ||
| const next = nextObserveIdentityStreak(observeIdentityStreaks.get(key), tabUrl, Date.now()); | ||
| observeIdentityStreaks.set(key, next.streak); | ||
| if (!next.isReloadLoop) return null; | ||
| return rpcError( | ||
| "cdp_failed", | ||
| "observe_identity_reload_loop", | ||
| `${OBSERVE_IDENTITY_CHANGED} — the page lost observation identity on ${next.streak.count} consecutive reads (the site is likely reloading itself when the debugger attaches; anti-debugging)`, | ||
| ); | ||
| } | ||
|
|
||
| async function handleVomObservation( | ||
| manager: SessionManager, | ||
| params: SnapshotParams | ObserveParams, | ||
|
|
@@ -1104,6 +1194,11 @@ async function handleVomObservation( | |
| if (denied) return denied; | ||
| const dialogCursor = markDialogCursor(deps.cdp, target.tabId); | ||
|
|
||
| // Anti-debugging reload-loop bookkeeping: the streak is cleared the | ||
| // moment any read of this tab succeeds (see the helper above). | ||
| const identityKey = observeIdentityKey(ctx.sessionId, target.tabId); | ||
| let readSucceeded = false; | ||
|
|
||
| try { | ||
| throwIfAborted(signal, toolName); | ||
| deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); | ||
|
|
@@ -1127,7 +1222,9 @@ async function handleVomObservation( | |
| { cursor, maxTokens: params.max_tokens }, | ||
| signal, | ||
| ); | ||
| return isRpcError(page) ? page : attachDialogs(deps.cdp, target.tabId, dialogCursor, page); | ||
| if (isRpcError(page)) return page; | ||
| readSucceeded = true; | ||
| return attachDialogs(deps.cdp, target.tabId, dialogCursor, page); | ||
| } | ||
| clearObservationContinuation(ctx.refStore); | ||
| const documentRevision = ctx.refStore.documentRevision(target.tabId); | ||
|
|
@@ -1158,6 +1255,7 @@ async function handleVomObservation( | |
| signal, | ||
| ); | ||
| if (isRpcError(page)) return page; | ||
| readSucceeded = true; | ||
| return attachDialogs(deps.cdp, target.tabId, dialogCursor, { | ||
| ...page, | ||
| ...(observation.hoverProbe?.performed | ||
|
|
@@ -1201,6 +1299,7 @@ async function handleVomObservation( | |
| ] as const; | ||
| }), | ||
| ); | ||
| readSucceeded = true; | ||
| return attachDialogs(deps.cdp, target.tabId, dialogCursor, { | ||
| text: observation.text, | ||
| ref_count: observation.refs.length, | ||
|
|
@@ -1232,11 +1331,17 @@ async function handleVomObservation( | |
| // Fresh observations clear their predecessor before capture. Continuation owns | ||
| // invalidation; cancellation must preserve its unpublished page for retry. | ||
| if (isAbortError(err)) return cancelled(toolName); | ||
| const identityLoopError = observeIdentityReloadLoopError(ctx, target.tabId, target.url, err); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Handle the optional target URL before passing it to the streak helper
The frontend CI workflow runs this command, so the passing unit tests do not cover this merge blocker. I also checked the previous observation implementation in the same environment, where the type check passes. Could we narrow the URL before calling the helper, or explicitly support a missing URL? For example, when the URL is unavailable, preserve the original error without counting that failure toward a page-specific streak. This would keep the fix type-safe without assuming that every resolved tab has a URL.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 74c0951, following the "explicitly support a missing URL" option: Rationale for not counting rather than falling back to a shared key: bucketing URL-less failures together on one tab would recreate exactly the false carry-over fixed for the three-sites scenario in 93e711a, just with Verified with the command you quoted: |
||
| if (identityLoopError) return identityLoopError; | ||
| return { | ||
| code: "cdp_failed", | ||
| message: err instanceof Error ? err.message : String(err), | ||
| ...readTimeoutDetails(err), | ||
| }; | ||
| } finally { | ||
| if (readSucceeded) { | ||
| observeIdentityStreaks.delete(identityKey); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Reset or scope the failure streak when the target page changes
The key only contains the session and tab IDs, so navigating the same tab to another site keeps the previous site's failures. I reproduced this in a handler-level test by changing the tab URL and invalidating its refs between three observations: one simulated transient identity failure on each of three different sites, all within 120 seconds, causes the third site to receive
observe_identity_reload_loop.That site has only failed once, but the new CLI hint tells the agent to stop observing and hand the task back. Following the suggestion to try another page in the same tab can therefore carry the false diagnosis into that page too.
Could we reset or scope the streak when the target page changes, while preserving detection of repeated reloads of the same page? A regression test covering navigation between different sites would help keep this distinction intact.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 93e711a. The streak entry now carries the observed target URL and only continues when the URL is unchanged (
previous.url === url); a navigation — or an expired window — restarts the count from 1, while repeated failures on the same page are still counted (OBSERVE_IDENTITY_LOOP_FAILURES = 3, unchanged). The handler passestarget.url(the same page identitycaptureVomObservationkeys off), and the success path clears the entry.Regression tests added in
src/tools/__tests__/observe-identity-loop.test.ts, rule-level (same shape as the pure helper you reproduced through):observe_identity_reload_loop(countresets to 1 onurlchange),