From 95b0e00d51b2f304b170d8d1d624cfa57c5a9339 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 14:42:35 -0700 Subject: [PATCH] [eric] browser: stop the caller starving the send-script, which silently killed every write --- backend/apps/agents/browser/browser_agent.py | 6 +++-- .../agents/browser/browser_send_script.py | 12 +++++++++ backend/tests/test_browser_send_honesty.py | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index cb192fd7..cfb69d4f 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -1690,9 +1690,11 @@ async def run_browser_agent( task, browser_id, tab_id, preloaded_perception, execute_browser_tool, send_submit_index_in_state, payload_in_textbox, payload_source=user_prompt, current_url=current_url, - ), timeout=30.0) + ), timeout=browser_send_script.WORST_CASE_BUDGET_S) except Exception as p_se: - logger.info(f"[browser-sendscript] outer skip ({p_se})") + # Name the class: a bare TimeoutError stringifies to nothing, so this used to log + # "outer skip ()" and a starved send looked identical to a page we chose not to touch. + logger.info(f"[browser-sendscript] outer skip ({type(p_se).__name__}: {p_se})") p_script = None if isinstance(p_script, dict): action_log.extend(p_script["log"]) diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index 912f00ce..e08eed31 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -27,6 +27,18 @@ logger = logging.getLogger(__name__) ToolRunner = Callable[[str, dict, str, str], Awaitable[dict]] +# The worst case this routine can legitimately take, so the CALLER cannot starve it. Roughly: the +# composer poll (3 backoff waits plus three 6s interactive lists), one structural finder call (which +# carries its own 30s command timeout), then fill-and-commit and submit-and-receipt. +# +# This constant exists because the caller's timeout and this routine's real cost silently drifted +# apart: raising find_composer's command timeout from 15s to 30s made a single finder call able to +# eat the caller's entire 30s budget, so the whole script was killed mid-send and EVERY write fell +# back to the slow model loop. It failed invisibly, because asyncio.TimeoutError stringifies to +# nothing and the log read "outer skip ()". Measured live on LinkedIn: a 190.9s write that never +# posted. Import this instead of writing a number at the call site. +WORST_CASE_BUDGET_S = 75.0 + def script_enabled() -> bool: return os.environ.get("OSW_SEND_SCRIPT", "0") != "0" diff --git a/backend/tests/test_browser_send_honesty.py b/backend/tests/test_browser_send_honesty.py index 30b4d0fb..b9fb7794 100644 --- a/backend/tests/test_browser_send_honesty.py +++ b/backend/tests/test_browser_send_honesty.py @@ -53,6 +53,31 @@ def test_the_two_facts_are_not_the_same_variable(): "the run's success must be a function of evidence, not of the model's Done argument" +def test_the_caller_cannot_starve_the_send_script(): + """INVARIANT, and the third time this exact trap has bitten (find_composer, import_session, now + this). The send-script's cost and its caller's timeout drifted apart when find_composer went + 15s -> 30s: one finder call could eat the caller's whole 30s budget, so the script was killed + mid-send and EVERY write silently fell back to the slow model loop. It was invisible because + asyncio.TimeoutError stringifies to nothing, so the log read "outer skip ()". + + Measured live on LinkedIn: a 190.9s write that never posted.""" + from backend.apps.agents.browser import browser_send_script as ss + from backend.apps.agents.core.ws_manager import BROWSER_CMD_TIMEOUTS + + assert "timeout=browser_send_script.WORST_CASE_BUDGET_S" in P_SRC, ( + "the caller hardcodes its own timeout again; it must import the script's stated worst case " + "so the two cannot drift") + # A single finder call must not be able to consume the whole budget. + assert ss.WORST_CASE_BUDGET_S > BROWSER_CMD_TIMEOUTS.get("find_composer", 15.0) * 2, \ + "the budget must leave room for fill and submit after the finder, not just the finder" + + +def test_a_starved_send_is_logged_by_exception_class(): + """A bare TimeoutError has an empty message, so a starved send read exactly like a page we + deliberately declined to touch. The class name is what makes those two distinguishable.""" + assert "type(p_se).__name__" in P_SRC + + def test_an_unverified_send_gets_an_honest_line_that_does_not_claim_delivery(): note = dc.unverified_send_note("https://x.com/home", "hello from my automation") low = note.lower()