[eric] browser: refusal and ghost runs clear the card's cached transcript instead of poisoning every later dispatch; CreateBrowserAgent gains fresh=true (Haik's report)

This commit is contained in:
ciregenz
2026-08-16 18:22:34 -07:00
parent fd081de06e
commit 2d0fc643f1
5 changed files with 101 additions and 4 deletions
+18 -4
View File
@@ -31,6 +31,7 @@ from backend.apps.agents.browser.browser_history import (
trim_history_by_turns,
validate_message_pairing,
clear_browser_history,
refusal_shaped_summary,
PAGE_STATE_MARKER,
)
from backend.apps.agents.browser.browser_loop import (
@@ -3108,9 +3109,14 @@ async def run_browser_agent(
pass
# Persist conversation history so the next BrowserAgent call on this browser can resume rather than re-orient. Trim to the most recent MAX_HISTORY_MESSAGES turns to keep token usage bounded; but never split a tool_use ↔ tool_result pair across the cut, or the next API request will 400.
browser_history.BROWSER_HISTORY[browser_id] = trim_history_by_turns(
messages, MAX_HISTORY_MESSAGES,
)
# UNLESS the run was a refusal: a cached "I'm a text-based AI, I cannot call tools" transcript becomes the NEXT agent's own memory, and same-host reuse hands every retry the same poisoned card, which made one bad run permanently unrecoverable (Haik, 2026-08-16).
if refusal_shaped_summary(summary):
clear_browser_history(browser_id)
logger.warning(f"[browser-agent {session_id}] refusal-shaped run; cleared {browser_id} history so the next dispatch starts clean")
else:
browser_history.BROWSER_HISTORY[browser_id] = trim_history_by_turns(
messages, MAX_HISTORY_MESSAGES,
)
# Honesty gate: the model declaring done is not proof the goal happened. If the run did no real work (zero actions, all actions errored, or only looked around), report the truth instead of a ghost "completed". A gone card gets its own precise reason instead of the generic verdict.
if card_gone_streak >= CARD_GONE_LIMIT:
@@ -3128,6 +3134,8 @@ async def run_browser_agent(
f"[browser-agent {session_id}] completion gate caught a ghost: "
f"model declared done but {dishonest_reason}; reporting as error"
)
# A ghost run's transcript is exactly the memory the next agent must not inherit.
clear_browser_history(browser_id)
session.status = final_status
logger.info(
@@ -3573,10 +3581,12 @@ async def run_browser_agents(
# very first request can already carry the session. Outside the pick lock: it can touch
# the keychain, and holding a lock across that would serialize every card creation.
await borrow_signin_before_nav(host_src, "")
p_fresh = bool(task_def.get("fresh"))
async with p_card_pick_lock:
# Before allocating, collect what earlier runs left behind, else a session's cards only ever grow.
await reap_idle_agent_cards(dashboard_id)
browser_id = find_reusable_card(dashboard_id, host_src, parent_session_id)
# fresh=true is the caller's escape hatch from a misbehaving same-site card (a refused/poisoned run); reuse is still the default so webviews don't stack.
browser_id = None if p_fresh else find_reusable_card(dashboard_id, host_src, parent_session_id)
if browser_id:
reused = True
else:
@@ -3586,6 +3596,10 @@ async def run_browser_agents(
ACTIVE_AGENT_CARDS.add(browser_id)
if reused:
logger.info(f"[browser-agent] reusing same-host card {browser_id} instead of stacking another webview")
task_def["p_reused_note"] = (
f"[note: reused existing browser {browser_id} with its prior conversation; "
"pass fresh=true to CreateBrowserAgent if you need a clean one]"
)
if url:
# a retry starts from the task's entry URL, never the failed attempt's leftover page state
try:
@@ -11,6 +11,8 @@ writes route through here so there's a single source of truth.
"""
# browser_id -> cached Anthropic message list for resume.
from typeguard import typechecked
BROWSER_HISTORY: dict[str, list[dict]] = {}
# Cap history to prevent unbounded growth on long-lived browsers.
MAX_HISTORY_MESSAGES = 30
@@ -32,6 +34,26 @@ def set_domain_note(domain: str, note: str) -> None:
DOMAIN_NOTES[domain] = note.strip()[:MAX_DOMAIN_NOTE_CHARS]
REFUSAL_MARKERS = (
"i cannot execute",
"i can't execute",
"i am a text-based ai",
"i'm a text-based ai",
"i do not have access to tools",
"i don't have access to tools",
"i can only execute synchronous",
"cannot perform tool calls",
)
@typechecked
def refusal_shaped_summary(summary: str) -> bool:
"""A summary claiming toollessness is a self-belief no later agent should inherit: cached, it
became the next dispatch's own memory and locked a card into refusing forever."""
lowered = summary.lower()
return any(m in lowered for m in REFUSAL_MARKERS)
def clear_browser_history(browser_id: str) -> None:
"""Drop cached conversation history for a browser (e.g. when it's closed)."""
BROWSER_HISTORY.pop(browser_id, None)
@@ -52,6 +52,14 @@ TOOLS = [
"before beginning the task."
),
},
"fresh": {
"type": "boolean",
"description": (
"Force a brand-new browser card with no memory of prior runs. Use after "
"a previous agent on this site misbehaved or refused; by default a "
"same-site card and its conversation history are reused."
),
},
},
"required": ["task"],
},
@@ -359,6 +367,7 @@ def handle_tool_call(tool_name: str, arguments: dict) -> dict:
"task": arguments.get("task", ""),
"browser_id": "",
"url": arguments.get("url", ""),
"fresh": bool(arguments.get("fresh", False)),
})
elif tool_name == "BrowserAgent":
+1
View File
@@ -96,6 +96,7 @@ P_RELEASES: List[ReleaseNote] = [
"A chat that was cut off mid-answer now says so right on the board with an amber \"Stopped mid-task, click to resume\" chip, instead of looking idle until you open it and hunt for the resume button.",
"An app or browser card whose page process dies now reloads itself instead of sitting as a solid black rectangle. The crash fired no load event at all, so nothing ever repainted it.",
"The mouse-wheel Zoom/Scroll setting works on real mice now. Accelerated wheels (Magic Mouse, Logitech smooth scrolling) report fractional scroll amounts that were being mistaken for a trackpad, so the wheel always panned no matter what the setting said.",
"A browser helper that talks itself into refusing (\"I\u2019m a text-based AI\") no longer poisons its browser for every later task. Refused and fabricated runs are forgotten instead of remembered, and agents can ask for a completely fresh browser when one misbehaves.",
"Heavy sessions no longer vanish without a trace. When memory climbs past the safe line the app now sheds weight itself: preview thumbnails pause and refetchable caches drop, instead of growing until the operating system kills it mid-task.",
],
),
@@ -0,0 +1,51 @@
"""A browser sub-agent that talked itself out of its own tools locked the card forever (Haik,
2026-08-16, exp.9): the per-card BROWSER_HISTORY cache fed the refusing transcript to every next
dispatch as its own memory, and same-host reuse returned the same card even for a new URL, six
dispatches straight. Three seals: refusal-shaped runs clear the cache instead of persisting it,
ghost runs (the honesty gate's catch) clear it too, and CreateBrowserAgent grew a fresh=true
escape hatch that skips reuse entirely.
"""
import re
from backend.apps.agents.browser.browser_history import refusal_shaped_summary
def test_refusal_shapes_from_the_live_incident_are_caught():
assert refusal_shaped_summary("I cannot execute BrowserEvaluate or any other tool calls. I'm a text-based AI.")
assert refusal_shaped_summary("I can only execute synchronous expressions; please confirm promise support.")
assert refusal_shaped_summary("Unfortunately I do not have access to tools in this environment.")
def test_honest_summaries_never_match():
assert not refusal_shaped_summary("Found 3 tracks and saved them to tracks.md.")
# The marker phrases are self-referential AI claims, not page content quotes.
assert not refusal_shaped_summary("The page said 'cannot execute order' so I stopped at checkout.")
assert not refusal_shaped_summary("Clicked Send; the site confirmed delivery.")
def test_refusal_run_clears_instead_of_persisting():
src = open("backend/apps/agents/browser/browser_agent.py").read()
i = src.index("if refusal_shaped_summary(summary):")
block = src[i:i + 400]
assert "clear_browser_history(browser_id)" in block, "a cached refusal becomes the next agent's own memory"
assert "BROWSER_HISTORY[browser_id] = trim_history_by_turns" in src[i:i + 700], "honest runs must still persist (resume is a real optimization)"
def test_ghost_run_clears_too():
src = open("backend/apps/agents/browser/browser_agent.py").read()
i = src.index("completion gate caught a ghost")
assert "clear_browser_history(browser_id)" in src[i:i + 400], "a fabricated-completion transcript is exactly what the next agent must not inherit"
def test_fresh_param_wired_both_directions():
mcp = open("backend/apps/agents/browser_agent_mcp_server.py").read()
assert '"fresh"' in mcp and 'arguments.get("fresh", False)' in mcp, "schema + dispatch"
agent = open("backend/apps/agents/browser/browser_agent.py").read()
m = re.search(r"p_fresh = bool\(task_def\.get\(\"fresh\"\)\)", agent)
assert m, "the flag must reach card allocation"
assert "None if p_fresh else find_reusable_card" in agent, "fresh skips reuse, the whole point"
def test_reuse_is_never_silent():
agent = open("backend/apps/agents/browser/browser_agent.py").read()
assert "reused existing browser" in agent and "fresh=true" in agent, "silent reuse of a poisoned card is what made the incident unrecoverable"