[eric] browser: prompt-cache the fixed prefix and push batching to cut first-run turns

This commit is contained in:
ciregenz
2026-06-02 14:01:00 -07:00
parent 014414440b
commit a015036383
3 changed files with 68 additions and 6 deletions
+35 -4
View File
@@ -322,6 +322,17 @@ async def run_browser_agent(
+ prior_note
)
# Prompt-caching shapes built once: system as a single cached text block,
# and the last tool carrying the cache_control marker (Anthropic keys on the
# trailing marker, so one marker covers the whole tool array + system).
_cached_system = [{
"type": "text", "text": run_system_prompt,
"cache_control": {"type": "ephemeral"},
}]
_cached_tools = [dict(t) for t in BROWSER_TOOLS_SCHEMA]
if _cached_tools:
_cached_tools[-1] = {**_cached_tools[-1], "cache_control": {"type": "ephemeral"}}
user_msg = Message(role="user", content=task)
session.messages.append(user_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
@@ -429,8 +440,13 @@ async def run_browser_agent(
response = await _cancellable(client.messages.create(
model=api_model,
max_tokens=4096,
system=run_system_prompt,
tools=BROWSER_TOOLS_SCHEMA,
# Cache the ~4k-token fixed prefix (system + tool schema) so it's
# reprocessed once, not on every turn: big TTFT + cost win on the
# first run, which is dominated by turns x per-turn prefill. The
# trailing cache_control marker is what Anthropic keys on; on
# non-Anthropic routes (9router) the marker is harmlessly ignored.
system=_cached_system,
tools=_cached_tools,
messages=messages,
))
if response is None:
@@ -445,6 +461,13 @@ async def run_browser_agent(
if hasattr(response, 'usage') and response.usage:
session.tokens["input"] = session.tokens.get("input", 0) + (response.usage.input_tokens or 0)
session.tokens["output"] = session.tokens.get("output", 0) + (response.usage.output_tokens or 0)
# Cache-read tokens prove the prompt cache is working (climbs
# after turn 1). Logged so the speed win is verifiable, not assumed.
_cr = getattr(response.usage, "cache_read_input_tokens", 0) or 0
_cw = getattr(response.usage, "cache_creation_input_tokens", 0) or 0
if _cr or _cw:
session.tokens["cache_read"] = session.tokens.get("cache_read", 0) + _cr
logger.info(f"[browser-perf] turn {turn}: cache_read={_cr} cache_write={_cw} input={response.usage.input_tokens}")
assistant_content = []
text_parts = []
@@ -881,10 +904,18 @@ async def run_browser_agent(
# the next identical task on this host runs via the no-LLM fast path.
try:
rec_host = browser_skills.host_of(last_seen_url)
_distilled = browser_skills.distill_steps(action_log)
logger.info(
f"[browser-skills] record attempt: host={rec_host!r} "
f"last_url={last_seen_url!r} action_tools={[a.get('tool') for a in action_log]} "
f"distilled={[s['tool'] for s in _distilled]}"
)
if browser_skills.record_skill(rec_host, task, action_log):
logger.info(f"[browser-skills] learned skill for {rec_host} (future runs replay fast)")
except Exception:
pass
else:
logger.info(f"[browser-skills] NOT recorded (host empty or no robust steps)")
except Exception as e:
logger.warning(f"[browser-skills] record raised: {e}")
agent_manager._sync_session_close(session)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
@@ -438,6 +438,14 @@ SYSTEM_PROMPT = (
"5. **Coordinate-based fallback**; last resort: take a screenshot, identify the "
"button visually, then click by approximate coords.\n\n"
"## Speed: minimize round-trips (this is the #1 driver of how fast you are)\n"
"Every turn is a slow model round-trip; tools themselves are fast. So the way "
"to be fast is FEWER TURNS, not faster tools. Once you can see the page, plan "
"the whole remaining sequence and emit it in ONE BrowserBatch instead of one "
"action per turn. A 3-step form (type, type, click Send) should be a single "
"batch turn, not three. Only break the batch when a later step genuinely "
"depends on reading what an earlier step produced.\n\n"
"## Batch known sequences with BrowserBatch\n"
"When you have a known sequence of actions; typing then pressing Enter, "
"swiping multiple times, clicking through pagination; emit them all in a "
+25 -2
View File
@@ -268,6 +268,24 @@ def test_replay_falls_back_to_full_agent_when_a_step_fails(monkeypatch):
assert len(primary.calls) > 0, "fell back to the full LLM agent"
def test_prompt_caching_markers_present(monkeypatch):
# The fixed system+tools prefix must carry cache_control so it's cached
# across turns (the first-run speed/cost win). Without the marker the
# ~4k-token prefix is reprocessed every turn.
BH._browser_history.clear(); BH._domain_notes.clear()
primary = FakeLLM([Resp([Blk("text", "done")], stop_reason="end_turn")])
aux = FakeAux()
_install(monkeypatch, primary, aux)
asyncio.run(BA.run_browser_agent(task="hi", browser_id="bz", model="sonnet"))
call = primary.calls[0]
sys = call["system"]
assert isinstance(sys, list) and sys[-1]["cache_control"]["type"] == "ephemeral"
tools = call["tools"]
assert tools[-1].get("cache_control", {}).get("type") == "ephemeral"
# exactly one cache marker on the tools array (Anthropic allows <=4; we use 1)
assert sum(1 for t in tools if t.get("cache_control")) == 1
def test_prior_domain_hint_is_seeded_into_system_prompt(monkeypatch):
BH._browser_history.clear(); BH._domain_notes.clear()
BH.set_domain_note("google.com", "REMEMBERED: Share button is index 43; Tab into the dialog.")
@@ -279,7 +297,12 @@ def test_prior_domain_hint_is_seeded_into_system_prompt(monkeypatch):
task="open the doc", browser_id="b2", model="sonnet", initial_url=DOC_URL,
))
assert primary.calls, "LLM should have been called"
# system is a cached content-block list (prompt caching); flatten its text
system = primary.calls[0]["system"]
assert "Notes from a previous visit" in system
assert "REMEMBERED: Share button is index 43" in system
system_text = system if isinstance(system, str) else " ".join(b.get("text", "") for b in system)
assert "Notes from a previous visit" in system_text
assert "REMEMBERED: Share button is index 43" in system_text
# the cached system block carries the cache_control marker
if isinstance(system, list):
assert system[-1].get("cache_control", {}).get("type") == "ephemeral"
assert len(aux.calls) == 0 # no exhaustion, no adjudication on a clean run