mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[eric] browser: open the first post by its own link, instead of hunting a feed
This commit is contained in:
@@ -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 <thing>, 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(
|
||||
|
||||
@@ -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 ''; })()"
|
||||
)
|
||||
@@ -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("") == ""
|
||||
Reference in New Issue
Block a user