@@ -1752,6 +1752,12 @@ def build_executor_prompt(
17521752 (intent and any (kw in intent .lower () for kw in product_keywords ))
17531753 or any (kw in goal .lower () for kw in product_keywords )
17541754 )
1755+ # Check if this is an Add to Cart action
1756+ add_to_cart_keywords = ["add to cart" , "add to bag" , "add to basket" , "buy now" ]
1757+ is_add_to_cart_action = (
1758+ (intent and any (kw in intent .lower () for kw in add_to_cart_keywords ))
1759+ or any (kw in goal .lower () for kw in add_to_cart_keywords )
1760+ )
17551761 # Check if intent asks to match text (e.g., "Click element with text matching [keyword]")
17561762 is_text_matching_action = intent and "matching" in intent .lower ()
17571763 # Check if input_text specifies a target to match (for CLICK actions, input_text is target text)
@@ -1799,6 +1805,24 @@ def build_executor_prompt(
17991805 "Output MUST match exactly: CLICK(<digits>) with no spaces.\n "
18001806 "Example: CLICK(1268)"
18011807 )
1808+ elif is_add_to_cart_action :
1809+ # Add to Cart action - may need to click product first if on search results page
1810+ system = (
1811+ "You are an executor for browser automation.\n "
1812+ "Return ONLY a single-line CLICK(id) action.\n "
1813+ "If you output anything else, the action fails.\n "
1814+ "Do NOT output <think> or any reasoning.\n "
1815+ "ADD TO CART HINTS:\n "
1816+ "- FIRST: Look for buttons with text: 'Add to Cart', 'Add to Bag', 'Add to Basket', 'Buy Now'\n "
1817+ "- If found, click that button directly\n "
1818+ "- FALLBACK: If NO 'Add to Cart' button is visible, you are likely on a SEARCH RESULTS page\n "
1819+ " - In this case, click a PRODUCT LINK to go to the product details page first\n "
1820+ " - Look for LINK elements with product IDs in href (e.g., /7027762, /dp/B...)\n "
1821+ " - Prefer links with product names, prices, or delivery info\n "
1822+ "- AVOID: 'Search' buttons, category buttons, filter buttons, pagination\n "
1823+ "Output MUST match exactly: CLICK(<digits>) with no spaces.\n "
1824+ "Example: CLICK(42)"
1825+ )
18021826 else :
18031827 system = (
18041828 "You are an executor for browser automation.\n "
@@ -2940,41 +2964,54 @@ async def replan(
29402964Return ONLY a JSON object with mode="patch" and replace_steps array.
29412965
29422966IMPORTANT - Alternative approaches when CLICK fails:
2943- - If a direct product/element click failed, try a DIFFERENT approach:
2944- * Use search: Add a TYPE_AND_SUBMIT step to search for the product
2945- * Use category navigation: Click a category link instead of the product directly
2946- * Click a different element: Try "Quick Shop" or "View Details" buttons
2967+ - If a product/category navigation failed, USE SITE SEARCH instead:
2968+ * Replace the failed CLICK with a TYPE_AND_SUBMIT to search for the product
2969+ * This is the MOST RELIABLE fallback - site search works on all websites
2970+ - If clicking a specific element failed:
2971+ * Try a different selector or button (e.g., "Quick Shop", "View Details")
29472972- Don't just retry the same approach with minor changes"""
29482973
2974+ # Extract product/item name from step for search suggestion
2975+ product_hint = ""
2976+ step_labels = " " .join ([
2977+ failed_step .goal or "" ,
2978+ failed_step .target or "" ,
2979+ failed_step .intent or "" ,
2980+ ]).lower ()
2981+ # Common patterns to extract product name
2982+ for pattern in [r"snow\s*blower" , r"product\s+(\w+)" , r"click\s+(?:on\s+)?(.+?)(?:\s+product|\s+category)?$" ]:
2983+ match = re .search (pattern , step_labels , re .IGNORECASE )
2984+ if match :
2985+ product_hint = match .group (0 ) if match .lastindex is None else match .group (1 )
2986+ break
2987+ if not product_hint and failed_step .target :
2988+ product_hint = failed_step .target
2989+
29492990 user = f"""Task: { task }
29502991
29512992Failure:
29522993- Step ID: { failed_step .id }
29532994- Step goal: { failed_step .goal }
29542995- Reason: { failure_reason }
29552996
2956- IMPORTANT: The element could not be clicked or the verification failed.
2957- Try a DIFFERENT approach - pick ONE of these alternatives:
2958-
2959- 1. If clicking a category link failed (e.g., "Rally Home Goods" in sidebar):
2960- - Try clicking "Catalog" or "Shop All" link instead
2961- - Or click "SHOP NOW" button to browse all products
2962-
2963- 2. If clicking a product failed:
2964- - Try clicking a category first, then look for the product
2965- - Or try clicking "Quick Shop" button near the product
2997+ IMPORTANT: The element could not be found or clicked. The current page likely doesn't have the target.
2998+ The BEST approach is to USE SITE SEARCH to find the product directly.
29662999
2967- Example - replace failed category click with Catalog link :
3000+ RECOMMENDED: Replace the failed step with a site search :
29683001{{
29693002 "mode": "patch",
29703003 "replace_steps": [
29713004 {{
29723005 "id": { failed_step .id } ,
2973- "step": {{ "id": { failed_step .id } , "goal": "Click Catalog to browse products ", "action": "CLICK ", "intent": "Click Catalog or Shop Now link ", "verify": [{{"predicate": "url_contains", "args": ["/collections "]}}] }}
3006+ "step": {{ "id": { failed_step .id } , "goal": "Search for { product_hint or 'the product' } ", "action": "TYPE_AND_SUBMIT ", "input": " { product_hint or 'product name' } ", " intent": "Type in search box and submit ", "verify": [{{"predicate": "url_contains", "args": ["search "]}}] }}
29743007 }}
29753008 ]
29763009}}
29773010
3011+ Alternative approaches (if search doesn't apply):
3012+ 1. Click "Catalog" or "Shop All" to browse products
3013+ 2. Click "Quick Shop" or "View Details" buttons
3014+
29783015Return JSON patch:"""
29793016
29803017 for attempt in range (1 , max_attempts + 1 ):
@@ -3492,6 +3529,102 @@ def _looks_like_search_submission(self, step: PlanStep, element: Any | None) ->
34923529 ).lower ()
34933530 return "search" in labels
34943531
3532+ def _is_add_to_cart_step (self , step : PlanStep ) -> bool :
3533+ """Detect if a step is an Add to Cart action."""
3534+ add_to_cart_keywords = ["add to cart" , "add to bag" , "add to basket" , "buy now" ]
3535+ labels = " " .join (
3536+ str (part or "" ).lower ()
3537+ for part in (step .goal , step .intent , step .input )
3538+ )
3539+ return any (kw in labels for kw in add_to_cart_keywords )
3540+
3541+ def _is_search_results_url (self , url : str ) -> bool :
3542+ """Check if URL looks like a search results page."""
3543+ url_lower = url .lower ()
3544+ # Common patterns for search results pages
3545+ search_patterns = [
3546+ "search" ,
3547+ "query=" ,
3548+ "q=" ,
3549+ "s=" ,
3550+ "/s?" ,
3551+ "keyword=" ,
3552+ "keywords=" ,
3553+ "results" ,
3554+ ]
3555+ return any (pattern in url_lower for pattern in search_patterns )
3556+
3557+ def _is_category_navigation_step (self , step : PlanStep ) -> bool :
3558+ """Check if this step is navigating to a category/section."""
3559+ nav_keywords = [
3560+ "navigate to" , "go to" , "click category" , "category link" ,
3561+ "click on" , "browse" , "section" , "department"
3562+ ]
3563+ labels = " " .join (
3564+ str (part or "" ).lower ()
3565+ for part in (step .goal , step .intent )
3566+ )
3567+ return any (kw in labels for kw in nav_keywords )
3568+
3569+ def _url_change_matches_intent (self , step : PlanStep , pre_url : str , post_url : str ) -> bool :
3570+ """
3571+ Check if URL change actually matches the step's intent.
3572+
3573+ For category navigation, the new URL should contain keywords from the target.
3574+ This prevents accepting unrelated URL changes as successful navigation.
3575+ """
3576+ # Extract target keywords from step
3577+ target = step .target or ""
3578+ intent = step .intent or ""
3579+ goal = step .goal or ""
3580+
3581+ post_url_lower = post_url .lower ()
3582+
3583+ # Special case: checkout/cart related steps
3584+ # These steps may go to /cart first before /checkout, which is valid
3585+ checkout_keywords = ["checkout" , "proceed to checkout" , "cart" , "view cart" ]
3586+ step_labels = f"{ goal } { intent } " .lower ()
3587+ is_checkout_step = any (kw in step_labels for kw in checkout_keywords )
3588+ if is_checkout_step :
3589+ # Accept cart or checkout URLs as valid for checkout steps
3590+ checkout_url_patterns = ["cart" , "checkout" , "basket" , "bag" ]
3591+ if any (pattern in post_url_lower for pattern in checkout_url_patterns ):
3592+ return True
3593+
3594+ # Get keywords from target (e.g., "Outdoor Power Equipment" -> ["outdoor", "power", "equipment"])
3595+ target_words = set (
3596+ word .lower () for word in re .split (r'[\s\-_]+' , target )
3597+ if len (word ) >= 3 # Skip short words like "to", "and"
3598+ )
3599+
3600+ # Also check predicates for expected URL patterns
3601+ expected_patterns = []
3602+ for pred in (step .verify or []):
3603+ if pred .predicate == "url_contains" and pred .args :
3604+ expected_patterns .append (pred .args [0 ].lower ())
3605+
3606+ # If predicates specify URL patterns, check those
3607+ if expected_patterns :
3608+ if any (pattern in post_url_lower for pattern in expected_patterns ):
3609+ return True
3610+ # For non-checkout steps, reject URL changes that don't match predicates
3611+ # But only if we have a target to validate against
3612+ if target_words :
3613+ return False
3614+ # No target and no predicate match - be permissive
3615+ return True
3616+
3617+ # Otherwise check if target keywords appear in URL
3618+ if target_words :
3619+ # At least one target word should appear in URL
3620+ if any (word in post_url_lower for word in target_words ):
3621+ return True
3622+ # URL doesn't contain any target keywords - suspicious
3623+ return False
3624+
3625+ # No target specified - can't validate, allow fallback
3626+ return True
3627+
34953628 def _find_submit_button_for_type_and_submit (
34963629 self ,
34973630 * ,
@@ -4702,6 +4835,95 @@ async def _execute_step(
47024835 element = typed_element ,
47034836 typed_text = executor_text or step .input or "" ,
47044837 )
4838+ elif original_action == "CLICK" and is_meaningful_change :
4839+ # For CLICK actions, validate URL change matches step intent
4840+ # This prevents accepting wrong category navigations
4841+ url_matches_intent = self ._url_change_matches_intent (
4842+ step = step ,
4843+ pre_url = pre_url ,
4844+ post_url = current_url ,
4845+ )
4846+ if not url_matches_intent :
4847+ fallback_ok = False
4848+ if self .config .verbose :
4849+ print (f" [VERIFY] URL changed but doesn't match step intent" , flush = True )
4850+ print (f" [VERIFY] Step target: { step .target } , URL: { current_url } " , flush = True )
4851+
4852+ # Special handling for Add to Cart steps: if we were on search results
4853+ # and navigated to a product page, retry the step on the new page
4854+ # instead of accepting URL change as success
4855+ is_add_to_cart = self ._is_add_to_cart_step (step )
4856+ was_on_search_results = self ._is_search_results_url (pre_url )
4857+ now_on_product_page = not self ._is_search_results_url (current_url )
4858+
4859+ if is_add_to_cart and was_on_search_results and now_on_product_page and is_meaningful_change :
4860+ # We clicked a product link instead of Add to Cart
4861+ # Retry the step on the product page
4862+ if self .config .verbose :
4863+ print (f" [ADD-TO-CART] Navigated from search results to product page: { pre_url } -> { current_url } " , flush = True )
4864+ print (f" [ADD-TO-CART] Retrying Add to Cart action on product page..." , flush = True )
4865+
4866+ # Get fresh snapshot on the product page
4867+ await asyncio .sleep (0.5 ) # Brief wait for page to load
4868+ try :
4869+ retry_ctx = await self ._get_execution_context (
4870+ runtime , step , step_index
4871+ )
4872+ # Build prompt for retry - looking for Add to Cart on product page
4873+ retry_prompt = self .build_executor_prompt (
4874+ goal = step .goal ,
4875+ elements = retry_ctx .snapshot .elements or [],
4876+ intent = step .intent ,
4877+ task_category = retry_ctx .task_category ,
4878+ input_text = step .input ,
4879+ )
4880+ if self .config .verbose :
4881+ print (f" [ADD-TO-CART] Asking executor to find Add to Cart button..." , flush = True )
4882+
4883+ retry_resp = self .executor .generate (
4884+ retry_prompt ["system" ],
4885+ retry_prompt ["user" ],
4886+ max_tokens = self .config .executor_max_tokens ,
4887+ )
4888+ self ._usage .record (role = "executor" , resp = retry_resp )
4889+ retry_action = retry_resp .content .strip ()
4890+ if self .config .verbose :
4891+ print (f" [ADD-TO-CART] Retry executor output: { retry_action } " , flush = True )
4892+
4893+ # Parse and execute retry action
4894+ retry_match = re .match (r"CLICK\((\d+)\)" , retry_action )
4895+ if retry_match :
4896+ retry_element_id = int (retry_match .group (1 ))
4897+ await runtime .click (retry_element_id )
4898+ if self .config .verbose :
4899+ print (f" [ADD-TO-CART] Clicked element { retry_element_id } " , flush = True )
4900+
4901+ # Wait and verify
4902+ await asyncio .sleep (0.5 )
4903+ verification_passed = await self ._verify_step (runtime , step )
4904+ if verification_passed :
4905+ if self .config .verbose :
4906+ print (f" [ADD-TO-CART] Add to Cart successful after retry!" , flush = True )
4907+ else :
4908+ # Check for DOM change (cart drawer/modal)
4909+ post_retry_snap = await runtime .snapshot (SnapshotOptions (limit = 50 ))
4910+ if post_retry_snap and hasattr (post_retry_snap , "elements" ):
4911+ post_els = post_retry_snap .elements or []
4912+ cart_indicators = ["cart" , "bag" , "basket" , "checkout" , "added" , "item" ]
4913+ has_cart_indicator = any (
4914+ any (ind in (getattr (el , "text" , "" ) or "" ).lower () for ind in cart_indicators )
4915+ for el in post_els [:30 ]
4916+ )
4917+ if has_cart_indicator :
4918+ if self .config .verbose :
4919+ print (f" [ADD-TO-CART] Cart indicator detected, accepting as success" , flush = True )
4920+ verification_passed = True
4921+ except Exception as retry_err :
4922+ if self .config .verbose :
4923+ print (f" [ADD-TO-CART] Retry failed: { retry_err } " , flush = True )
4924+
4925+ # Skip the normal URL fallback since we handled Add to Cart specially
4926+ fallback_ok = False
47054927
47064928 if fallback_ok :
47074929 if self .config .verbose :
0 commit comments