From 78843dba2f1da55125089c0af7afc3e397340813 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 30 Jul 2026 17:31:16 -0700 Subject: [PATCH] [eric] browser: a prose decline from the read script is a decline, not an answer --- .../agents/browser/browser_read_script.py | 18 +++++++++++ backend/tests/test_browser_read_script.py | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/backend/apps/agents/browser/browser_read_script.py b/backend/apps/agents/browser/browser_read_script.py index 23d8ff6d..937c0967 100644 --- a/backend/apps/agents/browser/browser_read_script.py +++ b/backend/apps/agents/browser/browser_read_script.py @@ -8,6 +8,7 @@ Fail-open everywhere: thin page, decline, error = the loop runs exactly as today import asyncio import logging import os +import re import time from typing import Awaitable, Callable, Dict, Optional @@ -57,12 +58,29 @@ def read_script_enabled() -> bool: return os.environ.get("OSW_READ_SCRIPT", "1") != "0" +# A decline written as prose instead of the token. The aux is asked to say INSUFFICIENT when the +# answer lives behind a click, and it usually does, but on a page that is ALMOST right it explains +# itself instead: "I can see the search results, but I cannot access the individual product page." +# That reply used to be accepted as the answer, which ended the run at the wrong page and made a +# perfectly good multi-step task fail 3 times out of 3 on amazon, deterministically. It is a decline, +# so it has to be read as one. Anchored on the aux declaring it cannot REACH somewhere, never on a +# page merely lacking a field, because "the page does not show a price" IS a legitimate answer here. +P_PROSE_DECLINE_RE = re.compile( + r"\b(?:cannot|can'?t|unable to|don'?t have (?:the )?ability to)\s+" + r"(?:\w+\s+){0,3}?(?:access|open|reach|navigate(?:\s+to)?|visit|click(?:\s+(?:on|into))?|load)\b" + r"|\b(?:would|will|you)\s+need\s+to\s+(?:\w+\s+){0,2}?" + r"(?:open|click|visit|navigate|go\s+to)\b", + re.I) + + def is_answer(reply: str) -> Optional[str]: """The usable answer text, or None. Declines, empties, and hedge-shaped replies all fail closed to the loop, so a thin extraction can never become a wrong answer.""" answer = (reply or "").strip() if not answer or answer.upper().startswith("INSUFFICIENT"): return None + if P_PROSE_DECLINE_RE.search(answer): + return None return answer diff --git a/backend/tests/test_browser_read_script.py b/backend/tests/test_browser_read_script.py index 242e8001..4ce8cff3 100644 --- a/backend/tests/test_browser_read_script.py +++ b/backend/tests/test_browser_read_script.py @@ -132,3 +132,34 @@ def test_no_aux_client_and_tool_error_both_fail_open(): return {"error": "card is gone"} assert asyncio.run(rs.run_read_script( Aux("answer"), "m", "t", "b1", "t1", broken_tool)) is None + + +def test_a_prose_decline_is_not_an_answer(): + """The exact reply that made amazon fail 3/3: the aux explains it cannot reach the page it + needs instead of emitting INSUFFICIENT. Accepting it ended the run on the search results and + the model loop, which could have clicked through, never got to run.""" + verbatim = ("I can see this is a search results page for \"usb c cable\" on Amazon, but I " + "cannot access the individual product page for the first result. The page shows " + "search results with multiple products listed, but to get the exact price, star " + "rating, and number of ratings I would need to open the product page.") + assert rs.is_answer(verbatim) is None + + for decline in ( + "I can't open the product page from here.", + "I am unable to navigate to that profile.", + "You would need to click into the post to see the replies.", + "I cannot reach the comments section on this page.", + ): + assert rs.is_answer(decline) is None, decline + + +def test_a_page_that_simply_lacks_the_field_is_still_an_answer(): + """The guard must not eat real answers. Per the read-script contract, a page that visibly + lacks the field IS the answer, and so is any answer that happens to contain the word 'open'.""" + for answer in ( + "The price is $12.99 and it has 4.5 stars from 8,214 ratings.", + "The profile does not show a headline; the name is Eric Zeng.", + "The first post is by @someone and the shop is open until 5pm.", + "Title: 'How to open a jar'. Channel: Kitchen Tips. 1.2M views.", + ): + assert rs.is_answer(answer) == answer, answer