diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index d9811fb2..0ca29956 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -238,6 +238,32 @@ async def get_browser_agent_children(session_id: str): children = agent_manager.get_browser_agent_children(session_id) return {"sessions": children} +@agents.router.get("/browser-memory") +async def list_browser_memory(): + """Everything the browser agent has learned, per site, so the user can see it + and clear it: tier-1 skills (replayable shortcuts) + tier-2 playbook (strategy + text). Read-only; pure introspection.""" + from backend.apps.agents.browser import browser_playbook, browser_skills + sites: dict[str, dict] = {} + for entry in browser_playbook.list_hosts(): + sites.setdefault(entry["host"], {"host": entry["host"], "skills": [], "strategy": []}) + sites[entry["host"]]["strategy"] = entry["bullets"] + sites[entry["host"]]["updated_at"] = entry.get("updated_at", 0) + for host in list(sites.keys()): + sites[host]["skills"] = browser_skills.list_skills(host) + return {"sites": sorted(sites.values(), key=lambda s: -s.get("updated_at", 0))} + + +@agents.router.delete("/browser-memory/{host}") +async def forget_browser_memory(host: str): + """Clear what the browser agent learned about one site (strategy + skills); it + re-learns on the next successful run.""" + from backend.apps.agents.browser import browser_playbook, browser_skills + forgot_strategy = browser_playbook.forget(host) + forgot_skills = browser_skills.forget_host(host) + return {"ok": True, "host": host, "forgot_strategy": forgot_strategy, "forgot_skills": forgot_skills} + + @agents.router.post("/sessions/{session_id}/resume") async def resume_session(session_id: str): try: diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index df32445d..dc37316a 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -773,6 +773,8 @@ async def run_browser_agent( cur_host = browser_skills.host_of(last_seen_url) or replay_host if tu.name == "BrowserListSkills": skills = browser_skills.list_skills(cur_host) if cur_host else [] + playbook = browser_playbook.get_playbook(cur_host) if cur_host else [] + parts = [] if skills: _tag = {"trusted": "proven", "probation": "unproven", "quarantine": "disabled"} def _fmt_skill(s): @@ -780,10 +782,10 @@ async def run_browser_agent( if s.get("builds_on"): line += f", builds on {len(s['builds_on'])} other shortcut(s)" return line + ")" - lines = "\n".join(_fmt_skill(s) for s in skills[:20]) - meta_text = f"Learned shortcuts for {cur_host}:\n{lines}" - else: - meta_text = f"No learned shortcuts for {cur_host or 'this site'} yet." + parts.append(f"Learned shortcuts for {cur_host}:\n" + "\n".join(_fmt_skill(s) for s in skills[:20])) + if playbook: + parts.append(f"Strategy I've learned about {cur_host}:\n" + "\n".join(f"- {b}" for b in playbook)) + meta_text = "\n\n".join(parts) if parts else f"Nothing learned for {cur_host or 'this site'} yet." else: target = tu.input.get("task", "") ok = browser_skills.deprecate_skill(cur_host, target) if cur_host else False diff --git a/backend/apps/agents/browser/browser_skills.py b/backend/apps/agents/browser/browser_skills.py index bda2902e..a3cc7707 100644 --- a/backend/apps/agents/browser/browser_skills.py +++ b/backend/apps/agents/browser/browser_skills.py @@ -691,6 +691,28 @@ def deprecate_skill(host: str, task: str) -> bool: return removed +def forget_host(host: str) -> int: + """Remove ALL learned skills for a host (memory + disk). For the user-facing + 'forget this site' control. Returns how many were removed.""" + if not host: + return 0 + n = 0 + for sig in list(_host_skills(host).keys()): + removed = _skills.pop(_key(host, sig), None) is not None + path = _skill_path(host, sig) + if path and os.path.exists(path): + try: + os.remove(path) + removed = True + except Exception: + pass + if removed: + n += 1 + if n: + logger.info(f"[browser-skills] forgot all {n} skill(s) for {host}") + return n + + def clear(wipe_disk: bool = False) -> None: """Clear the in-memory cache. With wipe_disk, also remove persisted files in the current skills dir (used by tests for isolation).""" diff --git a/backend/tests/test_browser_memory_endpoints.py b/backend/tests/test_browser_memory_endpoints.py new file mode 100644 index 00000000..20467263 --- /dev/null +++ b/backend/tests/test_browser_memory_endpoints.py @@ -0,0 +1,71 @@ +"""The browser-memory UX surface: list + forget endpoints. + +Calls the real route handlers directly (thin wrappers over the already-tested +skill + playbook stores) so the user-facing 'see what it learned / forget it' +controls are proven, including that forget actually clears both tiers. +""" + +import asyncio +import json + +from backend.apps.agents import agents as agents_mod +from backend.apps.agents.browser import browser_playbook as pb +from backend.apps.agents.browser import browser_skills as sk + + +def _seed_skill(host, task): + sk.record_skill(host, task, [ + {"tool": "BrowserClickIndex", "input": {}, "ok": True, + "clicked_role": "button", "clicked_name": "Go"}, + ]) + + +async def _seed_strategy(host, *bullets): + class _Blk: + def __init__(self, t): self.text = t + + class _Resp: + def __init__(self, t): self.content = [_Blk(t)] + + class _Aux: + def __init__(self): self.messages = self + async def create(self, **kw): + return _Resp(json.dumps({"playbook": list(bullets)})) + await pb.distill_and_store(host, "t", "m", "s", _Aux(), "aux") + + +def test_list_browser_memory_groups_skills_and_strategy_by_site(): + sk.clear(); pb.clear(wipe_disk=True) + _seed_skill("shop.com", "search now") + asyncio.run(_seed_strategy("shop.com", "use the search box at the top")) + asyncio.run(_seed_strategy("docs.com", "share lives behind the blue button")) + + out = asyncio.run(agents_mod.list_browser_memory()) + sites = {s["host"]: s for s in out["sites"]} + assert set(sites) == {"shop.com", "docs.com"} + assert sites["shop.com"]["strategy"] == ["use the search box at the top"] + assert len(sites["shop.com"]["skills"]) == 1 + assert sites["docs.com"]["strategy"] == ["share lives behind the blue button"] + + +def test_forget_clears_both_tiers_for_a_site(): + sk.clear(); pb.clear(wipe_disk=True) + _seed_skill("gone.com", "do it now") + asyncio.run(_seed_strategy("gone.com", "a strategy bullet")) + # sanity: present + assert pb.get_playbook("gone.com") and sk.list_skills("gone.com") + + res = asyncio.run(agents_mod.forget_browser_memory("gone.com")) + assert res["ok"] and res["forgot_strategy"] is True and res["forgot_skills"] >= 1 + # both tiers actually cleared + assert pb.get_playbook("gone.com") == [] + assert sk.list_skills("gone.com") == [] + # the site no longer appears in the listing + out = asyncio.run(agents_mod.list_browser_memory()) + assert "gone.com" not in {s["host"] for s in out["sites"]} + + +def test_forget_unknown_site_is_harmless(): + sk.clear(); pb.clear(wipe_disk=True) + res = asyncio.run(agents_mod.forget_browser_memory("never.com")) + assert res["ok"] and res["forgot_strategy"] is False and res["forgot_skills"] == 0