From 324639e21725c2143ebfceb8ed9585a35e78fb25 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 4 Aug 2026 23:26:37 -0700 Subject: [PATCH] [eric] browser: open the first post by its own link, instead of hunting a feed --- .../apps/agents/browser/browser_prestage.py | 32 +++++++++ .../apps/agents/browser/first_item_of_type.py | 66 +++++++++++++++++++ backend/tests/test_first_item_of_type.py | 61 +++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 backend/apps/agents/browser/first_item_of_type.py create mode 100644 backend/tests/test_first_item_of_type.py diff --git a/backend/apps/agents/browser/browser_prestage.py b/backend/apps/agents/browser/browser_prestage.py index 34941c88..35016663 100644 --- a/backend/apps/agents/browser/browser_prestage.py +++ b/backend/apps/agents/browser/browser_prestage.py @@ -19,6 +19,7 @@ import time from typing import Awaitable, Callable from backend.apps.agents.browser import browser_send_parse, compose_discovery, compose_entry +from backend.apps.agents.browser import first_item_of_type from backend.apps.agents.browser.strip_lone_surrogates import strip_lone_surrogates logger = logging.getLogger(__name__) @@ -400,6 +401,7 @@ async def run_prestage( p_results_overruled = False p_composer_overruled = False p_ctype_overruled = False + p_first_item_tried = False while (not staged_complete and steps < p_max_steps and (time.monotonic() - t0) < p_total_timeout): # Per-step cost, broken out. Prestage is the largest single phase of a LinkedIn write @@ -421,6 +423,36 @@ async def run_prestage( f"{int((time.monotonic() - p_t_step) * 1000)}ms: a composer is already " f"on the page, no aux call needed") break + # TIER 1: the task asked for the FIRST , and the page's own links say which ones + # are that thing. Instagram posts are /p/, tiktok videos /video/, youtube /watch. Picking + # the first matching link is deterministic; asking a model to find it in a feed is not, + # and that is where instagram kept going wrong (1/10, 3/5, 0/5, 0/5 across four windows, + # landing in the stories viewer, on a profile, or nowhere). Costs one evaluate against + # the 1.7-6.5s aux call it replaces, and it can only ever choose a URL the downstream + # wrong-surface guard would also accept, because both read the same type patterns. + if task_is_send and not p_first_item_tried: + p_first_item_tried = True + p_want = first_item_of_type.wanted_type(task) + p_expr = first_item_of_type.first_link_expression(p_want) if p_want else "" + if p_expr and not browser_send_parse.content_type_mismatch(task, current_url): + try: + p_r = await asyncio.wait_for( + execute_tool("BrowserEvaluate", {"expression": p_expr}, + browser_id, tab_id), timeout=6.0) + p_href = str((p_r or {}).get("value") or (p_r or {}).get("text") or "").strip() + except Exception: + p_href = "" + if p_href.startswith("http") and p_href != current_url: + logger.info(f"[browser-prestage] tier-1: first {p_want} is {p_href[:70]}") + r = await execute_tool("BrowserNavigate", {"url": p_href}, browser_id, tab_id) + if isinstance(r, dict) and "error" not in r: + recs.append({"tool": "BrowserNavigate", "input": {"url": p_href}, + "ok": True, "result_summary": f"first {p_want}"[:200], + "elapsed_ms": 0}) + done_desc.append(f"opened the first {p_want}") + current_url = p_href + steps += 1 + continue p_t_aux = time.monotonic() reply = safe_resp_text(await asyncio.wait_for( client.messages.create( diff --git a/backend/apps/agents/browser/first_item_of_type.py b/backend/apps/agents/browser/first_item_of_type.py new file mode 100644 index 00000000..119d04c3 --- /dev/null +++ b/backend/apps/agents/browser/first_item_of_type.py @@ -0,0 +1,66 @@ +"""Open the first item of the kind the task named, without asking a model to find it. + +"Comment on the first post" is a RESPOND task, so the compose-URL table (which answers "where do I +CREATE one of these?") does not apply, and the aux navigator has to find the item by clicking around +a feed. That is where instagram kept going wrong: measured across four windows it scored 1/10, 3/5, +0/5 and 0/5, landing variously in the stories viewer, on a profile, or nowhere, because the feed's +first clickable thing is not reliably a post. + +But a site already publishes what each link IS, in the link's own URL. Instagram posts live at /p/, +tiktok videos at /video/, youtube at /watch, reddit threads at /comments/. So when the task names a +content type, we can pick the first link of that type deterministically instead of hoping the model +picks it. One page evaluate plus one navigate, no aux turn. + +Generalises by construction: the patterns are the same P_URL_CONTENT_TYPES the wrong-surface guard +already uses, so a URL that would be REFUSED downstream as the wrong kind can never be chosen here. +""" + +import re +from typing import Optional + +from typeguard import typechecked + +from backend.apps.agents.browser import browser_send_parse + +# "the first post", "the top video", "the first story". Ordinal words only: without one the task is +# about a specific named thing, and picking the first anything would be a guess. +P_FIRST_RE = re.compile(r"\b(first|top|latest|newest|most recent)\b", re.I) + + +@typechecked +def wanted_type(task: str) -> Optional[str]: + """The content type this task wants the first of, or None. + + Requires BOTH an ordinal ("first", "top", "latest") and exactly one content-type word, so + "comment on the first post" qualifies and "reply to Sarah's message" does not: the second names + a specific target that a positional pick would get wrong. + """ + if not task or not P_FIRST_RE.search(task): + return None + hits = [name for name, rx in browser_send_parse.P_TASK_CONTENT_TYPES if rx.search(task)] + return hits[0] if len(hits) == 1 else None + + +@typechecked +def first_link_expression(content_type: str) -> str: + """JS returning the first same-origin link whose URL says it is `content_type`, or "". + + Same-origin on purpose: a feed is full of outbound links, and following one off-site turns a + "comment on the first post" into a visit to whatever an ad pointed at. + """ + pattern = { + "post": r"/(p|posts?|status)/", + "video": r"/(watch|shorts)\b|/video/", + "story": r"/stories?/", + "message": r"/(direct|messages?)/", + }.get(content_type, "") + if not pattern: + return "" + return ( + "(() => { const rx = new RegExp(" + repr(pattern).replace("'", '"') + "); " + "for (const a of document.querySelectorAll('a[href]')) { " + " let u; try { u = new URL(a.href, location.href); } catch (e) { continue; } " + " if (u.origin !== location.origin) continue; " + " if (rx.test(u.pathname)) return u.href; } " + "return ''; })()" + ) diff --git a/backend/tests/test_first_item_of_type.py b/backend/tests/test_first_item_of_type.py new file mode 100644 index 00000000..6d62d7f3 --- /dev/null +++ b/backend/tests/test_first_item_of_type.py @@ -0,0 +1,61 @@ +"""Open the first item of the kind the task named, without asking a model to find it. + +"Comment on the first post" is a RESPOND task, so the compose-URL table (which answers "where do I +CREATE one of these?") does not apply and the aux navigator has to find the item by clicking around a +feed. Measured on instagram across four windows: 1/10, 3/5, 0/5, 0/5, landing variously in the +stories viewer, on a profile, or nowhere, because a feed's first clickable thing is not reliably a +post. + +But a site publishes what each link IS, in the link's own URL: instagram posts at /p/, tiktok videos +at /video/, youtube at /watch. Picking the first match is deterministic where a model is not. + +The rules exist to stop it firing when a positional pick would be WRONG, which is the only way this +tier can hurt: it navigates, and navigating away from the right page costs the run. +""" + +from backend.apps.agents.browser import first_item_of_type as fit + + +def test_an_ordinal_plus_one_content_type_qualifies(): + assert fit.wanted_type("write a comment on the first post") == "post" + assert fit.wanted_type("comment on the first video") == "video" + assert fit.wanted_type("reply to the top story") == "story" + + +def test_a_named_target_is_never_a_positional_pick(): + """"reply to Sarah's message" names WHICH one. Opening "the first message" would answer the + wrong person, which is the same family as the DM incident this codebase already has scars from.""" + assert fit.wanted_type("reply to Sarah's message") is None + assert fit.wanted_type("comment on the post about rust") is None + + +def test_no_content_type_named_means_no_pick(): + assert fit.wanted_type("post this tweet") is None + assert fit.wanted_type("send hi to charles") is None + + +def test_two_content_types_stay_ambiguous(): + """A task naming two kinds gives no basis for choosing which to open first.""" + assert fit.wanted_type("reply to the first story on that post") is None + assert fit.wanted_type("comment on the first video or post") is None + + +def test_the_link_search_is_same_origin_only(): + """A feed is full of outbound links and ads. Following one turns "comment on the first post" + into a visit to whatever an advertiser paid for.""" + expr = fit.first_link_expression("post") + assert "u.origin !== location.origin" in expr, "must refuse cross-origin links" + assert "querySelectorAll('a[href]')" in expr + + +def test_each_type_gets_the_shape_that_site_family_actually_uses(): + assert "/(p|posts?|status)/" in fit.first_link_expression("post") # instagram, mastodon + assert "/(watch|shorts)" in fit.first_link_expression("video") # youtube + assert "/video/" in fit.first_link_expression("video") # tiktok + assert "/stories?/" in fit.first_link_expression("story") + + +def test_an_unknown_type_yields_no_expression_rather_than_a_broken_one(): + """No expression means the tier stands down and the aux navigator runs, i.e. today's behaviour.""" + assert fit.first_link_expression("banana") == "" + assert fit.first_link_expression("") == ""