From 3a601ba3ad6a4ba2ff7392ad18b33acea8a6a196 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 2 Jun 2026 23:46:49 -0700 Subject: [PATCH] [eric] browser: calm zero-click signals so the user feels the agent recall + learn site memory --- backend/apps/agents/browser/browser_agent.py | 23 ++++++++- backend/tests/test_browser_agent_loop.py | 50 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index ac7539e6..2b791a97 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -401,6 +401,17 @@ async def run_browser_agent( "message": user_msg.model_dump(mode="json"), }) + # Perceived value, zero clicks: one calm line so the user FEELS the agent is + # picking up where it left off, not figuring the site out cold again. Only + # when strategy was actually seeded, so it's honest, never noise. + if pb_seeded and _pb_host: + _recall_msg = Message(role="assistant", + content=f"Picking up what I learned about {_pb_host} from a previous visit.") + session.messages.append(_recall_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": _recall_msg.model_dump(mode="json"), + }) + async def _cancellable(coro): """Race any awaitable against the cancel event. Returns None if cancelled.""" task = asyncio.ensure_future(coro) @@ -1131,10 +1142,20 @@ async def run_browser_agent( pb_host = browser_skills.host_of(last_seen_url) if pb_host: aux_client, aux_model = await _get_aux_client() - await browser_playbook.distill_and_store( + changed = await browser_playbook.distill_and_store( pb_host, skill_key_task, latest_working_mem, summary, aux_client, aux_model, ) + # Perceived value, zero clicks: a calm closing line so the user + # sees the agent got a little smarter for next time. Only when + # it genuinely learned something, so it stays honest + rare. + if changed: + _learn_msg = Message(role="assistant", + content=f"Noted what worked on {pb_host} so I'm faster here next time.") + session.messages.append(_learn_msg) + await ws_manager.send_to_session(session_id, "agent:message", { + "session_id": session_id, "message": _learn_msg.model_dump(mode="json"), + }) except Exception as e: logger.debug(f"[browser-playbook] distill skipped: {e}") agent_manager._sync_session_close(session) diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 42519819..97b3e07b 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -726,6 +726,56 @@ def test_playbook_distills_on_success_survives_restart_and_seeds_next_run(monkey assert "Vercel/Linear + React" in system_text +def test_ambient_memory_signals_fire_calmly(monkeypatch): + # Perceived value, zero clicks: the user should SEE the agent (a) pick up what + # it learned when strategy is seeded, and (b) note new learning at the end, + # both as calm one-liners in the existing stream, only when real. + import backend.apps.agents.browser.browser_playbook as PB + import backend.apps.agents.browser.browser_skills as SK + import json as _json + SK.clear(); PB.clear(wipe_disk=True) + BH._browser_history.clear() + + class PBAux: + def __init__(self): self.messages = self + async def create(self, **kw): + return Resp([Blk("text", _json.dumps({"playbook": ["search company+React, not generic"]}))], + stop_reason="end_turn") + msgs = [] + orig = BA.ws_manager.send_to_session + + async def _cap(session_id, event, payload): + if event == "agent:message": + c = payload.get("message", {}).get("content") + msgs.append(c if isinstance(c, str) else (c or {}).get("text", "")) + return await orig(session_id, event, payload) + monkeypatch.setattr(BA.ws_manager, "send_to_session", _cap, raising=False) + + def _run(): + return FakeLLM([ + Resp([_rp("orient"), _tu("BrowserListInteractives")]), + Resp([_rp("go"), _tu("BrowserNavigate", url=DOC_URL)]), + Resp([_rp("read"), _tu("BrowserGetText")]), + Resp([_rp("act"), _tu("BrowserClickIndex", index=1)]), + Resp([Blk("text", "Done, found them.")], stop_reason="end_turn"), + ]) + + # Run 1: nothing learned yet -> NO recall line, but it learns -> closing line. + _install(monkeypatch, _run(), PBAux()) + monkeypatch.setattr(BA.ws_manager, "send_to_session", _cap, raising=False) + asyncio.run(BA.run_browser_agent(task="find engineers", browser_id="b1", model="sonnet", initial_url=DOC_URL)) + joined1 = " ".join(msgs) + assert "Picking up what I learned" not in joined1, "no recall on the first-ever visit" + assert "so I'm faster here next time" in joined1, "closing 'learned' line after first success" + + # Run 2: now there's a playbook -> recall line fires. + msgs.clear() + _install(monkeypatch, _run(), PBAux()) + monkeypatch.setattr(BA.ws_manager, "send_to_session", _cap, raising=False) + asyncio.run(BA.run_browser_agent(task="find more", browser_id="b2", model="sonnet", initial_url=DOC_URL)) + assert any("Picking up what I learned about docs.google.com" in m for m in msgs), "recall line on a return visit" + + def test_playbook_not_learned_from_a_ghost_completion(monkeypatch): # Fail-safe: a dishonest 'completion' (all actions errored) must NOT distill a # playbook, garbage strategy from a failed run would mislead future runs.