""" Self-audit loop for the browser agent's own LEARNING (browser memory tier 4). Tiers 1-3 make the agent better at the WEBSITE. This makes it better at LEARNING: it reads its own run metrics + skill lifecycle and flags where the learning machinery is misfiring, skills that re-learn forever but never replay (thrash), runs that stall (turns far above the site's norm), recurring tool errors, low route-hint adoption, then writes a PROPOSAL for a human to act on. SAFETY LINE (deliberate): this NEVER edits prompts, thresholds, or code. In an open-source local-agent product, an agent silently rewriting its own behavior is a line we don't cross. It only READS metrics and WRITES a human-readable report; a person decides what to change. Pure observation in, one proposal file out. """ import json import logging import os from collections import defaultdict logger = logging.getLogger(__name__) # Thresholds for flagging; conservative so the report stays signal, not noise. P_THRASH_MIN_RELEARNS = 3 # a skill re-versioned this many times with 0 replays = stuck P_STALL_TURN_FACTOR = 2.0 # a run 2x the host's median turns is a stall worth noting P_MIN_RUNS_FOR_MEDIAN = 4 # don't call a "norm" from too few runs P_ERROR_RATE_FLAG = 0.25 # >25% of a host's tool calls erroring = something systemic def p_read_jsonl(path: str, cap: int = 20000) -> list[dict]: out: list[dict] = [] if not path or not os.path.exists(path): return out try: with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: out.append(json.loads(line)) except Exception: continue if len(out) >= cap: break except Exception: pass return out def p_median(xs: list[float]) -> float: s = sorted(xs) n = len(s) if not n: return 0.0 return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2 def audit(metrics_dir: str) -> dict: """Read the metrics + skill events and return a structured findings dict. Pure read; safe to call anytime. The caller renders/persists it.""" tasks = p_read_jsonl(os.path.join(metrics_dir, "tasks.jsonl")) skill_events = p_read_jsonl(os.path.join(metrics_dir, "skill_events.jsonl")) findings: list[dict] = [] # 1) THRASH: a skill re-versioned (edit) or sent to quarantine many times but never PROMOTED, the kinds the skill layer actually records. It keeps re-learning and never earns trust = the recorded steps don't hold up at replay. churn: dict[tuple, int] = defaultdict(int) promotes: dict[tuple, int] = defaultdict(int) for e in skill_events: key = (e.get("host"), e.get("task_sig")) kind = e.get("kind") if kind in ("edit", "quarantine", "demote"): churn[key] += 1 elif kind == "promote": promotes[key] += 1 for key, n in churn.items(): if n >= P_THRASH_MIN_RELEARNS and promotes.get(key, 0) == 0: findings.append({ "kind": "thrash", "host": key[0], "detail": f"skill re-learned/quarantined {n}x but never promoted", "suggestion": "the recorded steps likely don't match at replay (brittle " "names or a late-rendering control); inspect the distill for " "this task or deprecate the skill so it stops churning.", }) # 2) STALL: runs far above the host's median turn count. by_host_turns: dict[str, list[int]] = defaultdict(list) for t in tasks: h = p_host_of_task(t) if t.get("turns"): by_host_turns[h].append(int(t["turns"])) for h, turns in by_host_turns.items(): if len(turns) < P_MIN_RUNS_FOR_MEDIAN: continue med = p_median([float(x) for x in turns]) stalls = [x for x in turns if med and x >= med * P_STALL_TURN_FACTOR] if stalls: findings.append({ "kind": "stall", "host": h, "detail": f"{len(stalls)} run(s) at >= {P_STALL_TURN_FACTOR}x the median " f"{med:.0f} turns (worst {max(stalls)})", "suggestion": "a few runs spike well above normal, likely a perception/verify " "loop or an env hang; check whether a prompt prior or mechanical " "hand-off would collapse the spike.", }) # 3) ERROR-HEAVY: a host whose tool calls error a lot (systemic, not one bad run). err = defaultdict(int) tot = defaultdict(int) for t in tasks: h = p_host_of_task(t) rc = t.get("recurring_errors") or {} tot[h] += int(t.get("tool_calls") or 0) if isinstance(rc, dict): err[h] += sum(int(v) for v in rc.values() if isinstance(v, (int, float))) for h in tot: if tot[h] >= 20 and err[h] / max(1, tot[h]) >= P_ERROR_RATE_FLAG: findings.append({ "kind": "error_rate", "host": h, "detail": f"{err[h]}/{tot[h]} tool calls recurring-errored " f"({100*err[h]/max(1,tot[h]):.0f}%)", "suggestion": "high error rate is usually a stale selector/index or a throttled " "session; consider a more robust locator or a backoff on this host.", }) return { "n_tasks": len(tasks), "n_skill_events": len(skill_events), "findings": findings, } def p_host_of_task(task: dict) -> str: # tasks.jsonl doesn't store host directly; task_sig is host-agnostic, so fall back to a coarse bucket. browser_id groups a card's runs well enough for norms. return task.get("browser_id") or task.get("task_sig") or "unknown" def render_report(result: dict) -> str: """A human-readable proposal. Read it, then YOU decide what (if anything) to change.""" lines = [ "# Browser self-audit (proposal only , nothing was changed)", "", f"Scanned {result.get('n_tasks', 0)} task runs and " f"{result.get('n_skill_events', 0)} skill events.", "", ] findings = result.get("findings") or [] if not findings: lines.append("No learning-machinery problems flagged. The agent is learning cleanly.") return "\n".join(lines) lines.append(f"## {len(findings)} thing(s) worth a human look") for i, f in enumerate(findings, 1): lines += [ "", f"### {i}. {f['kind'].upper()} , {f.get('host', '?')}", f"- What: {f['detail']}", f"- Proposed action: {f['suggestion']}", ] return "\n".join(lines) def run_and_write(metrics_dir: str | None = None) -> str | None: """Audit + write the proposal to metrics_dir/self_audit_report.md. Returns the path written, or None. Never raises into the caller.""" try: if metrics_dir is None: from backend.apps.agents.browser import browser_metrics metrics_dir = browser_metrics.metrics_dir() result = audit(metrics_dir) report = render_report(result) path = os.path.join(metrics_dir, "self_audit_report.md") with open(path, "w", encoding="utf-8") as f: f.write(report) logger.info(f"[browser-self-audit] wrote {len(result.get('findings', []))} finding(s) to {path}") return path except Exception as e: logger.debug(f"[browser-self-audit] failed: {e}") return None