diff --git a/backend/apps/agents/browser/browser_read_script.py b/backend/apps/agents/browser/browser_read_script.py index e8dfe056..c957eb5a 100644 --- a/backend/apps/agents/browser/browser_read_script.py +++ b/backend/apps/agents/browser/browser_read_script.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) ToolRunner = Callable[[str, Dict, str, str], Awaitable[Dict]] P_MIN_PAGE_CHARS = 500 -P_MAX_PAGE_CHARS = 24000 +MAX_PAGE_CHARS = 24000 P_TEXT_TIMEOUT_S = 8.0 P_AUX_TIMEOUT_S = 12.0 # Prestage's click often lands here while the SPA is still hydrating (measured: a @@ -107,8 +107,12 @@ async def run_read_script( 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", {}, browser_id, tab_id), timeout=P_TEXT_TIMEOUT_S) + execute_tool("BrowserGetText", {"max_chars": MAX_PAGE_CHARS}, 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 "" if len(text) >= P_MIN_PAGE_CHARS and 0 <= prev <= len(text) <= prev * (1 + P_STABLE_GROWTH): @@ -131,7 +135,7 @@ async def run_read_script( aux_client.messages.create( model=aux_model, max_tokens=500, temperature=0, system=P_SYSTEM, messages=[{"role": "user", "content": ( - f"Request: {task[:1200]}\n\nPage text:\n{page[:P_MAX_PAGE_CHARS]}")}], + f"Request: {task[:1200]}\n\nPage text:\n{page[:MAX_PAGE_CHARS]}")}], ), timeout=P_AUX_TIMEOUT_S)) ms = int((time.monotonic() - t0) * 1000) answer = is_answer(reply) diff --git a/backend/tests/test_browser_read_script.py b/backend/tests/test_browser_read_script.py index 4ce8cff3..94a717ff 100644 --- a/backend/tests/test_browser_read_script.py +++ b/backend/tests/test_browser_read_script.py @@ -163,3 +163,27 @@ def test_a_page_that_simply_lacks_the_field_is_still_an_answer(): "Title: 'How to open a jar'. Channel: Kitchen Tips. 1.2M views.", ): 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 = {} + + async def run_tool(name, params, browser_id, tab_id): + asked.update(params or {}) + 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}") + + +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 diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index 1cf90d73..b1aa42c0 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -257,9 +257,20 @@ async function evalViaExecuteJs(wv: BrowserWebview, code: string): Promise return first.value; } -async function handleGetText(wv: BrowserWebview): Promise> { +// What the MAIN model loop gets. Every read lands in its context, so this stays modest on purpose. +const GET_TEXT_DEFAULT_CHARS = 15000; +// Ceiling for a caller that asks for more. The read script (one cheap aux call with its own context) +// asks for its full budget: measured, 9 of 18 of its reads came back at EXACTLY 15000 chars, meaning +// cut off, and on a reddit thread the comment scores sit after the post body, so they were never in +// the text it was handed. It then declined, and a 100-220s model loop went scrolling for what we had +// truncated ourselves. +const GET_TEXT_MAX_CHARS = 30000; + +async function handleGetText(wv: BrowserWebview, params: Record = {}): Promise> { + const asked = Number(params.max_chars) || GET_TEXT_DEFAULT_CHARS; + const cap = Math.min(Math.max(asked, 1000), GET_TEXT_MAX_CHARS); const text: string = await evalInPage(wv, - 'document.body.innerText.substring(0, 15000)' + `document.body.innerText.substring(0, ${cap})` ); // Sampled HERE (on a read), not on navigate: by the time the agent reads the page, the SPA's XHR/fetch have fired, so routes are actually captured. const routes_available = await countSafeRoutes(wv); @@ -2028,7 +2039,7 @@ async function runBrowserCommand( result = await handleScreenshot(wv, params); break; case 'get_text': - result = await handleGetText(wv); + result = await handleGetText(wv, params); break; case 'get_console': result = await handleGetConsole(wv);