Skip to content

Commit e4d7696

Browse files
codexclaude
andcommitted
fix(browser): the pursuit was cancelled at 181s, then blamed the website
Measured live: 3,183-token decision prompts with 2,471 tokens re-prefilled every round, roughly a minute a round, and the turn's own budget killing the generation mid-pursuit. What the person then read was "the page did not respond to any action" — a confident claim about a website, made because a model call had been cancelled. The page had been responding fine. Two things, both general. The observation was unbounded. A live questionnaire renders 83 controls and most are site furniture — nav, language, login, footer — emitted first in document order, so truncating the raw list would have cut the answers and kept the chrome. The controls are ranked by what they DO, and the ones that can advance a goal are offered first. Page text is halved. The loop resolves indices against the same ranked list she was shown, because rendering a subset and resolving against the raw one means index 3 names one control on screen and a different one in the click — the precise way these loops end up pressing whatever moved into slot four. And an empty error is not evidence about a page. A cancelled generation returns {"status": "failed", "error": ""}, which was being rendered as a finding about the site. It now says what is actually known: the status it ended in, or that it ended before it could act. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent cae5e97 commit e4d7696

3 files changed

Lines changed: 100 additions & 4 deletions

File tree

core/skills/desktop_task.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5863,7 +5863,24 @@ async def _delegate_page_objective(
58635863
if step.get("error"):
58645864
failure = str(step["error"])
58655865
if not failure and not report.get("ok"):
5866-
failure = str(report.get("error") or "the page did not respond to any action")
5866+
# Say what actually happened, not a guess about the page.
5867+
#
5868+
# A cancelled generation comes back `{"status": "failed",
5869+
# "error": ""}`, and an empty error was being rendered as "the page
5870+
# did not respond to any action" — a confident claim about a
5871+
# website, made because a model call was killed mid-round. The turn
5872+
# ran 181s and was cancelled by its own budget; the page had been
5873+
# responding fine.
5874+
reported = str(report.get("error") or "").strip()
5875+
status = str(report.get("status") or "").strip()
5876+
if reported:
5877+
failure = reported
5878+
elif status and status != "completed":
5879+
failure = f"the browser task ended as {status} without saying why"
5880+
elif report.get("rounds") in (None, 0):
5881+
failure = "the browser task ended before it could act"
5882+
else:
5883+
failure = "the page did not respond to any action"
58675884
# The lane's own result shape, not a parallel vocabulary.
58685885
#
58695886
# Third time in this integration: `final_url` where the effect verifier

core/skills/sovereign_browser.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -881,7 +881,7 @@ async def _handle_interact(
881881
#: How many of the page's own words travel with the element list. The
882882
#: controls say what can be done; this says what is being asked, and a
883883
#: questionnaire is unanswerable without it.
884-
PURSUE_TEXT_BUDGET = 1800
884+
PURSUE_TEXT_BUDGET = 900
885885
#: Consecutive rounds that change neither the URL nor the set of controls
886886
#: before the loop concedes. Two is enough to distinguish a slow page from
887887
#: a wall: the first repeat may be a re-render, the second is a loop.
@@ -897,10 +897,44 @@ def _observation_signature(observation: Mapping[str, Any]) -> str:
897897
)
898898
return f"{observation.get('url')}#{marks}"
899899

900+
#: Controls offered to one decision. A live questionnaire renders 83, most
901+
#: of them site furniture — nav, language, login, footer — and every one of
902+
#: them is prefill on every round. Measured: 3,183-token prompts with 2,471
903+
#: re-prefilled each time, ~60s a round, and the turn cancelled at 181s
904+
#: mid-pursuit.
905+
PURSUE_CONTROL_BUDGET = 40
906+
907+
#: Roles that DO something, offered before the ones that merely navigate.
908+
_ACTIONABLE_ROLES = (
909+
"radio", "checkbox", "switch", "option", "select", "textarea",
910+
"text", "email", "password", "search", "number", "button", "submit",
911+
)
912+
913+
@classmethod
914+
def _controls_worth_offering(cls, elements: list[Any]) -> list[Any]:
915+
"""The controls that can advance a goal, before the ones that decorate.
916+
917+
Truncating the raw list would cut the answers and keep the navigation,
918+
because site furniture is emitted first in document order. Ranking by
919+
what a control DOES keeps the form and drops the chrome.
920+
"""
921+
ranked = sorted(
922+
enumerate(elements),
923+
key=lambda pair: (
924+
cls._ACTIONABLE_ROLES.index(str(pair[1].get("role") or "").lower())
925+
if str(pair[1].get("role") or "").lower() in cls._ACTIONABLE_ROLES
926+
else len(cls._ACTIONABLE_ROLES),
927+
pair[0],
928+
),
929+
)
930+
return [element for _index, element in ranked[: cls.PURSUE_CONTROL_BUDGET]]
931+
900932
@staticmethod
901933
def _render_observation(observation: Mapping[str, Any]) -> str:
902934
"""The page as the decision sees it: what it says, and what it offers."""
903-
elements = observation.get("elements") or []
935+
elements = SovereignBrowserSkill._controls_worth_offering(
936+
list(observation.get("elements") or [])
937+
)
904938
lines = [
905939
f"URL: {observation.get('url')}",
906940
f"Title: {observation.get('title')}",
@@ -1539,7 +1573,12 @@ async def _handle_pursue(
15391573
steps.append({"why": str(decision.get("why") or ""), "done": True})
15401574
break
15411575

1542-
elements = observation.get("elements") or []
1576+
# The same list she was shown, in the same order. Rendering a
1577+
# ranked subset and resolving against the raw list would mean
1578+
# index 3 named one control on screen and a different one in the
1579+
# click — the precise way these loops end up pressing whatever
1580+
# moved into slot four.
1581+
elements = self._controls_worth_offering(list(observation.get("elements") or []))
15431582
planned: list[BrowserAction] = []
15441583
for item in decision.get("actions") or []:
15451584
if not isinstance(item, dict):

tests/test_browser_pursue_is_a_closed_loop.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,3 +482,43 @@ async def test_done_after_work_is_accepted(self):
482482
3,
483483
)
484484
assert result["completed"] is True
485+
486+
487+
class TestTheDecisionPromptStaysAffordable:
488+
"""3,183-token prompts, 2,471 re-prefilled per round, cancelled at 181s.
489+
490+
A live questionnaire renders 83 controls and most are site furniture — nav,
491+
language, login, footer — emitted first in document order. Truncating the
492+
raw list would therefore cut the answers and keep the chrome, so the ranking
493+
is by what a control DOES.
494+
"""
495+
496+
def _crowded_page(self):
497+
return {
498+
"url": "https://example.com/q",
499+
"title": "t",
500+
"text": "Question 1 of 60",
501+
"elements": [{"role": "link", "name": f"nav{i}", "selector": f"#n{i}"} for i in range(30)]
502+
+ [{"role": "radio", "name": f"answer{i}", "selector": f"#a{i}"} for i in range(14)]
503+
+ [{"role": "button", "name": "Next", "selector": "#next"}],
504+
}
505+
506+
def test_the_form_survives_and_the_chrome_does_not(self):
507+
offered = SovereignBrowserSkill._controls_worth_offering(self._crowded_page()["elements"])
508+
assert len(offered) <= SovereignBrowserSkill.PURSUE_CONTROL_BUDGET
509+
assert sum(1 for e in offered if e["role"] == "radio") == 14
510+
assert any(e["name"] == "Next" for e in offered)
511+
512+
def test_what_she_is_shown_is_what_the_indices_mean(self):
513+
"""Rendering a subset and resolving against the raw list clicks the wrong thing."""
514+
page = self._crowded_page()
515+
rendered = SovereignBrowserSkill._render_observation(page)
516+
offered = SovereignBrowserSkill._controls_worth_offering(page["elements"])
517+
assert f"[0] {offered[0]['role']}" in rendered
518+
assert offered[0]["name"] in rendered
519+
520+
def test_the_ranking_is_stable(self):
521+
page = self._crowded_page()
522+
assert [e["selector"] for e in SovereignBrowserSkill._controls_worth_offering(page["elements"])] == [
523+
e["selector"] for e in SovereignBrowserSkill._controls_worth_offering(page["elements"])
524+
]

0 commit comments

Comments
 (0)