[eric] browser: a card's cached transcript belongs to the chat that produced it, not the card (ENG-403)

This commit is contained in:
ciregenz
2026-08-26 13:56:21 -07:00
parent a340f1d67d
commit a84b1b5946
4 changed files with 114 additions and 5 deletions
+4 -4
View File
@@ -1165,7 +1165,7 @@ async def run_browser_agent(
preloaded_perception = ""
current_url = ""
preloaded_reads: list[dict] = [] # real front-loaded reads, seeded into action_log
p_resumed = bool(browser_history.BROWSER_HISTORY.get(browser_id))
p_resumed = bool(browser_history.resume_history(browser_id, parent_session_id))
# App mode skips this whole block: the app is already loaded and its DOM is uninformative (often a bare <canvas>), so the agent perceives via the bridge (AppDescribe) on turn 1 instead of navigating or front-loading the AX tree.
if not app_mode:
if initial_url:
@@ -1331,7 +1331,7 @@ async def run_browser_agent(
"(no url/elements, confirming probe empty); evicting + recovering early")
# Resume prior conversation on this browser if we have one cached. This lets the sub-agent skip the "take a screenshot to figure out where I am" cycle every time the parent issues a new task. Defensively validate the cache; if it's somehow corrupted (orphaned tool_use_ids), drop it and start fresh rather than crash on the next API call.
prior_messages = browser_history.BROWSER_HISTORY.get(browser_id) or []
prior_messages = browser_history.resume_history(browser_id, parent_session_id)
if prior_messages and not validate_message_pairing(prior_messages):
logger.warning(
f"[browser-agent {session_id}] cached history for {browser_id} has "
@@ -3249,8 +3249,8 @@ async def run_browser_agent(
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,
browser_history.remember_history(
browser_id, parent_session_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.
@@ -11,9 +11,17 @@ writes route through here so there's a single source of truth.
"""
# browser_id -> cached Anthropic message list for resume.
from typing import Dict, List, Optional
from typeguard import typechecked
BROWSER_HISTORY: dict[str, list[dict]] = {}
# Which CHAT produced each cached transcript. A card outlives the chat that opened it, and without
# this the next chat to use it inherits the previous chat's task as its own memory: a Spotify run
# whose reasoning insisted the job was "read everything under src/ and give observations", verbatim,
# turn after turn, while every tool call was a Spotify action (ENG-403). Stable across turns is the
# tell: a race drifts, a cache repeats.
HISTORY_OWNER: dict[str, str] = {}
# Cap history to prevent unbounded growth on long-lived browsers.
MAX_HISTORY_MESSAGES = 30
@@ -57,6 +65,33 @@ def refusal_shaped_summary(summary: str) -> bool:
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)
HISTORY_OWNER.pop(browser_id, None)
@typechecked
def resume_history(browser_id: str, owner_session_id: Optional[str]) -> List[Dict]:
"""The cached transcript, and ONLY if this chat is the one that produced it.
Resuming is what makes a follow-up cheap ("swipe left" should not re-orient from a screenshot),
so this keeps the win inside one chat and refuses it across chats. A card that changes hands
starts clean, which costs one screenshot and removes a whole class of phantom task.
"""
if not owner_session_id or HISTORY_OWNER.get(browser_id) != owner_session_id:
return []
return BROWSER_HISTORY.get(browser_id) or []
@typechecked
def remember_history(browser_id: str, owner_session_id: Optional[str], messages: List[Dict]) -> None:
"""Cache a transcript against the chat that produced it, or not at all.
An unowned entry is the bug: it is exactly what a later chat would read as its own past.
"""
if not owner_session_id:
clear_browser_history(browser_id)
return
BROWSER_HISTORY[browser_id] = messages
HISTORY_OWNER[browser_id] = owner_session_id
OMITTED_SCREENSHOT_STUB = "[earlier screenshot omitted to save context]"
@@ -0,0 +1,71 @@
"""A browser card's cached transcript belongs to the CHAT that produced it, never to the card alone.
ENG-403, production 1.7.9. A user asked for a Spotify playlist. The agent's private reasoning kept
insisting the task was "read everything under src/ (converters, server.js, logger, utils, doc.md)
and give observations" -- a code review nobody asked for -- verbatim, turn after turn, while every
tool call was a Spotify browser action. Haik on why it is severity 1:
"I only caught it because the phantom task and my real actions openly contradicted each other.
If they'd been even loosely compatible, I'd have quietly done the wrong thing and reported
success."
`BROWSER_HISTORY` was keyed by browser_id with no session identity at all, and a card outlives the
chat that opened it. "Stable across turns" was the tell: a race drifts, a cache repeats.
"""
import pytest
from backend.apps.agents.browser import browser_history as h
PHANTOM = [{"role": "user", "content": "read everything under src/ and give observations"}]
@pytest.fixture(autouse=True)
def p_clean_store():
h.BROWSER_HISTORY.clear()
h.HISTORY_OWNER.clear()
yield
h.BROWSER_HISTORY.clear()
h.HISTORY_OWNER.clear()
def test_a_chat_resumes_its_own_work_on_a_card():
# The whole point of the cache: a follow-up must not re-orient from a screenshot.
h.remember_history("card1", "chat-code-review", PHANTOM)
assert h.resume_history("card1", "chat-code-review") == PHANTOM
def test_the_next_chat_to_use_that_card_starts_clean():
h.remember_history("card1", "chat-code-review", PHANTOM)
assert h.resume_history("card1", "chat-spotify") == []
def test_a_transcript_with_no_owner_is_never_cached():
# An unowned entry is precisely what a later chat reads as its own past.
h.remember_history("card2", None, PHANTOM)
assert "card2" not in h.BROWSER_HISTORY
assert h.resume_history("card2", "chat-spotify") == []
def test_closing_a_card_forgets_who_owned_it():
h.remember_history("card1", "chat-a", PHANTOM)
h.clear_browser_history("card1")
assert "card1" not in h.HISTORY_OWNER, "a stale owner would let a recycled id resume"
assert h.resume_history("card1", "chat-a") == []
def test_nothing_reaches_into_the_store_behind_the_accessors():
# The module's own docstring already claimed this ("all reads and writes route through here");
# browser_agent.py reached into the dict directly, which is how the key stayed card-only.
src = open("backend/apps/agents/browser/browser_agent.py").read()
assert "BROWSER_HISTORY" not in src
assert "resume_history(browser_id, parent_session_id)" in src
assert "remember_history(" in src
def test_the_owner_is_the_CHAT_not_the_sub_agent():
# A browser sub-agent session is created per dispatch, so keying on it would make resume never
# fire: the feature would be silently dead rather than merely scoped.
src = open("backend/apps/agents/browser/browser_agent.py").read()
i = src.index("prior_messages = browser_history.resume_history(")
assert "parent_session_id" in src[i:i + 120]
+4 -1
View File
@@ -28,7 +28,10 @@ def test_refusal_run_clears_instead_of_persisting():
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)"
# The write moved behind an accessor that stamps the owning chat (ENG-403); persisting at all
# is still the point, because resume is a real optimization.
assert "remember_history(" in src[i:i + 700], "honest runs must still persist"
assert "parent_session_id" in src[i:i + 700], "and must record which chat produced it"
def test_ghost_run_clears_too():