Skip to content

cache locator-scoped act, observe & extract instead of bypassing - #2712

Open
sameelarif wants to merge 1 commit into
mainfrom
sameelarif/stg-2803-update-cache-api-for-locator-based-actextractobserve-params
Open

cache locator-scoped act, observe & extract instead of bypassing#2712
sameelarif wants to merge 1 commit into
mainfrom
sameelarif/stg-2803-update-cache-api-for-locator-based-actextractobserve-params

Conversation

@sameelarif

@sameelarif sameelarif commented Aug 13, 2026

Copy link
Copy Markdown
Member

why

Server is updated to accept new locator params so we can remove the client-side bypass.

what changed

Removed locator based cache bypass boolean

test plan


Summary by cubic

Caches locator‑scoped act(), observe(), and extract() calls instead of bypassing them. The cache key now includes locator and ignoreLocators (with nth), and the page fingerprint is scoped to the locator subtree with ignored subtrees excluded.

  • Resolves the focus locator to focusBackendNodeId and ignore locators to ignoredBackendNodeIds; an unresolvable focus locator skips caching.
  • Resolution is main‑frame only; iframe‑only matches still key via the literal locator fields but aren’t pruned, leading to conservative misses, not collisions.
  • Updates cacheService to include locator fields in cache data and to collect CDP tree with scope node ids; actService, observeService, and extractService pass focusLocator and ignoreLocators.
  • Adds ignoredBackendNodeIds to the cacheClient schema and updates docs/tests to reflect the new behavior.

Rollout

  • Requires the server cache API that keys on locator/ignoreLocators (STG‑2803). Do not ship until deployed to all regions; older servers would collide locator‑scoped requests with unscoped keys.
  • No caller changes are required; locator‑scoped requests now return MISS/HIT instead of DISABLED.
  • If locator does not resolve on the page, the request executes without cache.

Written for commit 7ddb2cf. Summary will update on new commits.

Review in cubic

The server cache API now keys on locator/ignoreLocators (STG-2803), so
the client-side bypass is removed. Cache data includes the locator
params, and collectCdpTree resolves them to the node ids the server
needs: the focus locator (honoring nth, defaulting to 0) becomes
focusBackendNodeId, and each ignore locator resolves to
ignoredBackendNodeIds (no nth = every match, nth = that single match).
An unresolvable focus locator skips caching entirely rather than keying
a scoped request on an unscoped tree.

Resolution is main-frame-only, mirroring the legacy selector contract:
iframe matches aren't pruned from the hash but still key via the
literal locator fields, so misses are the worst case, never collisions.

Must not ship until the server change (core stagehand-api-v3) is
deployed to all regions — old servers strip the unknown fields and
would collide locator-scoped requests with unscoped cache keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sameelarif
sameelarif requested a review from a team as a code owner August 13, 2026 18:41
@mintlify

mintlify Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
stagehand 🟢 Ready View Preview Aug 13, 2026, 6:42 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 7ddb2cf

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cubic analysis

12 issues found across 14 files

Confidence score: 3/5

  • In packages/extension/services/cacheService.ts, locator resolution keeps resolved.objectId alive because describeBackendNodeId does not release the DevTools runtime handle, which can accumulate objects and degrade extension stability over time—call Runtime.releaseObject in a finally block after DOM.describeNode/DOM.describeBackendNodeId work completes.
  • In packages/extension/tests/observe.test.ts and packages/extension/tests/extract.test.ts, the resolveAtIndex mocks ignore the index argument, so nth:0 and nth:1 effectively resolve the same target and index-related cache-key regressions could slip through—make mocks index-aware and assert different results per index.
  • In packages/extension/tests/extract.test.ts, the new locator-scoped extraction coverage only exercises cache MISS behavior, leaving HIT behavior unverified and increasing the chance the main caching path breaks unnoticed—add a locator-scoped HIT-path test that proves cached results are returned.
  • Across packages/docs/v4/basics/act.mdx and packages/docs/v4/best-practices/caching.mdx, passive voice and em-dash usage violate the docs prose guide, which is likely to cause docs-lint friction and inconsistent guidance—rewrite the flagged sentences in active voice and replace em dashes with allowed punctuation.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/docs/v4/basics/observe.mdx">

<violation number="1" location="packages/docs/v4/basics/observe.mdx:504">
P3: This sentence uses passive voice for the key behavior, which makes the actor unclear in public docs. Rewrite it in active voice so Stagehand explicitly performs the scoping.

(Based on your team's feedback about active voice with explicit actors in Stagehand docs.)</violation>
</file>

<file name="packages/extension/tests/observe.test.ts">

<violation number="1" location="packages/extension/tests/observe.test.ts:265">
P2: This test's mock for resolveAtIndex ignores the index parameter, keying only on query.value. So it returns obj-main for both nth:0 and nth:1, and the test would still pass if the cache path dropped nth and always resolved the first match. Since STG-2803's core change is supporting indexed locators, the test should pin that scope resolution honors the index: return distinct objectIds per index, map them to distinct backendNodeIds in the session.send fake, and assert the resolved id is the nth match's, not the first's.</violation>
</file>

<file name="packages/extension/tests/extract.test.ts">

<violation number="1" location="packages/extension/tests/extract.test.ts:481">
P3: This PR's goal is to serve locator-scoped act/observe/extract from the cache, but the new "caches locator-scoped extraction" tests only exercise the MISS path (get resolves to `hit: false`). Without a locator-scoped HIT test, nothing verifies that a scoped request is served from cache and skips `page.captureSnapshot` and inference — the core behavior this change enables. Add a case asserting a locator-scoped hit returns the cached value without re-capturing or calling `clientLLMGenerate`/`set`.</violation>

<violation number="2" location="packages/extension/tests/extract.test.ts:483">
P2: According to linked Linear issue STG-2803, index support (nth) is a key aspect of locator-based cache params. The `resolveAtIndex` mock derives its result only from `query.value` and never reads the `index` argument, so both new cases (`locator: { selector: "main", nth: 1 }` and `ignoreLocators: [{ selector: "nav", nth: 2 }]`) would pass even if cacheService dropped `nth` and resolved nth:0 every time. Assert that the index is forwarded (e.g. `expect(resolver.resolveAtIndex).toHaveBeenCalledWith(expect.anything(), 1)`) so a regression in index handling is caught.</violation>
</file>

<file name="packages/extension/services/cacheService.ts">

<violation number="1" location="packages/extension/services/cacheService.ts:469">
P3: For an index-free ignore locator, `resolveIgnoredBackendNodeIds` calls `resolver.resolveAll(query)` with no limit, and `resolveAll` issues one locator-world `Runtime.evaluate` round trip per matched element. An ignore locator that matches many nodes (e.g. a generic selector) therefore triggers an unbounded series of CDP round trips inside `collectCdpTree`, on both the cache read and the cache write of every scoped request. Consider bounding the resolution or accepting a cap on pruned nodes, since the cache flow is best-effort.</violation>

<violation number="2" location="packages/extension/services/cacheService.ts:485">
P2: Each cache locator resolution leaves `resolved.objectId` alive in the DevTools runtime because `describeBackendNodeId` never calls `Runtime.releaseObject`. Release the handle in a `finally` block after `DOM.describeNode` so repeated cached requests do not accumulate remote objects.</violation>
</file>

<file name="packages/extension/tests/act.test.ts">

<violation number="1" location="packages/extension/tests/act.test.ts:24">
P3: The ~17-line hoisted FrameSelectorResolver mock (the resolver object plus the vi.mock stub) is duplicated verbatim across act.test.ts, observe.test.ts, and extract.test.ts. Extract it into a shared test helper (e.g. a `mockSelectorResolver()` factory or a shared `__mocks__` module) so the fake stays in one place and any change to its shape (new method, changed resolveAtIndex signature) doesn't need to be made in three files.</violation>
</file>

<file name="packages/docs/v4/basics/act.mdx">

<violation number="1" location="packages/docs/v4/basics/act.mdx:355">
P1: Custom agent: **Stagehand docs prose guide**

"Instruction-based `act()` calls with a target locator or ignored locators are cached too" and "the page fingerprint is scoped to the locator's subtree" are passive voice without naming Stagehand as the actor. The docs prose guide requires active voice with explicit actors. Rewrite to name the responsible component explicitly, for example: "Stagehand also caches instruction-based `act()` calls that pass `locator` or `ignoreLocators`. Stagehand includes `locator` and `ignoreLocators` (including `nth` indices) in the cache key and scopes the page fingerprint to the locator's subtree, excluding ignored subtrees."</violation>
</file>

<file name="packages/docs/v4/best-practices/caching.mdx">

<violation number="1" location="packages/docs/v4/best-practices/caching.mdx:346">
P2: Custom agent: **Stagehand docs prose guide**

Use active voice instead of passive. Rewrite this sentence to name Stagehand as the actor: 'Stagehand scopes the page fingerprint to the locator’s subtree — so content changes outside the scoped container don’t invalidate the cached entry.'</violation>

<violation number="2" location="packages/docs/v4/best-practices/caching.mdx:346">
P1: Custom agent: **Stagehand docs prose guide**

The docs prose guide forbids em dashes in prose. Replace each em dash with a period, colon, comma, parentheses, or a shorter sentence.</violation>

<violation number="3" location="packages/docs/v4/best-practices/caching.mdx:408">
P2: Custom agent: **Stagehand docs prose guide**

Use active voice instead of passive. Rewrite to name Stagehand as the actor: 'Stagehand excludes subtrees matched by `ignoreLocators` from the page fingerprint, so changes inside ignored content don't invalidate the cache.'</violation>

<violation number="4" location="packages/docs/v4/best-practices/caching.mdx:408">
P1: Custom agent: **Stagehand docs prose guide**

The docs prose guide forbids em dashes in prose. Replace the em dash with a period, colon, comma, parentheses, or a shorter sentence.</violation>
</file>

Linked issue analysis

Linked issue: STG-2803: Update cache API for locator-based act/extract/observe params

Status Acceptance criteria Notes
Include locator and ignoreLocators (with nth) in the cache payload/key so locator-scoped requests are keyed separately from unscoped requests build*CacheData now conditionally spreads locator fields into the request data and locatorCacheParam includes nth when present; docs updated to reflect this behavior.
Stop bypassing caching for locator-scoped act/observe/extract calls and pass locator fields into cache path instead of a bypass flag Calls to cache with bypass were replaced by passing focusLocator and ignoreLocators; shouldBypassCacheForLocatorScope removed and cache code now attempts to resolve and include locator scope.
Resolve focus locator to a focusBackendNodeId and skip caching when the focus locator does not resolve collectCdpTree resolves the focus locator via resolveLocatorBackendNodeId and returns null (skips cache) when resolution fails; tests assert cache is skipped for an unresolvable focus locator.
Resolve ignoreLocators to ignoredBackendNodeIds (may be empty) and include them in the cdpTree payload resolveIgnoredBackendNodeIds collects backend node ids for ignore locators (handling nth/no-nth semantics) and packages them into cdpTree; tests verify non-empty and empty cases.
Expose ignoredBackendNodeIds in the cache client schema and send that field to the server Cache client schema now includes ignoredBackendNodeIds; cacheService attaches ignoredBackendNodeIds to the cdpTree in the payload passed to the cache client.
Honor nth indices in locators for both keying and resolution (nth defaults to 0 when omitted) locatorCacheParam includes nth when defined; resolveLocatorBackendNodeId and resolveIgnoredBackendNodeIds use nth (default 0 for focus resolution, nth absent => resolveAll for ignores); tests validate nth resolution and index-free default behavior.
Architecture diagram
sequenceDiagram
    participant Client as SDK Client
    participant Service as act/observe/extract Service
    participant CacheSvc as Cache Service
    participant Resolver as FrameSelectorResolver
    participant Frame as CDP Frame Session
    participant CacheClient as Cache Client
    participant Server as Browserbase Cache API
    
    Note over Client,Server: Locator-Scoped Cache Flow (act/observe/extract)
    
    Client->>Service: callAct/observe/extract(params with locator/ignoreLocators)
    Service->>CacheSvc: withCache(focusLocator, ignoreLocators)
    
    CacheSvc->>CacheSvc: buildCacheData() - includes locator/ignoreLocators in key
    CacheSvc->>CacheSvc: collectCdpTree(page, focusLocator, ignoreLocators)
    
    alt focusLocator provided
        CacheSvc->>Resolver: resolveAtIndex(selector, nth ?? 0)
        alt locator resolves
            Resolver->>Frame: resolve node in main frame
            Frame-->>Resolver: objectId
            CacheSvc->>Frame: DOM.describeNode(objectId)
            Frame-->>CacheSvc: backendNodeId
            CacheSvc->>CacheSvc: set focusBackendNodeId
        else locator does NOT resolve
            Resolver-->>CacheSvc: null
            CacheSvc->>CacheSvc: skip caching (return null)
            CacheSvc-->>Service: execute without cache
            Service-->>Client: result with cache DISABLED
        end
    end
    
    opt ignoreLocators provided
        loop for each ignore locator
            alt nth is undefined
                CacheSvc->>Resolver: resolveAll(selector)
                Resolver->>Frame: resolve all matches
                Frame-->>Resolver: multiple objectIds
                Resolver-->>CacheSvc: array of nodes
            else nth is defined
                CacheSvc->>Resolver: resolveAtIndex(selector, nth)
                Resolver->>Frame: resolve nth match
                Frame-->>Resolver: single objectId
                Resolver-->>CacheSvc: single node
            end
            CacheSvc->>Frame: DOM.describeNode(objectId) for each
            Frame-->>CacheSvc: backendNodeIds
            CacheSvc->>CacheSvc: collect into ignoredBackendNodeIds set
        end
    end
    
    CacheSvc->>CacheSvc: assemble CdpTree (frames, focusBackendNodeId, ignoredBackendNodeIds)
    
    CacheSvc->>CacheClient: get(cacheKey, data, cdpTree)
    CacheClient->>Server: POST cache lookup with scoped tree
    Server-->>CacheClient: { hit: false, missReason }
    CacheClient-->>CacheSvc: cache miss
    
    CacheSvc-->>Service: execute LLM operation
    Service->>Service: run act/observe/extract with locator scoping
    Service-->>CacheSvc: result data
    
    CacheSvc->>CacheClient: set(cacheKey, value, cdpTree)
    CacheClient->>Server: POST cache write with scoped tree
    Server-->>CacheClient: { written: true }
    CacheClient-->>CacheSvc: success
    
    CacheSvc-->>Service: result with cache status MISS
    Service-->>Client: return result
    
    Note over Client,Server: Subsequent identical call (cache hit path)
    
    Client->>Service: call with same locator/ignoreLocators
    Service->>CacheSvc: withCache(focusLocator, ignoreLocators)
    CacheSvc->>CacheSvc: collectCdpTree - resolve same backendNodeIds
    CacheSvc->>CacheClient: get(cacheKey, data, cdpTree)
    CacheClient->>Server: POST cache lookup
    Server-->>CacheClient: { hit: true, value: cached result }
    CacheClient-->>CacheSvc: cache hit
    
    CacheSvc->>CacheSvc: onHit() - replay cached actions
    CacheSvc-->>Service: cached result with status HIT
    Service-->>Client: return cached result (no LLM call)
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

When running on Browserbase, Stagehand can cache `act()` results server-side. Repeated calls with the same inputs return instantly without consuming LLM tokens. Enable caching on the constructor and override it per call:

Instruction-based `act()` calls with a target locator or ignored locators bypass the server-side cache and report `metadata.cache.status` as `DISABLED`.
Instruction-based `act()` calls with a target locator or ignored locators are cached too: `locator` and `ignoreLocators` (including `nth` indices) are part of the cache key, and the page fingerprint is scoped to the locator's subtree with ignored subtrees excluded.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Stagehand docs prose guide

"Instruction-based act() calls with a target locator or ignored locators are cached too" and "the page fingerprint is scoped to the locator's subtree" are passive voice without naming Stagehand as the actor. The docs prose guide requires active voice with explicit actors. Rewrite to name the responsible component explicitly, for example: "Stagehand also caches instruction-based act() calls that pass locator or ignoreLocators. Stagehand includes locator and ignoreLocators (including nth indices) in the cache key and scopes the page fingerprint to the locator's subtree, excluding ignored subtrees."

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/docs/v4/basics/act.mdx, line 355:

<comment>"Instruction-based `act()` calls with a target locator or ignored locators are cached too" and "the page fingerprint is scoped to the locator's subtree" are passive voice without naming Stagehand as the actor. The docs prose guide requires active voice with explicit actors. Rewrite to name the responsible component explicitly, for example: "Stagehand also caches instruction-based `act()` calls that pass `locator` or `ignoreLocators`. Stagehand includes `locator` and `ignoreLocators` (including `nth` indices) in the cache key and scopes the page fingerprint to the locator's subtree, excluding ignored subtrees."</comment>

<file context>
@@ -352,7 +352,7 @@ Target locators only affect instruction-based `act()` calls. When you pass an `A
 When running on Browserbase, Stagehand can cache `act()` results server-side. Repeated calls with the same inputs return instantly without consuming LLM tokens. Enable caching on the constructor and override it per call:
 
-Instruction-based `act()` calls with a target locator or ignored locators bypass the server-side cache and report `metadata.cache.status` as `DISABLED`.
+Instruction-based `act()` calls with a target locator or ignored locators are cached too: `locator` and `ignoreLocators` (including `nth` indices) are part of the cache key, and the page fingerprint is scoped to the locator's subtree with ignored subtrees excluded.
 
 <Tabs>
</file context>
Suggested change
Instruction-based `act()` calls with a target locator or ignored locators are cached too: `locator` and `ignoreLocators` (including `nth` indices) are part of the cache key, and the page fingerprint is scoped to the locator's subtree with ignored subtrees excluded.
Stagehand also caches instruction-based `act()` calls that pass a target `locator` or `ignoreLocators`: `locator` and `ignoreLocators` (including `nth` indices) are part of the cache key, and Stagehand scopes the page fingerprint to the locator's subtree, excluding ignored subtrees.


<Note>
When you set `locator` or `ignoreLocators` on `act()`, `observe()`, or `extract()`, Stagehand skips server-side cache reads and writes for that call.
When you set `locator` or `ignoreLocators` on `act()`, `observe()`, or `extract()`, both become part of the cache key — calls that differ only in their locators (including `nth` indices) use separate cache entries. Subtrees matched by `ignoreLocators` are excluded from the page fingerprint, so changes inside ignored content don't invalidate the cache.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Stagehand docs prose guide

The docs prose guide forbids em dashes in prose. Replace the em dash with a period, colon, comma, parentheses, or a shorter sentence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/docs/v4/best-practices/caching.mdx, line 408:

<comment>The docs prose guide forbids em dashes in prose. Replace the em dash with a period, colon, comma, parentheses, or a shorter sentence.</comment>

<file context>
@@ -405,7 +405,7 @@ func example(ctx context.Context, client *stagehand.Stagehand, page *stagehand.P
 
 <Note>
-When you set `locator` or `ignoreLocators` on `act()`, `observe()`, or `extract()`, Stagehand skips server-side cache reads and writes for that call.
+When you set `locator` or `ignoreLocators` on `act()`, `observe()`, or `extract()`, both become part of the cache key — calls that differ only in their locators (including `nth` indices) use separate cache entries. Subtrees matched by `ignoreLocators` are excluded from the page fingerprint, so changes inside ignored content don't invalidate the cache.
 </Note>
 </Accordion>
</file context>
Suggested change
When you set `locator` or `ignoreLocators` on `act()`, `observe()`, or `extract()`, both become part of the cache key calls that differ only in their locators (including `nth` indices) use separate cache entries. Subtrees matched by `ignoreLocators` are excluded from the page fingerprint, so changes inside ignored content don't invalidate the cache.
When you set `locator` or `ignoreLocators` on `act()`, `observe()`, or `extract()`, both become part of the cache key, so calls that differ only in their locators (including `nth` indices) use separate cache entries. Subtrees matched by `ignoreLocators` are excluded from the page fingerprint, so changes inside ignored content don't invalidate the cache.

When targeting a specific part of a page, pass a locator to scope the accessibility tree snapshot to that container. This reduces token costs and speeds up inference.

Locator-scoped `act()`, `observe()`, and `extract()` calls currently bypass the server-side result cache, because the cache contract is keyed on unscoped requests. Their cache status is `DISABLED` even when instance-level caching is enabled.
Locator-scoped `act()`, `observe()`, and `extract()` calls participate in the server-side result cache: the locator (including its `nth` index) is part of the cache key, and the page fingerprint is scoped to the locator's subtree — so content changes outside the scoped container don't invalidate the cached entry.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Stagehand docs prose guide

The docs prose guide forbids em dashes in prose. Replace each em dash with a period, colon, comma, parentheses, or a shorter sentence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/docs/v4/best-practices/caching.mdx, line 346:

<comment>The docs prose guide forbids em dashes in prose. Replace each em dash with a period, colon, comma, parentheses, or a shorter sentence.</comment>

<file context>
@@ -343,7 +343,7 @@ Cache behavior is also visible in the [Browserbase session replay dashboard](htt
 When targeting a specific part of a page, pass a locator to scope the accessibility tree snapshot to that container. This reduces token costs and speeds up inference.
 
-Locator-scoped `act()`, `observe()`, and `extract()` calls currently bypass the server-side result cache, because the cache contract is keyed on unscoped requests. Their cache status is `DISABLED` even when instance-level caching is enabled.
+Locator-scoped `act()`, `observe()`, and `extract()` calls participate in the server-side result cache: the locator (including its `nth` index) is part of the cache key, and the page fingerprint is scoped to the locator's subtree — so content changes outside the scoped container don't invalidate the cached entry.
 
 <Tabs>
</file context>
Suggested change
Locator-scoped `act()`, `observe()`, and `extract()` calls participate in the server-side result cache: the locator (including its `nth` index) is part of the cache key, and the page fingerprint is scoped to the locator's subtree so content changes outside the scoped container don't invalidate the cached entry.
Locator-scoped `act()`, `observe()`, and `extract()` calls participate in the server-side result cache: the locator (including its `nth` index) is part of the cache key, and the page fingerprint is scoped to the locator's subtree, so content changes outside the scoped container don't invalidate the cached entry.


it("bypasses cache reads and writes for locator-scoped observations", async () => {
it("caches locator-scoped observations with resolved scope node ids", async () => {
resolver.resolveAtIndex.mockImplementation(async (query: { value: string }) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This test's mock for resolveAtIndex ignores the index parameter, keying only on query.value. So it returns obj-main for both nth:0 and nth:1, and the test would still pass if the cache path dropped nth and always resolved the first match. Since STG-2803's core change is supporting indexed locators, the test should pin that scope resolution honors the index: return distinct objectIds per index, map them to distinct backendNodeIds in the session.send fake, and assert the resolved id is the nth match's, not the first's.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/observe.test.ts, line 265:

<comment>This test's mock for resolveAtIndex ignores the index parameter, keying only on query.value. So it returns obj-main for both nth:0 and nth:1, and the test would still pass if the cache path dropped nth and always resolved the first match. Since STG-2803's core change is supporting indexed locators, the test should pin that scope resolution honors the index: return distinct objectIds per index, map them to distinct backendNodeIds in the session.send fake, and assert the resolved id is the nth match's, not the first's.</comment>

<file context>
@@ -242,10 +261,20 @@ describe("observe service", () => {
 
-  it("bypasses cache reads and writes for locator-scoped observations", async () => {
+  it("caches locator-scoped observations with resolved scope node ids", async () => {
+    resolver.resolveAtIndex.mockImplementation(async (query: { value: string }) =>
+      query.value === "main"
+        ? { objectId: "obj-main", nodeId: null }
</file context>

async ({ options, expectedSnapshotOptions }) => {
"caches locator-scoped extraction with $name",
async ({ options, expectedSnapshotOptions, expectedDataOptions, expectedCdpTree }) => {
resolver.resolveAtIndex.mockImplementation(async (query: { value: string }) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: According to linked Linear issue STG-2803, index support (nth) is a key aspect of locator-based cache params. The resolveAtIndex mock derives its result only from query.value and never reads the index argument, so both new cases (locator: { selector: "main", nth: 1 } and ignoreLocators: [{ selector: "nav", nth: 2 }]) would pass even if cacheService dropped nth and resolved nth:0 every time. Assert that the index is forwarded (e.g. expect(resolver.resolveAtIndex).toHaveBeenCalledWith(expect.anything(), 1)) so a regression in index handling is caught.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/extract.test.ts, line 483:

<comment>According to linked Linear issue STG-2803, index support (nth) is a key aspect of locator-based cache params. The `resolveAtIndex` mock derives its result only from `query.value` and never reads the `index` argument, so both new cases (`locator: { selector: "main", nth: 1 }` and `ignoreLocators: [{ selector: "nav", nth: 2 }]`) would pass even if cacheService dropped `nth` and resolved nth:0 every time. Assert that the index is forwarded (e.g. `expect(resolver.resolveAtIndex).toHaveBeenCalledWith(expect.anything(), 1)`) so a regression in index handling is caught.</comment>

<file context>
@@ -453,13 +474,25 @@ describe("extract service", () => {
-    async ({ options, expectedSnapshotOptions }) => {
+    "caches locator-scoped extraction with $name",
+    async ({ options, expectedSnapshotOptions, expectedDataOptions, expectedCdpTree }) => {
+      resolver.resolveAtIndex.mockImplementation(async (query: { value: string }) =>
+        query.value === "main"
+          ? { objectId: "obj-main", nodeId: null }
</file context>

When targeting a specific part of a page, pass a locator to scope the accessibility tree snapshot to that container. This reduces token costs and speeds up inference.

Locator-scoped `act()`, `observe()`, and `extract()` calls currently bypass the server-side result cache, because the cache contract is keyed on unscoped requests. Their cache status is `DISABLED` even when instance-level caching is enabled.
Locator-scoped `act()`, `observe()`, and `extract()` calls participate in the server-side result cache: the locator (including its `nth` index) is part of the cache key, and the page fingerprint is scoped to the locator's subtree — so content changes outside the scoped container don't invalidate the cached entry.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Stagehand docs prose guide

Use active voice instead of passive. Rewrite this sentence to name Stagehand as the actor: 'Stagehand scopes the page fingerprint to the locator’s subtree — so content changes outside the scoped container don’t invalidate the cached entry.'

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/docs/v4/best-practices/caching.mdx, line 346:

<comment>Use active voice instead of passive. Rewrite this sentence to name Stagehand as the actor: 'Stagehand scopes the page fingerprint to the locator’s subtree — so content changes outside the scoped container don’t invalidate the cached entry.'</comment>

<file context>
@@ -343,7 +343,7 @@ Cache behavior is also visible in the [Browserbase session replay dashboard](htt
 When targeting a specific part of a page, pass a locator to scope the accessibility tree snapshot to that container. This reduces token costs and speeds up inference.
 
-Locator-scoped `act()`, `observe()`, and `extract()` calls currently bypass the server-side result cache, because the cache contract is keyed on unscoped requests. Their cache status is `DISABLED` even when instance-level caching is enabled.
+Locator-scoped `act()`, `observe()`, and `extract()` calls participate in the server-side result cache: the locator (including its `nth` index) is part of the cache key, and the page fingerprint is scoped to the locator's subtree — so content changes outside the scoped container don't invalidate the cached entry.
 
 <Tabs>
</file context>
Suggested change
Locator-scoped `act()`, `observe()`, and `extract()` calls participate in the server-side result cache: the locator (including its `nth` index) is part of the cache key, and the page fingerprint is scoped to the locator's subtree — so content changes outside the scoped container don't invalidate the cached entry.
Locator-scoped `act()`, `observe()`, and `extract()` calls participate in the server-side result cache: the locator (including its `nth` index) is part of the cache key, and Stagehand scopes the page fingerprint to the locator's subtree — so content changes outside the scoped container don't invalidate the cached entry.

When running on Browserbase, Stagehand can cache `observe()` results server-side. Repeated calls with the same inputs return instantly without consuming LLM tokens. Enable caching on the constructor and override it per call:

Locator-scoped observations, including calls with `locator` or `ignoreLocators`, bypass the server-side cache and report `metadata.cache.status` as `DISABLED`.
Locator-scoped observations are cached too: `locator` and `ignoreLocators` (including `nth` indices) are part of the cache key, and the page fingerprint is scoped to the locator's subtree with ignored subtrees excluded.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This sentence uses passive voice for the key behavior, which makes the actor unclear in public docs. Rewrite it in active voice so Stagehand explicitly performs the scoping.

(Based on your team's feedback about active voice with explicit actors in Stagehand docs.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/docs/v4/basics/observe.mdx, line 504:

<comment>This sentence uses passive voice for the key behavior, which makes the actor unclear in public docs. Rewrite it in active voice so Stagehand explicitly performs the scoping.

(Based on your team's feedback about active voice with explicit actors in Stagehand docs.) </comment>

<file context>
@@ -501,7 +501,7 @@ if emailField != nil && passwordField != nil {
 When running on Browserbase, Stagehand can cache `observe()` results server-side. Repeated calls with the same inputs return instantly without consuming LLM tokens. Enable caching on the constructor and override it per call:
 
-Locator-scoped observations, including calls with `locator` or `ignoreLocators`, bypass the server-side cache and report `metadata.cache.status` as `DISABLED`.
+Locator-scoped observations are cached too: `locator` and `ignoreLocators` (including `nth` indices) are part of the cache key, and the page fingerprint is scoped to the locator's subtree with ignored subtrees excluded.
 
 <Tabs>
</file context>
Suggested change
Locator-scoped observations are cached too: `locator` and `ignoreLocators` (including `nth` indices) are part of the cache key, and the page fingerprint is scoped to the locator's subtree with ignored subtrees excluded.
Locator-scoped observations are cached too: Stagehand includes `locator` and `ignoreLocators` (including `nth` indices) in the cache key, and Stagehand scopes the page fingerprint to the locator's subtree while excluding ignored subtrees.

])(
"bypasses cache reads and writes for locator-scoped extraction with $name",
async ({ options, expectedSnapshotOptions }) => {
"caches locator-scoped extraction with $name",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This PR's goal is to serve locator-scoped act/observe/extract from the cache, but the new "caches locator-scoped extraction" tests only exercise the MISS path (get resolves to hit: false). Without a locator-scoped HIT test, nothing verifies that a scoped request is served from cache and skips page.captureSnapshot and inference — the core behavior this change enables. Add a case asserting a locator-scoped hit returns the cached value without re-capturing or calling clientLLMGenerate/set.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/extract.test.ts, line 481:

<comment>This PR's goal is to serve locator-scoped act/observe/extract from the cache, but the new "caches locator-scoped extraction" tests only exercise the MISS path (get resolves to `hit: false`). Without a locator-scoped HIT test, nothing verifies that a scoped request is served from cache and skips `page.captureSnapshot` and inference — the core behavior this change enables. Add a case asserting a locator-scoped hit returns the cached value without re-capturing or calling `clientLLMGenerate`/`set`.</comment>

<file context>
@@ -453,13 +474,25 @@ describe("extract service", () => {
   ])(
-    "bypasses cache reads and writes for locator-scoped extraction with $name",
-    async ({ options, expectedSnapshotOptions }) => {
+    "caches locator-scoped extraction with $name",
+    async ({ options, expectedSnapshotOptions, expectedDataOptions, expectedCdpTree }) => {
+      resolver.resolveAtIndex.mockImplementation(async (query: { value: string }) =>
</file context>

// FrameSelectorResolver, which needs a live locator-world execution context;
// tests fake the resolver and answer DOM.describeNode from the frame's fake
// session instead.
const resolver = vi.hoisted(() => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The ~17-line hoisted FrameSelectorResolver mock (the resolver object plus the vi.mock stub) is duplicated verbatim across act.test.ts, observe.test.ts, and extract.test.ts. Extract it into a shared test helper (e.g. a mockSelectorResolver() factory or a shared __mocks__ module) so the fake stays in one place and any change to its shape (new method, changed resolveAtIndex signature) doesn't need to be made in three files.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/act.test.ts, line 24:

<comment>The ~17-line hoisted FrameSelectorResolver mock (the resolver object plus the vi.mock stub) is duplicated verbatim across act.test.ts, observe.test.ts, and extract.test.ts. Extract it into a shared test helper (e.g. a `mockSelectorResolver()` factory or a shared `__mocks__` module) so the fake stays in one place and any change to its shape (new method, changed resolveAtIndex signature) doesn't need to be made in three files.</comment>

<file context>
@@ -17,6 +17,25 @@ vi.mock("../handlers/handlerUtils/actHandlerUtils.js", () => ({
+// FrameSelectorResolver, which needs a live locator-world execution context;
+// tests fake the resolver and answer DOM.describeNode from the frame's fake
+// session instead.
+const resolver = vi.hoisted(() => ({
+  resolveAtIndex: vi.fn(),
+  resolveAll: vi.fn(),
</file context>

const query = FrameSelectorResolver.parseSelector(locator.selector);
const matches =
locator.nth === undefined
? await resolver.resolveAll(query)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: For an index-free ignore locator, resolveIgnoredBackendNodeIds calls resolver.resolveAll(query) with no limit, and resolveAll issues one locator-world Runtime.evaluate round trip per matched element. An ignore locator that matches many nodes (e.g. a generic selector) therefore triggers an unbounded series of CDP round trips inside collectCdpTree, on both the cache read and the cache write of every scoped request. Consider bounding the resolution or accepting a cap on pruned nodes, since the cache flow is best-effort.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/services/cacheService.ts, line 469:

<comment>For an index-free ignore locator, `resolveIgnoredBackendNodeIds` calls `resolver.resolveAll(query)` with no limit, and `resolveAll` issues one locator-world `Runtime.evaluate` round trip per matched element. An ignore locator that matches many nodes (e.g. a generic selector) therefore triggers an unbounded series of CDP round trips inside `collectCdpTree`, on both the cache read and the cache write of every scoped request. Consider bounding the resolution or accepting a cap on pruned nodes, since the cache flow is best-effort.</comment>

<file context>
@@ -367,3 +431,59 @@ async function collectCdpTree(page: CachePage, logger: StagehandLogger): Promise
+    const query = FrameSelectorResolver.parseSelector(locator.selector);
+    const matches =
+      locator.nth === undefined
+        ? await resolver.resolveAll(query)
+        : [await resolver.resolveAtIndex(query, locator.nth)].filter(
+            (node): node is ResolvedNode => node !== null,
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant