@@ -5,6 +5,7 @@ import { trimTrailingTextNode } from "../../utils";
55import { v3Logger } from "../logger" ;
66import { ActHandlerParams } from "../types/private/handlers" ;
77import { ActResult , Action , V3FunctionName } from "../types/public/methods" ;
8+ import { ActTimeoutError } from "../types/public/sdkErrors" ;
89import {
910 captureHybridSnapshot ,
1011 diffCombinedTrees ,
@@ -22,6 +23,7 @@ import {
2223 performUnderstudyMethod ,
2324 waitForDomNetworkQuiet ,
2425} from "./handlerUtils/actHandlerUtils" ;
26+ import { createTimeoutGuard } from "./handlerUtils/timeoutGuard" ;
2527
2628type 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