cache locator-scoped act, observe & extract instead of bypassing - #2712
Conversation
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>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
There was a problem hiding this comment.
cubic analysis
12 issues found across 14 files
Confidence score: 3/5
- In
packages/extension/services/cacheService.ts, locator resolution keepsresolved.objectIdalive becausedescribeBackendNodeIddoes not release the DevTools runtime handle, which can accumulate objects and degrade extension stability over time—callRuntime.releaseObjectin afinallyblock afterDOM.describeNode/DOM.describeBackendNodeIdwork completes. - In
packages/extension/tests/observe.test.tsandpackages/extension/tests/extract.test.ts, theresolveAtIndexmocks ignore theindexargument, sonth:0andnth:1effectively 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.mdxandpackages/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)
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. |
There was a problem hiding this comment.
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>
| 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. |
There was a problem hiding this comment.
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>
| 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. |
There was a problem hiding this comment.
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>
| 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 }) => |
There was a problem hiding this comment.
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 }) => |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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>
| 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. |
There was a problem hiding this comment.
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.)
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>
| 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", |
There was a problem hiding this comment.
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(() => ({ |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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>
why
Server is updated to accept new locator params so we can remove the client-side bypass.
what changed
Removed locator based cache
bypassbooleantest plan
Summary by cubic
Caches locator‑scoped
act(),observe(), andextract()calls instead of bypassing them. The cache key now includeslocatorandignoreLocators(withnth), and the page fingerprint is scoped to the locator subtree with ignored subtrees excluded.focusBackendNodeIdand ignore locators toignoredBackendNodeIds; an unresolvable focus locator skips caching.cacheServiceto include locator fields in cache data and to collect CDP tree with scope node ids;actService,observeService, andextractServicepassfocusLocatorandignoreLocators.ignoredBackendNodeIdsto thecacheClientschema and updates docs/tests to reflect the new behavior.Rollout
locator/ignoreLocators(STG‑2803). Do not ship until deployed to all regions; older servers would collide locator‑scoped requests with unscoped keys.MISS/HITinstead ofDISABLED.locatordoes not resolve on the page, the request executes without cache.Written for commit 7ddb2cf. Summary will update on new commits.