mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
182 lines
7.5 KiB
Python
182 lines
7.5 KiB
Python
"""
|
|
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
|