diff --git a/backend/apps/agents/browser/browser_read_script.py b/backend/apps/agents/browser/browser_read_script.py index c957eb5a..8a1da46c 100644 --- a/backend/apps/agents/browser/browser_read_script.py +++ b/backend/apps/agents/browser/browser_read_script.py @@ -19,6 +19,12 @@ logger = logging.getLogger(__name__) ToolRunner = Callable[[str, Dict, str, str], Awaitable[Dict]] P_MIN_PAGE_CHARS = 500 +# First look: the same modest slice the main loop reads. Most pages answer from this. +FIRST_READ_CHARS = 15000 +# Second look, only after a decline: everything we can actually use. Half our live reads used to +# arrive pinned at exactly the smaller cap, with the answer (a reddit thread's comment scores sit +# after the post body) sitting just past the cut, so the aux declined and a 100-220s model loop went +# scrolling for text we had truncated ourselves. MAX_PAGE_CHARS = 24000 P_TEXT_TIMEOUT_S = 8.0 P_AUX_TIMEOUT_S = 12.0 @@ -102,16 +108,13 @@ async def run_read_script( try: from backend.apps.agents.core.aux_llm import safe_resp_text - async def p_page_text() -> tuple: + async def p_page_text(cap: int) -> tuple: """Page text, but only once two consecutive reads agree it has stopped growing.""" prev = -1 text, url = "", "" for attempt in range(MAX_READS): - # Ask for the whole budget we can actually use. The handler's default is sized for the - # main loop's context, not for this one aux call, and taking that default meant half - # our reads arrived pre-truncated with the answer sitting just past the cut. r = await asyncio.wait_for( - execute_tool("BrowserGetText", {"max_chars": MAX_PAGE_CHARS}, browser_id, tab_id), + execute_tool("BrowserGetText", {"max_chars": cap}, browser_id, tab_id), timeout=P_TEXT_TIMEOUT_S) text = str(r.get("text") or "") if isinstance(r, dict) and "error" not in r else "" url = str(r.get("url") or "") if isinstance(r, dict) else "" @@ -125,7 +128,13 @@ async def run_read_script( return (text, url) if len(text) >= P_MIN_PAGE_CHARS else ("", "") for ask in range(1 + P_INSUFFICIENT_RETRIES): - page, p_live_url = await p_page_text() + # Cheap read first, full read only if that one came up short. A search-results page is one + # we ALWAYS leave (the answer is a click deeper), so buying the whole thing up front is + # text we can never answer from. Note the wall-clock case for this is NOT proven: live + # sweeps of these tasks vary 5-12x run to run, which buries an effect this size. It stands + # on the shape alone, never pay for what you don't need, and the retry is where the big + # read earns its keep, on a page that IS the right page but got cut off. + page, p_live_url = await p_page_text(FIRST_READ_CHARS if ask == 0 else MAX_PAGE_CHARS) if len(page) < P_MIN_PAGE_CHARS: logger.info(f"[browser-readscript] page too thin ({len(page)} chars); loop runs") return None diff --git a/backend/tests/test_browser_read_script.py b/backend/tests/test_browser_read_script.py index 94a717ff..c73b7aa5 100644 --- a/backend/tests/test_browser_read_script.py +++ b/backend/tests/test_browser_read_script.py @@ -165,25 +165,40 @@ def test_a_page_that_simply_lacks_the_field_is_still_an_answer(): assert rs.is_answer(answer) == answer, answer -def test_the_read_asks_for_its_whole_budget_not_the_loops_default(): - """The handler's default char cap is sized for the MAIN model loop's context, not for this one - cheap aux call. Taking that default silently truncated the page: measured, 9 of 18 live reads - came back at EXACTLY the cap, and on a reddit thread the comment scores sit past the post body, - so the answer was cut off and a 100-220s model loop went looking for what we had removed.""" - asked = {} +def test_the_first_look_is_cheap_and_only_a_decline_buys_the_big_read(): + """Reading the whole page every time made a search-results page (which we ALWAYS leave, one + click deeper) pay for chars it could never answer from: amazon's median went 26.1s -> 58.5s. + So the first look is the modest slice and only an INSUFFICIENT escalates.""" + caps = [] async def run_tool(name, params, browser_id, tab_id): - asked.update(params or {}) + caps.append((params or {}).get("max_chars")) return {"text": PAGE, "url": "https://www.reddit.com/r/x/comments/1/y"} aux = Aux("The top comment is by u/someone with 387 upvotes.") out = asyncio.run(rs.run_read_script(aux, "m", "top comment?", "b1", "t1", run_tool)) assert out == "The top comment is by u/someone with 387 upvotes." - assert asked.get("max_chars") == rs.MAX_PAGE_CHARS, ( - f"read must ask for the {rs.MAX_PAGE_CHARS} chars it can use, asked {asked!r}") + assert set(caps) == {rs.FIRST_READ_CHARS}, f"an answered page must never buy the big read, got {caps}" + + +def test_a_decline_escalates_to_the_full_page(): + """The big read exists for the page that IS the right page but was cut off. On a decline we + re-read at the full budget before giving the task up to the model loop.""" + caps = [] + + async def run_tool(name, params, browser_id, tab_id): + caps.append((params or {}).get("max_chars")) + return {"text": PAGE, "url": "https://www.reddit.com/r/x/comments/1/y"} + + aux = Aux("INSUFFICIENT") + out = asyncio.run(rs.run_read_script(aux, "m", "top comment?", "b1", "t1", run_tool)) + assert out is None # still fails open to the loop + assert rs.FIRST_READ_CHARS in caps # cheap first + assert rs.MAX_PAGE_CHARS in caps, f"a decline must escalate to the full page, got {caps}" def test_the_budget_it_asks_for_is_the_budget_it_sends(): """If the ask and the send drift apart we are either paying for text we discard, or discarding text we paid for. They are the same number by construction.""" assert rs.MAX_PAGE_CHARS >= 24000 + assert rs.FIRST_READ_CHARS < rs.MAX_PAGE_CHARS