Skip to content

Commit d382084

Browse files
seanmcguire12miguelg719cubic-dev-ai[bot]
authored
fix: act, extract, and observe not respecting timeout param (#1330)
# why - async functions invoked by act, extract, and observe all continued to run even after the timeout was reached # what changed - this PR introduces a time remaining check mechanism which runs between each major IO operation inside each of the handlers - this ensures that user defined timeout are actually respected inside of act, extract, and observe # test plan - added tests to confirm that internal async functions do not continue running after the timeout is reached <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Fixes act, extract, and observe to truly honor the timeout parameter with step-wise guards that abort early and return clear errors. Deterministic actions now use the same guard path in v3. - **Bug Fixes** - Added createTimeoutGuard and specific ActTimeoutError, ExtractTimeoutError, and ObserveTimeoutError (exported). - Replaced Promise.race with per-step checks across snapshot capture, LLM inference, action execution, and self-heal retries. - Enforced per-step timeouts in ActHandler.takeDeterministicAction; metrics unchanged. - Wired v3 deterministic actions to pass a timeout guard; shadow DOM and unsupported actions behavior unchanged. <sup>Written for commit d6bbfb8. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: miguel <miguelg71921@gmail.com> Co-authored-by: Miguel <36487034+miguelg719@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1 parent 7a8e705 commit d382084

File tree

11 files changed

+1641
-364
lines changed

11 files changed

+1641
-364
lines changed

.changeset/tasty-teams-call.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
---
4+
5+
fix: make act, extract, and observe respect user defined timeout param

packages/core/lib/v3/handlers/actHandler.ts

Lines changed: 125 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { trimTrailingTextNode } from "../../utils";
55
import { v3Logger } from "../logger";
66
import { ActHandlerParams } from "../types/private/handlers";
77
import { ActResult, Action, V3FunctionName } from "../types/public/methods";
8+
import { ActTimeoutError } from "../types/public/sdkErrors";
89
import {
910
captureHybridSnapshot,
1011
diffCombinedTrees,
@@ -22,6 +23,7 @@ import {
2223
performUnderstudyMethod,
2324
waitForDomNetworkQuiet,
2425
} from "./handlerUtils/actHandlerUtils";
26+
import { createTimeoutGuard } from "./handlerUtils/timeoutGuard";
2527

2628
type ActInferenceElement = {
2729
elementId?: string;
@@ -144,145 +146,144 @@ export class ActHandler {
144146
const { instruction, page, variables, timeout, model } = params;
145147

146148
const llmClient = this.resolveLlmClient(model);
149+
const effectiveTimeoutMs =
150+
typeof timeout === "number" && timeout > 0 ? timeout : undefined;
147151

148-
const doObserveAndAct = async (): Promise<ActResult> => {
149-
await waitForDomNetworkQuiet(
150-
page.mainFrame(),
151-
this.defaultDomSettleTimeoutMs,
152-
);
153-
const { combinedTree, combinedXpathMap } = await captureHybridSnapshot(
154-
page,
155-
{ experimental: true },
156-
);
157-
158-
const actInstruction = buildActPrompt(
159-
instruction,
160-
Object.values(SupportedPlaywrightAction),
161-
variables,
162-
);
152+
const ensureTimeRemaining = createTimeoutGuard(
153+
effectiveTimeoutMs,
154+
(ms) => new ActTimeoutError(ms),
155+
);
163156

164-
const { action: firstAction, response: actInferenceResponse } =
165-
await this.getActionFromLLM({
166-
instruction: actInstruction,
167-
domElements: combinedTree,
168-
xpathMap: combinedXpathMap,
169-
llmClient,
170-
variables,
171-
});
157+
ensureTimeRemaining();
158+
await waitForDomNetworkQuiet(
159+
page.mainFrame(),
160+
this.defaultDomSettleTimeoutMs,
161+
);
162+
ensureTimeRemaining();
163+
const { combinedTree, combinedXpathMap } = await captureHybridSnapshot(
164+
page,
165+
{ experimental: true },
166+
);
172167

173-
if (!firstAction) {
174-
v3Logger({
175-
category: "action",
176-
message: "no actionable element returned by LLM",
177-
level: 1,
178-
});
179-
return {
180-
success: false,
181-
message: "Failed to perform act: No action found",
182-
actionDescription: instruction,
183-
actions: [],
184-
};
185-
}
168+
const actInstruction = buildActPrompt(
169+
instruction,
170+
Object.values(SupportedPlaywrightAction),
171+
variables,
172+
);
186173

187-
// First action (self-heal aware path)
188-
const firstResult = await this.takeDeterministicAction(
189-
firstAction,
190-
page,
191-
this.defaultDomSettleTimeoutMs,
174+
ensureTimeRemaining();
175+
const { action: firstAction, response: actInferenceResponse } =
176+
await this.getActionFromLLM({
177+
instruction: actInstruction,
178+
domElements: combinedTree,
179+
xpathMap: combinedXpathMap,
192180
llmClient,
193-
);
194-
195-
// If not two-step, return the first action result
196-
if (actInferenceResponse?.twoStep !== true) {
197-
return firstResult;
198-
}
181+
variables,
182+
});
199183

200-
// Take a new focused snapshot and observe again
201-
const {
202-
combinedTree: combinedTree2,
203-
combinedXpathMap: combinedXpathMap2,
204-
} = await captureHybridSnapshot(page, {
205-
experimental: true,
184+
if (!firstAction) {
185+
v3Logger({
186+
category: "action",
187+
message: "no actionable element returned by LLM",
188+
level: 1,
206189
});
190+
return {
191+
success: false,
192+
message: "Failed to perform act: No action found",
193+
actionDescription: instruction,
194+
actions: [],
195+
};
196+
}
207197

208-
let diffedTree = diffCombinedTrees(combinedTree, combinedTree2);
209-
if (!diffedTree.trim()) {
210-
// Fallback: if no diff detected, use the fresh tree to avoid empty context
211-
diffedTree = combinedTree2;
212-
}
198+
// First action (self-heal aware path)
199+
ensureTimeRemaining();
200+
const firstResult = await this.takeDeterministicAction(
201+
firstAction,
202+
page,
203+
this.defaultDomSettleTimeoutMs,
204+
llmClient,
205+
ensureTimeRemaining,
206+
);
213207

214-
const previousAction = `method: ${firstAction.method}, description: ${firstAction.description}, arguments: ${firstAction.arguments}`;
215-
216-
const stepTwoInstructions = buildStepTwoPrompt(
217-
instruction,
218-
previousAction,
219-
Object.values(SupportedPlaywrightAction).filter(
220-
(
221-
action,
222-
): action is Exclude<
223-
SupportedPlaywrightAction,
224-
SupportedPlaywrightAction.SELECT_OPTION_FROM_DROPDOWN
225-
> => action !== SupportedPlaywrightAction.SELECT_OPTION_FROM_DROPDOWN,
226-
),
227-
variables,
228-
);
208+
// If not two-step, return the first action result
209+
if (actInferenceResponse?.twoStep !== true) {
210+
return firstResult;
211+
}
229212

230-
const { action: secondAction } = await this.getActionFromLLM({
231-
instruction: stepTwoInstructions,
232-
domElements: diffedTree,
233-
xpathMap: combinedXpathMap2,
234-
llmClient,
235-
variables,
213+
// Take a new focused snapshot and observe again
214+
ensureTimeRemaining();
215+
const { combinedTree: combinedTree2, combinedXpathMap: combinedXpathMap2 } =
216+
await captureHybridSnapshot(page, {
217+
experimental: true,
236218
});
237219

238-
if (!secondAction) {
239-
// No second action found — return first result as-is
240-
return firstResult;
241-
}
220+
let diffedTree = diffCombinedTrees(combinedTree, combinedTree2);
221+
if (!diffedTree.trim()) {
222+
// Fallback: if no diff detected, use the fresh tree to avoid empty context
223+
diffedTree = combinedTree2;
224+
}
242225

243-
const secondResult = await this.takeDeterministicAction(
244-
secondAction,
245-
page,
246-
this.defaultDomSettleTimeoutMs,
247-
llmClient,
248-
);
226+
const previousAction = `method: ${firstAction.method}, description: ${firstAction.description}, arguments: ${firstAction.arguments}`;
249227

250-
// Combine results
251-
return {
252-
success: firstResult.success && secondResult.success,
253-
message: secondResult.success
254-
? `${firstResult.message}${secondResult.message}`
255-
: `${firstResult.message}${secondResult.message}`,
256-
actionDescription: firstResult.actionDescription,
257-
actions: [
258-
...(firstResult.actions || []),
259-
...(secondResult.actions || []),
260-
],
261-
};
262-
};
228+
const stepTwoInstructions = buildStepTwoPrompt(
229+
instruction,
230+
previousAction,
231+
Object.values(SupportedPlaywrightAction).filter(
232+
(
233+
action,
234+
): action is Exclude<
235+
SupportedPlaywrightAction,
236+
SupportedPlaywrightAction.SELECT_OPTION_FROM_DROPDOWN
237+
> => action !== SupportedPlaywrightAction.SELECT_OPTION_FROM_DROPDOWN,
238+
),
239+
variables,
240+
);
241+
242+
ensureTimeRemaining();
243+
const { action: secondAction } = await this.getActionFromLLM({
244+
instruction: stepTwoInstructions,
245+
domElements: diffedTree,
246+
xpathMap: combinedXpathMap2,
247+
llmClient,
248+
variables,
249+
});
263250

264-
// Hard timeout for entire act() call → reject on timeout (align with extract/observe)
265-
if (!timeout) {
266-
return doObserveAndAct();
251+
if (!secondAction) {
252+
// No second action found — return first result as-is
253+
return firstResult;
267254
}
268255

269-
return await Promise.race([
270-
doObserveAndAct(),
271-
new Promise<ActResult>((_, reject) => {
272-
setTimeout(
273-
() => reject(new Error(`act() timed out after ${timeout}ms`)),
274-
timeout,
275-
);
276-
}),
277-
]);
256+
ensureTimeRemaining();
257+
const secondResult = await this.takeDeterministicAction(
258+
secondAction,
259+
page,
260+
this.defaultDomSettleTimeoutMs,
261+
llmClient,
262+
ensureTimeRemaining,
263+
);
264+
265+
// Combine results
266+
return {
267+
success: firstResult.success && secondResult.success,
268+
message: secondResult.success
269+
? `${firstResult.message}${secondResult.message}`
270+
: `${firstResult.message}${secondResult.message}`,
271+
actionDescription: firstResult.actionDescription,
272+
actions: [
273+
...(firstResult.actions || []),
274+
...(secondResult.actions || []),
275+
],
276+
};
278277
}
279278

280279
async takeDeterministicAction(
281280
action: Action,
282281
page: Page,
283282
domSettleTimeoutMs?: number,
284283
llmClientOverride?: LLMClient,
284+
ensureTimeRemaining?: () => void,
285285
): Promise<ActResult> {
286+
ensureTimeRemaining?.();
286287
const settleTimeout = domSettleTimeoutMs ?? this.defaultDomSettleTimeoutMs;
287288
const effectiveClient = llmClientOverride ?? this.llmClient;
288289
const method = action.method?.trim();
@@ -307,6 +308,7 @@ export class ActHandler {
307308
const args = Array.isArray(action.arguments) ? action.arguments : [];
308309

309310
try {
311+
ensureTimeRemaining?.();
310312
await performUnderstudyMethod(
311313
page,
312314
page.mainFrame(),
@@ -329,6 +331,9 @@ export class ActHandler {
329331
],
330332
};
331333
} catch (err) {
334+
if (err instanceof ActTimeoutError) {
335+
throw err;
336+
}
332337
const msg = err instanceof Error ? err.message : String(err);
333338

334339
// Attempt self-heal: rerun actInference and retry with updated selector
@@ -356,6 +361,7 @@ export class ActHandler {
356361
: method;
357362

358363
// Take a fresh snapshot and ask for a new actionable element
364+
ensureTimeRemaining?.();
359365
const { combinedTree, combinedXpathMap } =
360366
await captureHybridSnapshot(page, {
361367
experimental: true,
@@ -367,6 +373,7 @@ export class ActHandler {
367373
{},
368374
);
369375

376+
ensureTimeRemaining?.();
370377
const { action: fallbackAction, response: fallbackResponse } =
371378
await this.getActionFromLLM({
372379
instruction,
@@ -393,6 +400,7 @@ export class ActHandler {
393400
newSelector = fallbackAction.selector;
394401
}
395402

403+
ensureTimeRemaining?.();
396404
await performUnderstudyMethod(
397405
page,
398406
page.mainFrame(),
@@ -416,6 +424,9 @@ export class ActHandler {
416424
],
417425
};
418426
} catch (retryErr) {
427+
if (retryErr instanceof ActTimeoutError) {
428+
throw retryErr;
429+
}
419430
const retryMsg =
420431
retryErr instanceof Error ? retryErr.message : String(retryErr);
421432
return {

0 commit comments

Comments
 (0)