mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""
|
|
Durable, reflective per-site strategy playbook (browser memory tier 2).
|
|
|
|
Tier 1 (`browser_skills`) REPLAYS mechanical action sequences with zero LLM, the
|
|
instant path for deterministic repeats. This tier remembers STRATEGY as text:
|
|
"on linkedin, generic 'design engineer' = hardware engineers; search Vercel/
|
|
Linear+React instead; the add-a-role wall is fine, read the top card." That can't
|
|
be replayed (it's judgment), but seeding it into the next run's prompt lets the
|
|
model skip re-discovery, so judgment tasks (find/evaluate/rank) get FASTER and
|
|
WISER each time instead of starting cold every run.
|
|
|
|
Folded from the best OSS, read from source (not guessed):
|
|
- mem0 (`configs/prompts.py`): distill a run into atomic facts, then reconcile
|
|
against existing memory with ADD/UPDATE/DELETE/NONE (accumulate + dedup +
|
|
self-correct, never blind-overwrite, never unbounded). We collapse mem0's two
|
|
LLM calls into ONE cheap aux call for cost: hand it the existing bullets + the
|
|
run, get back the reconciled bullet list.
|
|
- Stagehand/Skyvern/Voyager: only learn from a VERIFIED success, self-heal.
|
|
|
|
Two properties we hold to:
|
|
1. FAIL-SAFE: the playbook is ADVISORY TEXT, seeded into the prompt and re-verified
|
|
by the agent, never auto-executed. A wrong bullet can only mislead (and gets
|
|
corrected on the next run's reconcile), never ghost-succeed like a bad skill.
|
|
2. CHEAP + PROVIDER-AGNOSTIC: one cheap-tier aux call, only on a substantive
|
|
verified success (rare relative to the work), via the caller's resolved aux
|
|
model. Reads are O(1) disk, no LLM, so seeding stays free.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
from backend.apps.agents.browser.seed_for import seed_for
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
P_PLAYBOOK_FORMAT_VERSION = 1
|
|
MAX_BULLETS = 8 # cap per site; reconcile keeps the most useful
|
|
P_MAX_BULLET_CHARS = 160
|
|
P_MAX_DISK_PLAYBOOKS = 500
|
|
P_MIN_TURNS_TO_LEARN = 4 # a 1-3 turn run taught nothing worth a durable bullet
|
|
|
|
# In-memory hot cache: host -> list[str] bullets.
|
|
CACHE: dict[str, list[str]] = {}
|
|
|
|
# Same sensitivity guard the skill layer uses: a strategy bullet must never carry a secret (email/token/etc.). We scrub bullets through this before persisting.
|
|
P_SECRET_RE = re.compile(
|
|
r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" # email
|
|
r"|\b(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ)[A-Za-z0-9._-]+" # token prefixes
|
|
r"|\b(?:\d[ -]?){13,19}\b" # card-ish
|
|
r"|\b\d{3}-\d{2}-\d{4}\b" # ssn
|
|
)
|
|
|
|
|
|
def host_of(url: str) -> str:
|
|
try:
|
|
return (urlparse(url).netloc or "").lower()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def p_has_secret(text: str) -> bool:
|
|
return bool(P_SECRET_RE.search(text or ""))
|
|
|
|
|
|
def clean_bullet(b: str) -> str | None:
|
|
b = re.sub(r"\s+", " ", str(b or "")).strip().lstrip("-*• ").strip()
|
|
if not b or p_has_secret(b):
|
|
return None
|
|
return b[:P_MAX_BULLET_CHARS]
|
|
|
|
|
|
# --- persistence (mirrors browser_skills, separate dir) -------------------
|
|
def p_dir() -> str | None:
|
|
base = os.environ.get("OPENSWARM_BROWSER_PLAYBOOK_DIR")
|
|
if not base:
|
|
try:
|
|
from backend.config.paths import DATA_ROOT
|
|
base = os.path.join(DATA_ROOT, "browser_playbook")
|
|
except Exception:
|
|
return None
|
|
try:
|
|
os.makedirs(base, mode=0o700, exist_ok=True)
|
|
except Exception:
|
|
return None
|
|
return base
|
|
|
|
|
|
def p_path(host: str) -> str | None:
|
|
import hashlib
|
|
d = p_dir()
|
|
if not d:
|
|
return None
|
|
h = hashlib.sha256(host.encode("utf-8")).hexdigest()[:32]
|
|
return os.path.join(d, f"{h}.json")
|
|
|
|
|
|
def persist(host: str, bullets: list[str]) -> None:
|
|
path = p_path(host)
|
|
if not path:
|
|
return
|
|
payload = {"version": P_PLAYBOOK_FORMAT_VERSION, "host": host,
|
|
"bullets": bullets, "updated_at": time.time()}
|
|
try:
|
|
d = os.path.dirname(path)
|
|
fd, tmp = tempfile.mkstemp(dir=d, suffix=".tmp")
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f)
|
|
os.replace(tmp, path) # atomic; a reader never sees a half-written file
|
|
p_evict_if_over_cap(d)
|
|
except Exception as e:
|
|
logger.debug(f"[browser-playbook] persist failed: {e}")
|
|
|
|
|
|
def p_evict_if_over_cap(d: str) -> None:
|
|
try:
|
|
files = [os.path.join(d, f) for f in os.listdir(d) if f.endswith(".json")]
|
|
if len(files) <= P_MAX_DISK_PLAYBOOKS:
|
|
return
|
|
files.sort(key=lambda p: os.path.getmtime(p))
|
|
for p in files[: len(files) - P_MAX_DISK_PLAYBOOKS]:
|
|
try:
|
|
os.remove(p)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def load(host: str) -> list[str]:
|
|
path = p_path(host)
|
|
if not path or not os.path.exists(path):
|
|
return []
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if data.get("version") != P_PLAYBOOK_FORMAT_VERSION:
|
|
return []
|
|
return [b for b in (data.get("bullets") or []) if isinstance(b, str)]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def get_playbook(host: str) -> list[str]:
|
|
"""Durable strategy bullets for a host (cheap, no LLM). For seeding + UX.
|
|
|
|
Falls back to a shipped seed when the user has no learned playbook yet, so a
|
|
fresh install isn't fully cold on a popular site's first task. The seed is
|
|
advisory like any bullet; the next verified run reconciles against it and
|
|
writes a learned playbook that supersedes it."""
|
|
if not host:
|
|
return []
|
|
if host in CACHE:
|
|
return CACHE[host]
|
|
bullets = load(host) or seed_for(host)
|
|
CACHE[host] = bullets
|
|
return bullets
|
|
|
|
|
|
def format_for_prompt(host: str) -> str:
|
|
"""The seed block injected into a fresh run's system prompt, or '' if none."""
|
|
bullets = get_playbook(host)
|
|
if not bullets:
|
|
return ""
|
|
lines = "\n".join(f"- {b}" for b in bullets[:MAX_BULLETS])
|
|
return (
|
|
f"\n\n## What you learned about {host} on past visits\n"
|
|
"Use these as a head start to skip re-discovery, but re-verify since the "
|
|
"site can change:\n" + lines
|
|
)
|
|
|
|
|
|
def p_store(host: str, bullets: list[str]) -> list[str]:
|
|
"""Clean, cap, persist, warm cache. Returns the stored list."""
|
|
cleaned: list[str] = []
|
|
seen = set()
|
|
for b in bullets:
|
|
cb = clean_bullet(b)
|
|
if cb and cb.lower() not in seen:
|
|
seen.add(cb.lower())
|
|
cleaned.append(cb)
|
|
if len(cleaned) >= MAX_BULLETS:
|
|
break
|
|
CACHE[host] = cleaned
|
|
persist(host, cleaned)
|
|
return cleaned
|
|
|
|
|
|
# --- the reflective distill+reconcile (mem0 pattern, one cheap call) ------
|
|
def should_learn(honest: bool, turns: int) -> bool:
|
|
"""Only learn from a verified, substantive success: a ghost teaches nothing,
|
|
and a 1-3 turn run has no durable site strategy worth a bullet."""
|
|
return bool(honest) and turns >= P_MIN_TURNS_TO_LEARN
|
|
|
|
|
|
def p_build_prompt(host: str, task: str, working_memory: str, summary: str,
|
|
existing: list[str]) -> str:
|
|
ex = "\n".join(f"{i+1}. {b}" for i, b in enumerate(existing)) or "(empty)"
|
|
return (
|
|
f"You maintain a short STRATEGY PLAYBOOK for using the website {host} "
|
|
"efficiently. A fresh agent reads it before each task to skip re-discovery.\n\n"
|
|
f"EXISTING PLAYBOOK for {host}:\n{ex}\n\n"
|
|
f"A task just SUCCEEDED on {host}. Here is what the agent did and learned:\n"
|
|
f"TASK: {task}\n"
|
|
f"AGENT NOTES: {working_memory[:1500]}\n"
|
|
f"RESULT: {summary[:800]}\n\n"
|
|
"Return JSON: {\"playbook\": [\"...\"], \"universal\": [\"...\"]}, where "
|
|
"`playbook` is the UPDATED per-site playbook and `universal` is the SUBSET of "
|
|
"lessons that are SITE-AGNOSTIC (true on ANY website, e.g. how composers/Send "
|
|
"buttons behave in general), so other sites can reuse them. `universal` may be "
|
|
"empty; never put site-specific URLs, selectors, or names in it. Rules:\n"
|
|
f"- At most {MAX_BULLETS} bullets, each under {P_MAX_BULLET_CHARS} chars, "
|
|
"atomic and REUSABLE for ANY task on this site.\n"
|
|
"- Keep only durable site strategy: which queries/filters/URLs work, what "
|
|
"to avoid, where things live, walls that are safe to ignore.\n"
|
|
"- ALWAYS keep interaction mechanics that cost the agent a retry, e.g. "
|
|
"'Enter inserts a newline in the message composer; click the Send button "
|
|
"instead' or 'the filter opens a dropdown, not a new page'.\n"
|
|
"- ALWAYS keep PERCEPTION TRAPS: something that LOOKED wrong or ambiguous "
|
|
"but was actually fine, so the next agent is not fooled into a detour, e.g. "
|
|
"'a 1:1 chat is titled \"<Other> and <You>\"; that IS the direct thread, do "
|
|
"not try to start a new one'.\n"
|
|
"- DROP anything specific to THIS task's data (names, the actual answer), "
|
|
"one-offs, secrets, or obvious facts.\n"
|
|
"- MERGE duplicates; if a new lesson CONTRADICTS an old bullet, keep the "
|
|
"corrected one and drop the stale one.\n"
|
|
"- If nothing durable was learned, return the existing playbook unchanged.\n"
|
|
"Return ONLY the JSON object, nothing else."
|
|
)
|
|
|
|
|
|
def p_parse(text: str) -> list[str] | None:
|
|
"""Pull the bullet list out of the aux reply. Tolerant of code fences/prose."""
|
|
if not text:
|
|
return None
|
|
m = re.search(r"\{.*\}", text, re.DOTALL)
|
|
if not m:
|
|
return None
|
|
try:
|
|
data = json.loads(m.group(0))
|
|
except Exception:
|
|
return None
|
|
pb = data.get("playbook")
|
|
if not isinstance(pb, list):
|
|
return None
|
|
return [str(x) for x in pb if isinstance(x, (str, int, float))]
|
|
|
|
|
|
def p_parse_universal(text: str) -> list[str]:
|
|
"""The site-agnostic subset the distill flagged, for the cross-site meta-playbook.
|
|
Tolerant: missing/garbled `universal` just yields nothing (the site distill still runs)."""
|
|
if not text:
|
|
return []
|
|
m = re.search(r"\{.*\}", text, re.DOTALL)
|
|
if not m:
|
|
return []
|
|
try:
|
|
data = json.loads(m.group(0))
|
|
except Exception:
|
|
return []
|
|
uni = data.get("universal")
|
|
if not isinstance(uni, list):
|
|
return []
|
|
return [str(x) for x in uni if isinstance(x, (str, int, float))]
|
|
|
|
|
|
async def distill_and_store(host, task, working_memory, summary,
|
|
aux_client, aux_model) -> bool:
|
|
"""One cheap aux call: distill this successful run + reconcile against the
|
|
existing playbook (mem0 ADD/UPDATE/DELETE/NONE, collapsed). Persists the
|
|
result. Best-effort: any failure leaves the prior playbook untouched and
|
|
never raises into the caller. Returns True if the playbook changed."""
|
|
try:
|
|
if not host or not aux_client or not aux_model:
|
|
return False
|
|
existing = get_playbook(host)
|
|
prompt = p_build_prompt(host, task or "", working_memory or "", summary or "", existing)
|
|
resp = await aux_client.messages.create(
|
|
model=aux_model, max_tokens=600,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
)
|
|
text = "".join(getattr(b, "text", "") for b in (resp.content or []))
|
|
new_bullets = p_parse(text)
|
|
if new_bullets is None:
|
|
return False
|
|
stored = p_store(host, new_bullets)
|
|
changed = stored != existing
|
|
# Fold any site-agnostic lessons into the cross-site meta-playbook (no extra LLM call, they rode along in this same reply). Best-effort, never fatal.
|
|
try:
|
|
from backend.apps.agents.browser import browser_meta_playbook
|
|
browser_meta_playbook.absorb(p_parse_universal(text))
|
|
except Exception:
|
|
pass
|
|
if changed:
|
|
logger.info(f"[browser-playbook] {host}: {len(stored)} strategy bullet(s) "
|
|
f"(was {len(existing)})")
|
|
return changed
|
|
except Exception as e:
|
|
logger.debug(f"[browser-playbook] distill failed: {e}")
|
|
return False
|
|
|
|
|
|
# --- UX / maintenance -----------------------------------------------------
|
|
def list_hosts() -> list[dict]:
|
|
"""Every site we have a playbook for, for a 'what has it learned' view."""
|
|
out = []
|
|
d = p_dir()
|
|
if d:
|
|
try:
|
|
for f in os.listdir(d):
|
|
if not f.endswith(".json"):
|
|
continue
|
|
try:
|
|
with open(os.path.join(d, f), encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
except Exception:
|
|
continue
|
|
if data.get("host") and data.get("bullets"):
|
|
out.append({"host": data["host"], "bullets": data["bullets"],
|
|
"updated_at": data.get("updated_at", 0)})
|
|
except Exception:
|
|
pass
|
|
return sorted(out, key=lambda x: -x["updated_at"])
|
|
|
|
|
|
def forget(host: str) -> bool:
|
|
"""User-facing: drop a site's learned strategy (it re-learns next success)."""
|
|
if not host:
|
|
return False
|
|
CACHE.pop(host, None)
|
|
path = p_path(host)
|
|
if path and os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
logger.info(f"[browser-playbook] forgot {host}")
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def clear(wipe_disk: bool = False) -> None:
|
|
"""Clear the in-memory cache (tests). With wipe_disk, also remove files."""
|
|
CACHE.clear()
|
|
if wipe_disk:
|
|
d = p_dir()
|
|
if d:
|
|
try:
|
|
for f in os.listdir(d):
|
|
if f.endswith(".json"):
|
|
os.remove(os.path.join(d, f))
|
|
except Exception:
|
|
pass
|