mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 02:37:45 +02:00
[eric] browser: leading-_ -> p_/P_/public for module functions+constants+state across browser subsystem; promote cross-file symbols public (detect_loop, BROWSER_HISTORY, TRUSTED, etc.), align analyze-browser-metrics.py + tests
This commit is contained in:
@@ -18,20 +18,20 @@ import anthropic
|
||||
|
||||
from backend.apps.agents.browser import browser_history
|
||||
from backend.apps.agents.browser.browser_history import (
|
||||
_MAX_HISTORY_MESSAGES,
|
||||
_trim_history_by_turns,
|
||||
_validate_message_pairing,
|
||||
MAX_HISTORY_MESSAGES,
|
||||
trim_history_by_turns,
|
||||
validate_message_pairing,
|
||||
clear_browser_history,
|
||||
PAGE_STATE_MARKER,
|
||||
)
|
||||
from backend.apps.agents.browser.browser_loop import (
|
||||
_LOOP_DETECTION_EXCLUDED_TOOLS,
|
||||
_LOOP_HARD_CAP,
|
||||
_LOOP_WARNING_TEXT,
|
||||
_LOOP_WINDOW_SIZE,
|
||||
_detect_loop,
|
||||
_hash_tool_call,
|
||||
_CARD_GONE_LIMIT,
|
||||
LOOP_DETECTION_EXCLUDED_TOOLS,
|
||||
LOOP_HARD_CAP,
|
||||
LOOP_WARNING_TEXT,
|
||||
LOOP_WINDOW_SIZE,
|
||||
detect_loop,
|
||||
hash_tool_call,
|
||||
CARD_GONE_LIMIT,
|
||||
advance_stagnation,
|
||||
card_is_unavailable,
|
||||
completion_is_honest,
|
||||
@@ -45,14 +45,14 @@ from backend.apps.agents.browser.browser_validator import adjudicate_stuck
|
||||
|
||||
# Single actions the model could have folded into one BrowserBatch turn;
|
||||
# reads, waits, and the batch tools themselves don't count toward the streak.
|
||||
_BATCHABLE_ACTION_TOOLS = {
|
||||
P_BATCHABLE_ACTION_TOOLS = {
|
||||
"BrowserNavigate", "BrowserClick", "BrowserClickIndex", "BrowserClickByName",
|
||||
"BrowserType", "BrowserPressKey", "BrowserScroll",
|
||||
}
|
||||
|
||||
# Injected when the spin backstop trips: one chance to land a real answer from
|
||||
# what's already gathered, instead of the loop cutting it off mid-thought.
|
||||
_WRAPUP_NUDGE = (
|
||||
P_WRAPUP_NUDGE = (
|
||||
"You've spent several turns looking without finishing. Wrap up NOW: call Done with the "
|
||||
"best answer you can give from what you've ALREADY gathered. For a find/list ask, put the "
|
||||
"actual items in the message and, if the site exposes fewer than asked, say so plainly and "
|
||||
@@ -69,7 +69,7 @@ from backend.apps.agents.browser import browser_skills
|
||||
from backend.apps.agents.browser import browser_wait
|
||||
from backend.apps.agents.browser import browser_schema
|
||||
from backend.apps.agents.browser.browser_schema import (
|
||||
_ACTION_TOOLS_REQUIRING_REPORT,
|
||||
ACTION_TOOLS_REQUIRING_REPORT,
|
||||
ACTION_MAP,
|
||||
BROWSER_TOOLS_SCHEMA,
|
||||
MAX_TURNS,
|
||||
@@ -84,7 +84,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Mutating actions that can carry an `expect` (the change they should cause) and be
|
||||
# confirmed after running. Reads/waits aren't here, there's nothing to confirm.
|
||||
_CONFIRM_TOOLS = {
|
||||
P_CONFIRM_TOOLS = {
|
||||
"BrowserClick", "BrowserClickIndex", "BrowserClickByName",
|
||||
"BrowserType", "BrowserNavigate", "BrowserPressKey", "BrowserBatch",
|
||||
}
|
||||
@@ -106,7 +106,7 @@ async def execute_browser_tool(
|
||||
return result
|
||||
|
||||
|
||||
def _extract_domain(url: str) -> str | None:
|
||||
def p_extract_domain(url: str) -> str | None:
|
||||
"""Extract the apex domain from a URL (acme-corp.notion.so → notion.so).
|
||||
Returns None for non-http URLs."""
|
||||
try:
|
||||
@@ -123,7 +123,7 @@ def _extract_domain(url: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _strip_lone_surrogates(s: str) -> str:
|
||||
def strip_lone_surrogates(s: str) -> str:
|
||||
# The JS/webview hands us page text as UTF-16, so an emoji can arrive as half
|
||||
# of its surrogate pair; Python carries the orphan but .encode('utf-8') later
|
||||
# (the SDK serializing the request to the LLM) detonates with "surrogates not
|
||||
@@ -131,10 +131,10 @@ def _strip_lone_surrogates(s: str) -> str:
|
||||
return re.sub(r"[\ud800-\udfff]", "�", s) if s else s
|
||||
|
||||
|
||||
def _format_tool_result(result: dict, tool_name: str) -> list[dict]:
|
||||
def format_tool_result(result: dict, tool_name: str) -> list[dict]:
|
||||
"""Convert a browser command result dict into Anthropic API content blocks."""
|
||||
if "error" in result:
|
||||
return [{"type": "text", "text": _strip_lone_surrogates(f"Error: {result['error']}")}]
|
||||
return [{"type": "text", "text": strip_lone_surrogates(f"Error: {result['error']}")}]
|
||||
|
||||
if tool_name == "BrowserScreenshot" and result.get("image"):
|
||||
blocks = [
|
||||
@@ -151,31 +151,31 @@ def _format_tool_result(result: dict, tool_name: str) -> list[dict]:
|
||||
return blocks
|
||||
|
||||
text = result.get("text", json.dumps(result))
|
||||
return [{"type": "text", "text": _strip_lone_surrogates(str(text))}]
|
||||
return [{"type": "text", "text": strip_lone_surrogates(str(text))}]
|
||||
|
||||
|
||||
# Mutating tools whose results get fresh page state attached (the browser-use
|
||||
# loop shape: act, settle, see), so acting and seeing are one turn, not two.
|
||||
_AUTO_STATE_TOOLS = {
|
||||
P_AUTO_STATE_TOOLS = {
|
||||
"BrowserNavigate", "BrowserClick", "BrowserClickIndex", "BrowserClickByName",
|
||||
"BrowserType", "BrowserPressKey", "BrowserScroll", "BrowserBatch",
|
||||
}
|
||||
_AUTO_STATE_MAX_LINES = 35
|
||||
_AUTO_SETTLE_CAPS_MS = {"BrowserNavigate": 2500, "BrowserBatch": 1500}
|
||||
P_AUTO_STATE_MAX_LINES = 35
|
||||
P_AUTO_SETTLE_CAPS_MS = {"BrowserNavigate": 2500, "BrowserBatch": 1500}
|
||||
|
||||
# URL shapes that mean "a list of candidates to pick from" (auto candidate scan)
|
||||
_RESULTS_URL_RE = re.compile(
|
||||
RESULTS_URL_RE = re.compile(
|
||||
r"[?&](q|query|keywords|search|search_query|find|term)=|/search\b|/results\b", re.I,
|
||||
)
|
||||
_AUTO_SCAN_MAX_PER_RUN = 2
|
||||
P_AUTO_SCAN_MAX_PER_RUN = 2
|
||||
|
||||
|
||||
def _batch_ends_with_read(tool_input: dict) -> bool:
|
||||
def p_batch_ends_with_read(tool_input: dict) -> bool:
|
||||
actions = (tool_input or {}).get("actions") or []
|
||||
return bool(actions) and (actions[-1] or {}).get("type") == "list_interactives"
|
||||
|
||||
|
||||
def _truncate_state(text: str, max_lines: int = _AUTO_STATE_MAX_LINES) -> str:
|
||||
def p_truncate_state(text: str, max_lines: int = P_AUTO_STATE_MAX_LINES) -> str:
|
||||
lines = str(text).splitlines()
|
||||
if len(lines) <= max_lines:
|
||||
return str(text)
|
||||
@@ -184,7 +184,7 @@ def _truncate_state(text: str, max_lines: int = _AUTO_STATE_MAX_LINES) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _delta_state(text: str, seen_lines: set[str]) -> str:
|
||||
def delta_state(text: str, seen_lines: set[str]) -> str:
|
||||
"""Shrink an attached element list to the rows that changed since the last
|
||||
attach; stable indices make a line's identity meaningful, so re-sending 30
|
||||
unchanged rows every action is pure token burn. Mutates `seen_lines` to the
|
||||
@@ -210,20 +210,20 @@ def _delta_state(text: str, seen_lines: set[str]) -> str:
|
||||
# A button row whose name is exactly a Send control (not "Send InMail credit" or
|
||||
# "Send a message to X"); used to hand the model the Send button after it types,
|
||||
# so it never burns turns hunting a button that's right there.
|
||||
_SEND_ROW_RE = re.compile(r'\[(\d+)\]\*?<\s*button\s+"([^"]*)"', re.I)
|
||||
P_SEND_ROW_RE = re.compile(r'\[(\d+)\]\*?<\s*button\s+"([^"]*)"', re.I)
|
||||
|
||||
|
||||
def _send_index_in_state(state_text: str):
|
||||
def send_index_in_state(state_text: str):
|
||||
"""(index, name) of a real Send button in an interactives list, or None.
|
||||
Strict exact match so it never grabs an upsell or a profile 'Send a message' link."""
|
||||
for line in (state_text or "").splitlines():
|
||||
m = _SEND_ROW_RE.search(line)
|
||||
m = P_SEND_ROW_RE.search(line)
|
||||
if m and m.group(2).strip().lower() in ("send", "send now", "send message"):
|
||||
return int(m.group(1)), m.group(2)
|
||||
return None
|
||||
|
||||
|
||||
def _is_composer_fill(tool_name: str, tool_input: dict) -> bool:
|
||||
def is_composer_fill(tool_name: str, tool_input: dict) -> bool:
|
||||
"""True if this action typed a message into a composer (the moment the Send
|
||||
button is about to matter). Covers the solo fill, BrowserType, and a batched
|
||||
fill, the three ways the model composes."""
|
||||
@@ -238,25 +238,25 @@ def _is_composer_fill(tool_name: str, tool_input: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _post_action_state(
|
||||
async def post_action_state(
|
||||
tool_name: str, tool_input: dict, result: dict,
|
||||
browser_id: str, tab_id: str, wait_exec, goal: str,
|
||||
seen_lines: set[str] | None = None,
|
||||
) -> str:
|
||||
"""Settle the page after a mutating action, then return a compact fresh
|
||||
interactives list to append to its result. Empty string = attach nothing."""
|
||||
if tool_name not in _AUTO_STATE_TOOLS or not isinstance(result, dict) or "error" in result:
|
||||
if tool_name not in P_AUTO_STATE_TOOLS or not isinstance(result, dict) or "error" in result:
|
||||
return ""
|
||||
if tool_name == "BrowserBatch" and _batch_ends_with_read(tool_input):
|
||||
if tool_name == "BrowserBatch" and p_batch_ends_with_read(tool_input):
|
||||
return ""
|
||||
# an `expect` confirm already ran its own smart_wait; don't settle twice
|
||||
if not str((tool_input or {}).get("expect") or "").strip():
|
||||
settle = await browser_wait.smart_wait(
|
||||
wait_exec, browser_id, tab_id, _AUTO_SETTLE_CAPS_MS.get(tool_name, 1200),
|
||||
wait_exec, browser_id, tab_id, P_AUTO_SETTLE_CAPS_MS.get(tool_name, 1200),
|
||||
)
|
||||
if settle.get("hung"):
|
||||
return ""
|
||||
_composer_fill = _is_composer_fill(tool_name, tool_input)
|
||||
_composer_fill = is_composer_fill(tool_name, tool_input)
|
||||
params = {"goal": goal} if goal else {}
|
||||
lst = None
|
||||
_send_si = None
|
||||
@@ -275,7 +275,7 @@ async def _post_action_state(
|
||||
break
|
||||
if isinstance(_l, dict) and "error" not in _l and _l.get("text"):
|
||||
lst = _l
|
||||
_send_si = _send_index_in_state(_l["text"])
|
||||
_send_si = send_index_in_state(_l["text"])
|
||||
if _send_si:
|
||||
break
|
||||
if time.monotonic() >= _deadline:
|
||||
@@ -290,8 +290,8 @@ async def _post_action_state(
|
||||
return ""
|
||||
if not isinstance(lst, dict) or "error" in lst or not lst.get("text"):
|
||||
return ""
|
||||
state = lst["text"] if seen_lines is None else _delta_state(lst["text"], seen_lines)
|
||||
out = f"\n\n{PAGE_STATE_MARKER}\n{_truncate_state(state)}"
|
||||
state = lst["text"] if seen_lines is None else delta_state(lst["text"], seen_lines)
|
||||
out = f"\n\n{PAGE_STATE_MARKER}\n{p_truncate_state(state)}"
|
||||
# Hand the Send button's index over so the model clicks it directly instead of
|
||||
# scanning the list or hunting via CSS/JS/screenshots (the polled list above is what
|
||||
# makes Send actually present to point at, the two work together).
|
||||
@@ -303,7 +303,7 @@ async def _post_action_state(
|
||||
return out
|
||||
|
||||
|
||||
async def _request_browser_approval(
|
||||
async def p_request_browser_approval(
|
||||
session: AgentSession, tool_name: str, tool_input: dict,
|
||||
) -> dict:
|
||||
"""Send an approval request for a browser sub-agent tool and wait for the decision."""
|
||||
@@ -430,7 +430,7 @@ async def run_browser_agent(
|
||||
preloaded_perception = ""
|
||||
current_url = ""
|
||||
preloaded_reads: list[dict] = [] # real front-loaded reads, seeded into action_log
|
||||
_resumed = bool(browser_history._browser_history.get(browser_id))
|
||||
_resumed = bool(browser_history.BROWSER_HISTORY.get(browser_id))
|
||||
if initial_url:
|
||||
nav_result = await execute_browser_tool(
|
||||
"BrowserNavigate", {"url": initial_url}, browser_id, tab_id,
|
||||
@@ -503,8 +503,8 @@ async def run_browser_agent(
|
||||
# cycle every time the parent issues a new task. Defensively validate
|
||||
# the cache; if it's somehow corrupted (orphaned tool_use_ids), drop
|
||||
# it and start fresh rather than crash on the next API call.
|
||||
prior_messages = browser_history._browser_history.get(browser_id) or []
|
||||
if prior_messages and not _validate_message_pairing(prior_messages):
|
||||
prior_messages = browser_history.BROWSER_HISTORY.get(browser_id) or []
|
||||
if prior_messages and not validate_message_pairing(prior_messages):
|
||||
logger.warning(
|
||||
f"[browser-agent {session_id}] cached history for {browser_id} has "
|
||||
f"orphaned tool_use_ids; dropping cache and starting fresh"
|
||||
@@ -594,7 +594,7 @@ async def run_browser_agent(
|
||||
# Advisory per-domain hints: seed the system prompt with what a prior agent
|
||||
# learned about this domain (if we know the domain at start), and keep the
|
||||
# store fresh from each ReportProgress. Re-verify, never blindly trust.
|
||||
start_domain = _extract_domain(initial_url) if initial_url else None
|
||||
start_domain = p_extract_domain(initial_url) if initial_url else None
|
||||
run_system_prompt = SYSTEM_PROMPT
|
||||
if start_domain:
|
||||
prior_note = browser_history.get_domain_note(start_domain)
|
||||
@@ -791,7 +791,7 @@ async def run_browser_agent(
|
||||
try:
|
||||
lst = await execute_browser_tool("BrowserListInteractives", {}, browser_id, tab_id)
|
||||
if isinstance(lst, dict) and lst.get("text") and "error" not in lst:
|
||||
_fresh = f"\nCurrent page state after the replayed prefix:\n{_truncate_state(lst['text'])}"
|
||||
_fresh = f"\nCurrent page state after the replayed prefix:\n{p_truncate_state(lst['text'])}"
|
||||
except Exception:
|
||||
pass
|
||||
remaining = "; ".join(f"{s['tool']}({str(s.get('params', {}))[:80]})" for s in steps[unsafe_i:])
|
||||
@@ -838,7 +838,7 @@ async def run_browser_agent(
|
||||
summary = browser_metrics.record_task(
|
||||
session_id, browser_id, task, "completed", metrics_started_at,
|
||||
turns_spent, rlog, session.tokens,
|
||||
path="replay", task_sig=browser_skills._sig(skill_key_task),
|
||||
path="replay", task_sig=browser_skills.compute_sig(skill_key_task),
|
||||
)
|
||||
logger.info(f"[browser-skills] REPLAY SUCCEEDED in {summary['total_ms']}ms ({turns_spent} LLM turn(s))")
|
||||
try:
|
||||
@@ -909,7 +909,7 @@ async def run_browser_agent(
|
||||
# Pre-nav landed on a results page (the cold entry case): scan it NOW so the
|
||||
# model's very first turn can pick a candidate instead of read-then-decide.
|
||||
_start_url = (current_url or initial_url or "").split("#")[0]
|
||||
if _start_url and _RESULTS_URL_RE.search(_start_url):
|
||||
if _start_url and RESULTS_URL_RE.search(_start_url):
|
||||
auto_scanned_urls.add(_start_url)
|
||||
_scan_json, _sc_ms = await _scan_results(task)
|
||||
if _scan_json:
|
||||
@@ -1048,7 +1048,7 @@ async def run_browser_agent(
|
||||
# tool is the redundant narration the prompt now forbids; count it so
|
||||
# the bench can verify the prose actually went away.
|
||||
if any(t.strip() for t in text_parts) and any(
|
||||
tu.name in _ACTION_TOOLS_REQUIRING_REPORT for tu in tool_uses
|
||||
tu.name in ACTION_TOOLS_REQUIRING_REPORT for tu in tool_uses
|
||||
):
|
||||
narration_turns += 1
|
||||
|
||||
@@ -1088,7 +1088,7 @@ async def run_browser_agent(
|
||||
# the brain state is recorded before any actions execute.
|
||||
has_report_progress = any(tu.name == "ReportProgress" for tu in tool_uses)
|
||||
has_action_tools = any(
|
||||
tu.name in _ACTION_TOOLS_REQUIRING_REPORT for tu in tool_uses
|
||||
tu.name in ACTION_TOOLS_REQUIRING_REPORT for tu in tool_uses
|
||||
)
|
||||
# Violation: action tools without ReportProgress in the same turn.
|
||||
# The model MUST articulate its evaluation/memory/goal before acting.
|
||||
@@ -1105,7 +1105,7 @@ async def run_browser_agent(
|
||||
rp_violations += 1
|
||||
rp_reminder_pending = True
|
||||
if not current_next_goal or current_next_goal == task:
|
||||
_synth = next((t.name for t in tool_uses if t.name in _ACTION_TOOLS_REQUIRING_REPORT), "act")
|
||||
_synth = next((t.name for t in tool_uses if t.name in ACTION_TOOLS_REQUIRING_REPORT), "act")
|
||||
current_next_goal = f"(continuing) {_synth.replace('Browser', '').lower()}"
|
||||
logger.info(
|
||||
f"[browser-agent {session_id}] ReportProgress omitted; running the action "
|
||||
@@ -1119,7 +1119,7 @@ async def run_browser_agent(
|
||||
|
||||
# Under-batching detector: the model ignores prompt-level batching
|
||||
# invitations, so measure each turn and nudge mechanically below.
|
||||
_turn_actions = sum(1 for t in tool_uses_sorted if t.name in _BATCHABLE_ACTION_TOOLS)
|
||||
_turn_actions = sum(1 for t in tool_uses_sorted if t.name in P_BATCHABLE_ACTION_TOOLS)
|
||||
_turn_has_batch = any(t.name in ("BrowserBatch", "BrowserRepeatFlow") for t in tool_uses_sorted)
|
||||
if _turn_actions >= 2 or _turn_has_batch:
|
||||
multi_action_turns += 1
|
||||
@@ -1139,7 +1139,7 @@ async def run_browser_agent(
|
||||
# it off with partial results. Re-reading the same page yields no new sig.
|
||||
_novel_read = False
|
||||
for _a in action_log:
|
||||
if (_a.get("ok") and _a.get("tool") not in _BATCHABLE_ACTION_TOOLS
|
||||
if (_a.get("ok") and _a.get("tool") not in P_BATCHABLE_ACTION_TOOLS
|
||||
and _a.get("tool") not in ("ReportProgress", "Done")):
|
||||
_sig = f"{_a.get('tool')}:{_a.get('result_summary') or ''}"
|
||||
if _sig not in seen_read_sigs:
|
||||
@@ -1166,7 +1166,7 @@ async def run_browser_agent(
|
||||
# The general backstop only applies AFTER the agent has actually
|
||||
# done something; early pure-perception is legitimate orienting on a
|
||||
# cold/slow page, which we must never cut short.
|
||||
_acted = any(a.get("ok") and a.get("tool") in (_BATCHABLE_ACTION_TOOLS | {"BrowserBatch"})
|
||||
_acted = any(a.get("ok") and a.get("tool") in (P_BATCHABLE_ACTION_TOOLS | {"BrowserBatch"})
|
||||
for a in action_log)
|
||||
_stall_limit = (_POST_SEND_STALL_LIMIT if send_confirmed
|
||||
else (_PERCEPTION_STALL_LIMIT if _acted else 10 ** 9))
|
||||
@@ -1442,7 +1442,7 @@ async def run_browser_agent(
|
||||
if tu.name == "RequestHumanIntervention":
|
||||
problem = tu.input.get("problem", "")
|
||||
instruction = tu.input.get("instruction", "")
|
||||
decision = await _request_browser_approval(
|
||||
decision = await p_request_browser_approval(
|
||||
session, tu.name, {"problem": problem, "instruction": instruction},
|
||||
)
|
||||
if decision.get("behavior") != "deny":
|
||||
@@ -1490,7 +1490,7 @@ async def run_browser_agent(
|
||||
continue
|
||||
|
||||
if policy == "ask":
|
||||
decision = await _request_browser_approval(
|
||||
decision = await p_request_browser_approval(
|
||||
session, tu.name, tu.input,
|
||||
)
|
||||
if decision.get("behavior") == "deny":
|
||||
@@ -1562,7 +1562,7 @@ async def run_browser_agent(
|
||||
# so the agent never claims a success it didn't see or re-fires blindly.
|
||||
_expect = (str(tu.input.get("expect") or "").strip()
|
||||
if isinstance(tu.input, dict) else "")
|
||||
if _expect and "error" not in result and tu.name in _CONFIRM_TOOLS:
|
||||
if _expect and "error" not in result and tu.name in P_CONFIRM_TOOLS:
|
||||
# target_only: wait for the expected text to actually appear, don't
|
||||
# call it 'not confirmed' just because the page settled first (a sent
|
||||
# message lands in the thread a beat after settle, esp. under load)
|
||||
@@ -1589,7 +1589,7 @@ async def run_browser_agent(
|
||||
# across nodes, or scrolled off, so the text-probe is unreliable, which
|
||||
# is exactly what left the model stalling to "double-check". A clean
|
||||
# send click is proof enough; drive to the OUTCOME.
|
||||
if task_is_send and not send_confirmed and "error" not in result and tu.name in _CONFIRM_TOOLS:
|
||||
if task_is_send and not send_confirmed and "error" not in result and tu.name in P_CONFIRM_TOOLS:
|
||||
_cn = result.get("clickedName") or ""
|
||||
_cr = result.get("clickedRole") or ""
|
||||
_send_click = browser_batch_replay.is_send_completed(
|
||||
@@ -1643,7 +1643,7 @@ async def run_browser_agent(
|
||||
result["text"] = (f"{result.get('error')}\n\n[recovery] That action did not "
|
||||
f"take effect, but the page is live. Current elements (re-act "
|
||||
f"from HERE, do not just retry the old index):\n"
|
||||
f"{_truncate_state(str(_rl['text']))}")
|
||||
f"{p_truncate_state(str(_rl['text']))}")
|
||||
recovery_attaches += 1
|
||||
logger.info(f"[browser-recovery {session_id}] attached fresh state after "
|
||||
f"recoverable error at turn {turn}: {str(result.get('error'))[:60]}")
|
||||
@@ -1656,7 +1656,7 @@ async def run_browser_agent(
|
||||
attached_state_seen.update(
|
||||
l for l in str(result.get("text") or "").splitlines() if l.startswith("[")
|
||||
)
|
||||
_auto_state = await _post_action_state(
|
||||
_auto_state = await post_action_state(
|
||||
tu.name, tu.input, result, browser_id, tab_id, _wait_exec, current_next_goal,
|
||||
seen_lines=attached_state_seen,
|
||||
)
|
||||
@@ -1669,7 +1669,7 @@ async def run_browser_agent(
|
||||
|
||||
# One gentle nudge per violating turn, folded onto the action that
|
||||
# ran, so the model self-corrects next turn without us costing it one.
|
||||
if rp_reminder_pending and tu.name in _ACTION_TOOLS_REQUIRING_REPORT:
|
||||
if rp_reminder_pending and tu.name in ACTION_TOOLS_REQUIRING_REPORT:
|
||||
rp_reminder_pending = False
|
||||
result["text"] = (f"{result.get('text') or ''}\n\n[note] Action ran. Next turn, "
|
||||
"include ReportProgress (working_memory + next_goal) alongside "
|
||||
@@ -1680,7 +1680,7 @@ async def run_browser_agent(
|
||||
# per URL, only on the tight throwaway-dismiss vocabulary that
|
||||
# never sits on a task-needed control, so it can't close anything
|
||||
# required. After closing, re-list so the model sees the page beneath.
|
||||
if tu.name in _AUTO_STATE_TOOLS and "error" not in result:
|
||||
if tu.name in P_AUTO_STATE_TOOLS and "error" not in result:
|
||||
_pop_url = (result.get("url") or last_seen_url or "").split("#")[0]
|
||||
if _pop_url and _pop_url not in dismissed_popup_urls:
|
||||
_close = interstitial_dismiss_target("\n".join(attached_state_seen))
|
||||
@@ -1692,7 +1692,7 @@ async def run_browser_agent(
|
||||
logger.info(f"[browser-popup {session_id}] auto-dismissed '{_close}' "
|
||||
f"ok={_dok} on {_pop_url[:80]}")
|
||||
if _dok:
|
||||
_fresh = await _post_action_state(
|
||||
_fresh = await post_action_state(
|
||||
"BrowserClickByName", {}, _dres or {}, browser_id, tab_id,
|
||||
_wait_exec, current_next_goal, seen_lines=attached_state_seen)
|
||||
result["text"] = (f"{result.get('text') or ''}\n\n[auto] Closed a blocking "
|
||||
@@ -1702,10 +1702,10 @@ async def run_browser_agent(
|
||||
# costs a read-then-decide turn pair; the cheap aux model reads it
|
||||
# now so the pick happens on this same turn. Capped, per-URL,
|
||||
# fail-silent (a miss just means the old two-turn dance).
|
||||
if (tu.name in _AUTO_STATE_TOOLS and "error" not in result
|
||||
and auto_scan_count < _AUTO_SCAN_MAX_PER_RUN):
|
||||
if (tu.name in P_AUTO_STATE_TOOLS and "error" not in result
|
||||
and auto_scan_count < P_AUTO_SCAN_MAX_PER_RUN):
|
||||
_scan_url = (result.get("url") or last_seen_url or "").split("#")[0]
|
||||
if _scan_url and _scan_url not in auto_scanned_urls and _RESULTS_URL_RE.search(_scan_url):
|
||||
if _scan_url and _scan_url not in auto_scanned_urls and RESULTS_URL_RE.search(_scan_url):
|
||||
auto_scanned_urls.add(_scan_url)
|
||||
_scan_json, _sc_ms = await _scan_results(task)
|
||||
if _scan_json:
|
||||
@@ -1771,22 +1771,22 @@ async def run_browser_agent(
|
||||
# acknowledge it on its next turn.
|
||||
# Loop detection only covers the non-excluded tools, so skip the
|
||||
# hash entirely for the excluded ones; otherwise a screenshot/read
|
||||
# serializes its full ~1MB result here just for _detect_loop to
|
||||
# serializes its full ~1MB result here just for detect_loop to
|
||||
# discard it (it short-circuits excluded tools to False anyway).
|
||||
if tu.name in _LOOP_DETECTION_EXCLUDED_TOOLS:
|
||||
if tu.name in LOOP_DETECTION_EXCLUDED_TOOLS:
|
||||
is_loop = False
|
||||
else:
|
||||
call_key = _hash_tool_call(tu.name, tu.input, result)
|
||||
is_loop = _detect_loop(recent_tool_calls, call_key)
|
||||
call_key = hash_tool_call(tu.name, tu.input, result)
|
||||
is_loop = detect_loop(recent_tool_calls, call_key)
|
||||
recent_tool_calls.append(call_key)
|
||||
if len(recent_tool_calls) > _LOOP_WINDOW_SIZE * 2:
|
||||
recent_tool_calls = recent_tool_calls[-_LOOP_WINDOW_SIZE * 2:]
|
||||
if len(recent_tool_calls) > LOOP_WINDOW_SIZE * 2:
|
||||
recent_tool_calls = recent_tool_calls[-LOOP_WINDOW_SIZE * 2:]
|
||||
|
||||
content_blocks = _format_tool_result(result, tu.name)
|
||||
content_blocks = format_tool_result(result, tu.name)
|
||||
try:
|
||||
url = result.get("url") or (tu.input or {}).get("url")
|
||||
if url:
|
||||
domain = _extract_domain(str(url))
|
||||
domain = p_extract_domain(str(url))
|
||||
if domain and domain not in session.browser_domains:
|
||||
session.browser_domains.append(domain)
|
||||
except Exception:
|
||||
@@ -1809,7 +1809,7 @@ async def run_browser_agent(
|
||||
if is_loop:
|
||||
loop_trigger_count += 1
|
||||
repeat_count = sum(1 for c in recent_tool_calls if c == call_key)
|
||||
warning = _LOOP_WARNING_TEXT.format(count=repeat_count)
|
||||
warning = LOOP_WARNING_TEXT.format(count=repeat_count)
|
||||
logger.warning(
|
||||
f"[browser-agent {session_id}] loop detected on {tu.name} "
|
||||
f"(trigger #{loop_trigger_count}): {warning}"
|
||||
@@ -1936,7 +1936,7 @@ async def run_browser_agent(
|
||||
# tool_results (a text block alongside them) so the model's next turn is
|
||||
# a clean Done instead of more looking.
|
||||
if wrapup_pending:
|
||||
tool_results.append({"type": "text", "text": _WRAPUP_NUDGE})
|
||||
tool_results.append({"type": "text", "text": P_WRAPUP_NUDGE})
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
if done_called:
|
||||
@@ -1948,10 +1948,10 @@ async def run_browser_agent(
|
||||
# Hard cap on loops: if the model keeps repeating itself even
|
||||
# after we warn it, force-exit so we don't burn the entire turn
|
||||
# budget on a stuck agent.
|
||||
if loop_trigger_count >= _LOOP_HARD_CAP:
|
||||
if loop_trigger_count >= LOOP_HARD_CAP:
|
||||
logger.warning(
|
||||
f"[browser-agent {session_id}] hit loop hard cap "
|
||||
f"({_LOOP_HARD_CAP}); force-exiting"
|
||||
f"({LOOP_HARD_CAP}); force-exiting"
|
||||
)
|
||||
break
|
||||
|
||||
@@ -1959,7 +1959,7 @@ async def run_browser_agent(
|
||||
# out / the page never responds). Either way the agent can't make
|
||||
# progress, so stop retrying after a short streak and report honestly,
|
||||
# instead of the 20-minute spin on a wedged tab.
|
||||
if card_gone_streak >= _CARD_GONE_LIMIT:
|
||||
if card_gone_streak >= CARD_GONE_LIMIT:
|
||||
logger.warning(
|
||||
f"[browser-agent {session_id}] browser card {browser_id} is unusable "
|
||||
f"({card_gone_streak} consecutive gone/hung results); aborting fast"
|
||||
@@ -2005,18 +2005,18 @@ async def run_browser_agent(
|
||||
|
||||
# Persist conversation history so the next BrowserAgent call on this
|
||||
# browser can resume rather than re-orient. Trim to the most recent
|
||||
# _MAX_HISTORY_MESSAGES turns to keep token usage bounded; but
|
||||
# MAX_HISTORY_MESSAGES turns to keep token usage bounded; but
|
||||
# never split a tool_use ↔ tool_result pair across the cut, or the
|
||||
# next API request will 400.
|
||||
browser_history._browser_history[browser_id] = _trim_history_by_turns(
|
||||
messages, _MAX_HISTORY_MESSAGES,
|
||||
browser_history.BROWSER_HISTORY[browser_id] = trim_history_by_turns(
|
||||
messages, MAX_HISTORY_MESSAGES,
|
||||
)
|
||||
|
||||
# Honesty gate: the model declaring done is not proof the goal happened.
|
||||
# If the run did no real work (zero actions, all actions errored, or only
|
||||
# looked around), report the truth instead of a ghost "completed". A gone
|
||||
# card gets its own precise reason instead of the generic verdict.
|
||||
if card_gone_streak >= _CARD_GONE_LIMIT:
|
||||
if card_gone_streak >= CARD_GONE_LIMIT:
|
||||
honest, dishonest_reason = False, "the browser became unresponsive (the tab hung or was closed); it needs a fresh browser to continue"
|
||||
else:
|
||||
honest, dishonest_reason = completion_is_honest(action_log)
|
||||
@@ -2073,7 +2073,7 @@ async def run_browser_agent(
|
||||
browser_metrics.record_task(session_id, browser_id, task, final_status,
|
||||
metrics_started_at, turn + 1, action_log, session.tokens,
|
||||
path="llm_fallback" if replay_attempted else "llm",
|
||||
task_sig=browser_skills._sig(skill_key_task),
|
||||
task_sig=browser_skills.compute_sig(skill_key_task),
|
||||
playbook_seeded=pb_seeded)
|
||||
# Learn this task ONLY from a genuinely successful run whose deliverable a
|
||||
# deterministic replay can actually reproduce. We skip recording when the
|
||||
@@ -2202,13 +2202,13 @@ async def run_browser_agent(
|
||||
|
||||
# Cards a sub-agent is actively driving in this process. Reuse must never hand
|
||||
# two agents one webview (their commands would interleave into chaos).
|
||||
_active_agent_cards: set[str] = set()
|
||||
ACTIVE_AGENT_CARDS: set[str] = set()
|
||||
# find+claim+create must be one critical section or two parallel dispatches
|
||||
# race to claim the same idle card (or both miss and double-create).
|
||||
_card_pick_lock = asyncio.Lock()
|
||||
p_card_pick_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | None) -> str:
|
||||
def find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | None) -> str:
|
||||
"""An existing same-host spawned card to drive instead of stacking another
|
||||
webview: concurrent same-site webviews wedge each other (shared-partition
|
||||
lock contention), so a retry must REUSE, not multiply. The parent's own
|
||||
@@ -2226,7 +2226,7 @@ def _find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | No
|
||||
own, orphan = "", ""
|
||||
for bid, card in cards.items():
|
||||
spawned = getattr(card, "spawned_by", None)
|
||||
if not spawned or bid in _active_agent_cards:
|
||||
if not spawned or bid in ACTIVE_AGENT_CARDS:
|
||||
continue
|
||||
if browser_skills.host_of(getattr(card, "url", "") or "") != want:
|
||||
continue
|
||||
@@ -2239,7 +2239,7 @@ def _find_reusable_card(dashboard_id: str, url: str, parent_session_id: str | No
|
||||
return own or orphan
|
||||
|
||||
|
||||
async def _create_browser_card(dashboard_id: str, url: str, parent_session_id: str | None = None) -> str:
|
||||
async def p_create_browser_card(dashboard_id: str, url: str, parent_session_id: str | None = None) -> str:
|
||||
"""Create a new browser card on the dashboard and return its browser_id."""
|
||||
from backend.apps.dashboards.dashboards import _load, _save
|
||||
from backend.apps.dashboards.models import BrowserCardPosition, BrowserTab
|
||||
@@ -2326,15 +2326,15 @@ async def run_browser_agents(
|
||||
# the url param is often empty with the target buried in the task
|
||||
# text; a url there still names the host we must not duplicate
|
||||
host_src = url or entry_url or next(iter(re.findall(r"https?://[^\s)\"'<>]+", task_text)), "")
|
||||
async with _card_pick_lock:
|
||||
browser_id = _find_reusable_card(dashboard_id, host_src, parent_session_id)
|
||||
async with p_card_pick_lock:
|
||||
browser_id = find_reusable_card(dashboard_id, host_src, parent_session_id)
|
||||
if browser_id:
|
||||
reused = True
|
||||
else:
|
||||
browser_id = await _create_browser_card(dashboard_id, url or entry_url, parent_session_id)
|
||||
browser_id = await p_create_browser_card(dashboard_id, url or entry_url, parent_session_id)
|
||||
if entry_url and not url:
|
||||
logger.info(f"[browser-cold] new card {browser_id} opens at brief entry {entry_url}")
|
||||
_active_agent_cards.add(browser_id)
|
||||
ACTIVE_AGENT_CARDS.add(browser_id)
|
||||
if reused:
|
||||
logger.info(f"[browser-agent] reusing same-host card {browser_id} instead of stacking another webview")
|
||||
if url:
|
||||
@@ -2346,7 +2346,7 @@ async def run_browser_agents(
|
||||
else:
|
||||
await asyncio.sleep(2.0)
|
||||
elif browser_id:
|
||||
_active_agent_cards.add(browser_id)
|
||||
ACTIVE_AGENT_CARDS.add(browser_id)
|
||||
|
||||
is_pre_selected = browser_id in pre_selected
|
||||
_nav_url = url or ("" if reused else entry_url)
|
||||
@@ -2362,7 +2362,7 @@ async def run_browser_agents(
|
||||
parent_session_id=parent_session_id,
|
||||
)
|
||||
finally:
|
||||
_active_agent_cards.discard(browser_id)
|
||||
ACTIVE_AGENT_CARDS.discard(browser_id)
|
||||
|
||||
results = await asyncio.gather(*[_run_one(t) for t in tasks], return_exceptions=True)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import re
|
||||
PLACEHOLDER = "{{value}}"
|
||||
|
||||
# Agent-facing step action -> (tool_name, the param keys it carries).
|
||||
_STEP_TOOLS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
P_STEP_TOOLS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
"navigate": ("BrowserNavigate", ("url",)),
|
||||
"get_text": ("BrowserGetText", ()),
|
||||
"evaluate": ("BrowserEvaluate", ("expression",)),
|
||||
@@ -49,27 +49,27 @@ _STEP_TOOLS: dict[str, tuple[str, tuple[str, ...]]] = {
|
||||
}
|
||||
|
||||
# Reads/navigation don't mutate anything irreversible; safe to loop freely.
|
||||
_READONLY_ACTIONS = {"navigate", "get_text", "evaluate", "scroll", "replay_route"}
|
||||
P_READONLY_ACTIONS = {"navigate", "get_text", "evaluate", "scroll", "replay_route"}
|
||||
|
||||
# Irreversible / outward-facing words on a clicked control. Conservative on
|
||||
# purpose: we'd rather refuse a borderline loop than auto-send 10 messages.
|
||||
_SEND_NAME_RE = re.compile(
|
||||
P_SEND_NAME_RE = re.compile(
|
||||
r"\b(send|submit|post|publish|connect|invite|follow|like|react|comment|reply|"
|
||||
r"share|message|dm|pay|buy|order|checkout|purchase|place\s*order|book|"
|
||||
r"confirm|apply|accept|decline|delete|remove|unsend|withdraw|endorse)\b",
|
||||
re.I,
|
||||
)
|
||||
# A field that reads like a message/comment composer; typing here is part of a send.
|
||||
_COMPOSE_SEL_RE = re.compile(r"message|compose|comment|msg|reply|editor|body|tweet|post", re.I)
|
||||
P_COMPOSE_SEL_RE = re.compile(r"message|compose|comment|msg|reply|editor|body|tweet|post", re.I)
|
||||
|
||||
|
||||
def is_send_step(step: dict) -> bool:
|
||||
"""True if this step is irreversible / outward-facing, so the whole loop must
|
||||
be gated rather than auto-replayed."""
|
||||
action = step.get("action")
|
||||
if action == "click" and _SEND_NAME_RE.search(str(step.get("name") or "")):
|
||||
if action == "click" and P_SEND_NAME_RE.search(str(step.get("name") or "")):
|
||||
return True
|
||||
if action == "type" and _COMPOSE_SEL_RE.search(str(step.get("selector") or "")):
|
||||
if action == "type" and P_COMPOSE_SEL_RE.search(str(step.get("selector") or "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -83,9 +83,9 @@ def validate_template(steps) -> tuple[bool, str]:
|
||||
if not isinstance(step, dict):
|
||||
return False, f"step {i+1} is not an object"
|
||||
action = step.get("action")
|
||||
spec = _STEP_TOOLS.get(action)
|
||||
spec = P_STEP_TOOLS.get(action)
|
||||
if not spec:
|
||||
return False, f"step {i+1}: unknown action {action!r} (allowed: {', '.join(_STEP_TOOLS)})"
|
||||
return False, f"step {i+1}: unknown action {action!r} (allowed: {', '.join(P_STEP_TOOLS)})"
|
||||
_, required = spec
|
||||
for key in required:
|
||||
if step.get(key) in (None, ""):
|
||||
@@ -105,9 +105,9 @@ def template_safety(steps) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
# Like _SEND_NAME_RE minus composer-openers ("Message"/"DM" buttons open a
|
||||
# Like P_SEND_NAME_RE minus composer-openers ("Message"/"DM" buttons open a
|
||||
# compose box, they don't send), so routine flows still batch freely.
|
||||
_LIVE_IRREVERSIBLE_RE = re.compile(
|
||||
P_LIVE_IRREVERSIBLE_RE = re.compile(
|
||||
r"\b(send|submit|post|publish|connect|invite|follow|like|react|comment|reply|"
|
||||
r"share|pay|buy|order|checkout|purchase|place\s*order|book|"
|
||||
r"confirm|apply|accept|decline|delete|remove|unsend|withdraw|endorse)\b",
|
||||
@@ -123,18 +123,18 @@ def is_replay_boundary(step: dict) -> bool:
|
||||
crosses to the live model. Uses the same opener-excluded wordlist the live
|
||||
send-guard already trusts, so a recorded Send still stops the prefix."""
|
||||
action = step.get("action")
|
||||
if action == "click" and _LIVE_IRREVERSIBLE_RE.search(str(step.get("name") or "")):
|
||||
if action == "click" and P_LIVE_IRREVERSIBLE_RE.search(str(step.get("name") or "")):
|
||||
return True
|
||||
if action == "type" and _COMPOSE_SEL_RE.search(str(step.get("selector") or "")):
|
||||
if action == "type" and P_COMPOSE_SEL_RE.search(str(step.get("selector") or "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_SEND_COMPLETED_RE = re.compile(
|
||||
P_SEND_COMPLETED_RE = re.compile(
|
||||
r"\b(send|submit|pay|place\s*order|complete\s*(order|purchase|checkout|payment))\b",
|
||||
re.I,
|
||||
)
|
||||
_OPENER_ROLES = frozenset({"menuitem", "menuitemcheckbox", "menuitemradio", "link", "tab"})
|
||||
P_OPENER_ROLES = frozenset({"menuitem", "menuitemcheckbox", "menuitemradio", "link", "tab"})
|
||||
|
||||
|
||||
def is_send_completed(step: dict) -> bool:
|
||||
@@ -145,9 +145,9 @@ def is_send_completed(step: dict) -> bool:
|
||||
if step.get("action") != "click":
|
||||
return False
|
||||
role = str(step.get("role") or "").lower()
|
||||
if role in _OPENER_ROLES:
|
||||
if role in P_OPENER_ROLES:
|
||||
return False
|
||||
return bool(_SEND_COMPLETED_RE.search(str(step.get("name") or "")))
|
||||
return bool(P_SEND_COMPLETED_RE.search(str(step.get("name") or "")))
|
||||
|
||||
|
||||
def live_batch_guard(actions, seen_lines, composer_pending: bool = False) -> str:
|
||||
@@ -172,7 +172,7 @@ def live_batch_guard(actions, seen_lines, composer_pending: bool = False) -> str
|
||||
elif typ == "click":
|
||||
label = str(params.get("selector") or "")
|
||||
elif typ == "type":
|
||||
if _COMPOSE_SEL_RE.search(str(params.get("selector") or "")):
|
||||
if P_COMPOSE_SEL_RE.search(str(params.get("selector") or "")):
|
||||
typed_composer = True
|
||||
continue
|
||||
elif typ == "press_key":
|
||||
@@ -184,7 +184,7 @@ def live_batch_guard(actions, seen_lines, composer_pending: bool = False) -> str
|
||||
continue
|
||||
# selectors hide words behind underscores/dashes (msg-form__send-button),
|
||||
# which defeat \b; flatten separators so the word check still sees them
|
||||
if label and _LIVE_IRREVERSIBLE_RE.search(re.sub(r"[_\-./#\[\]]+", " ", label)):
|
||||
if label and P_LIVE_IRREVERSIBLE_RE.search(re.sub(r"[_\-./#\[\]]+", " ", label)):
|
||||
return (f"sub-action {i+1} ({typ}) targets {label.strip()!r}, "
|
||||
"which is irreversible/outward-facing")
|
||||
return ""
|
||||
@@ -209,12 +209,12 @@ def send_payload_from_log(action_log, prompt: str = "") -> str:
|
||||
summ = str(a.get("result_summary") or "")
|
||||
# focus+type results carry no clicked fields (r47's live miss); the
|
||||
# executor's own "typed the text" wording is the surviving signal
|
||||
if _COMPOSE_SEL_RE.search(name) or (len(text) >= 20 and (
|
||||
if P_COMPOSE_SEL_RE.search(name) or (len(text) >= 20 and (
|
||||
role == "textbox" or "typed the text" in summ.lower())):
|
||||
typed.append(text)
|
||||
elif tool == "BrowserType":
|
||||
sel = str(inp.get("selector") or "")
|
||||
if _COMPOSE_SEL_RE.search(sel) or (not sel and len(text) >= 20):
|
||||
if P_COMPOSE_SEL_RE.search(sel) or (not sel and len(text) >= 20):
|
||||
typed.append(text)
|
||||
elif tool == "BrowserBatch":
|
||||
for sub in (inp.get("actions") or []):
|
||||
@@ -224,7 +224,7 @@ def send_payload_from_log(action_log, prompt: str = "") -> str:
|
||||
sub_text = str(p.get("text") or "").strip()
|
||||
sub_sel = str(p.get("selector") or "")
|
||||
if sub.get("type") == "type" and sub_text and (
|
||||
_COMPOSE_SEL_RE.search(sub_sel)
|
||||
P_COMPOSE_SEL_RE.search(sub_sel)
|
||||
or (not sub_sel and len(sub_text) >= 20)):
|
||||
typed.append(sub_text)
|
||||
if not typed:
|
||||
@@ -237,7 +237,7 @@ def send_payload_from_log(action_log, prompt: str = "") -> str:
|
||||
return typed[-1]
|
||||
|
||||
|
||||
def _sub(val, value: str):
|
||||
def p_sub(val, value: str):
|
||||
return value if val == PLACEHOLDER else (
|
||||
val.replace(PLACEHOLDER, value) if isinstance(val, str) else val
|
||||
)
|
||||
@@ -247,14 +247,14 @@ def fill_step(step: dict, value: str) -> tuple[str, dict]:
|
||||
"""Turn one template step + one value into (tool_name, params) ready for
|
||||
execute_browser_tool. Substitutes {{value}} anywhere it appears."""
|
||||
action = step["action"]
|
||||
tool_name, keys = _STEP_TOOLS[action]
|
||||
tool_name, keys = P_STEP_TOOLS[action]
|
||||
params = {}
|
||||
for k in keys:
|
||||
if k in step:
|
||||
params[k] = _sub(step[k], value)
|
||||
params[k] = p_sub(step[k], value)
|
||||
# carry an optional role default for clicks
|
||||
if action == "click" and "role" not in params:
|
||||
params["role"] = _sub(step.get("role", ""), value)
|
||||
params["role"] = p_sub(step.get("role", ""), value)
|
||||
return tool_name, params
|
||||
|
||||
|
||||
@@ -265,19 +265,19 @@ def fill_template(steps, value: str) -> list[tuple[str, dict]]:
|
||||
def is_readonly_template(steps) -> bool:
|
||||
"""True if every step is a pure read/navigation (no clicks/types at all), the
|
||||
safest class of loop."""
|
||||
return all(s.get("action") in _READONLY_ACTIONS for s in steps)
|
||||
return all(s.get("action") in P_READONLY_ACTIONS for s in steps)
|
||||
|
||||
|
||||
# A batch READ is useless if it doesn't hand the data back. We return each item's
|
||||
# read output, capped so a 20-item batch stays cheap, and stay honest about
|
||||
# failures (named, with the error) and truncation (named, never silently dropped).
|
||||
_MAX_ITEM_CHARS = 500
|
||||
_MAX_TOTAL_CHARS = 6000
|
||||
P_MAX_ITEM_CHARS = 500
|
||||
P_MAX_TOTAL_CHARS = 6000
|
||||
|
||||
|
||||
def summarize_batch(records: list[dict], readonly: bool,
|
||||
max_item_chars: int = _MAX_ITEM_CHARS,
|
||||
max_total_chars: int = _MAX_TOTAL_CHARS) -> str:
|
||||
max_item_chars: int = P_MAX_ITEM_CHARS,
|
||||
max_total_chars: int = P_MAX_TOTAL_CHARS) -> str:
|
||||
"""Turn per-item batch results into the text the agent gets back.
|
||||
|
||||
`records`: [{value, ok, text}]. For a successful item `text` is its read
|
||||
|
||||
@@ -13,12 +13,12 @@ import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_PAGE_CHARS = 12000
|
||||
_MAX_OUT_TOKENS = 1200
|
||||
_MAX_SCHEMA_CHARS = 2000
|
||||
P_MAX_PAGE_CHARS = 12000
|
||||
P_MAX_OUT_TOKENS = 1200
|
||||
P_MAX_SCHEMA_CHARS = 2000
|
||||
|
||||
|
||||
def _first_json(text: str) -> str:
|
||||
def first_json(text: str) -> str:
|
||||
"""The model's output minus any prose/fences around the JSON, or ''."""
|
||||
cleaned = re.sub(r"```(?:json)?|```", "", text or "")
|
||||
m = re.search(r"\{.*\}|\[.*\]", cleaned, re.DOTALL)
|
||||
@@ -38,7 +38,7 @@ async def extract_structured(
|
||||
if not aux_client or not aux_model or not page_text:
|
||||
return ""
|
||||
shape = (
|
||||
f"Return JSON matching this schema exactly:\n{json.dumps(schema)[:_MAX_SCHEMA_CHARS]}"
|
||||
f"Return JSON matching this schema exactly:\n{json.dumps(schema)[:P_MAX_SCHEMA_CHARS]}"
|
||||
if isinstance(schema, dict) and schema else "Return one compact JSON object."
|
||||
)
|
||||
prompt = (
|
||||
@@ -47,15 +47,15 @@ async def extract_structured(
|
||||
"Output ONLY the JSON, no prose, no code fences. Use only what is on the "
|
||||
'page, never guess. If the requested data is not on the page, output '
|
||||
'{"not_found": true, "reason": "<one short line>"}.\n\n'
|
||||
f"PAGE TEXT:\n{page_text[:_MAX_PAGE_CHARS]}"
|
||||
f"PAGE TEXT:\n{page_text[:P_MAX_PAGE_CHARS]}"
|
||||
)
|
||||
try:
|
||||
resp = await aux_client.messages.create(
|
||||
model=aux_model, max_tokens=_MAX_OUT_TOKENS,
|
||||
model=aux_model, max_tokens=P_MAX_OUT_TOKENS,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
text = "".join(getattr(b, "text", "") for b in (resp.content or []))
|
||||
return _first_json(text)
|
||||
return first_json(text)
|
||||
except Exception as e:
|
||||
logger.debug(f"[browser-extract] aux extraction failed: {e}")
|
||||
return ""
|
||||
|
||||
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Zero-cost smell test: only prompts that mention the web at all are worth a
|
||||
# classifier call. False negatives just take the normal path.
|
||||
_BROWSY_RE = re.compile(
|
||||
P_BROWSY_RE = re.compile(
|
||||
r"https?://|www\.|\b[a-z0-9-]+\.(com|org|net|io|co|ai|dev|app)\b"
|
||||
r"|\b(browse|browser|website|web ?page|webpage|site|url|tab)\b"
|
||||
r"|\b(go to|open|visit|navigate|log ?in|sign ?in|search on|look up on|check on)\b"
|
||||
@@ -34,7 +34,7 @@ _BROWSY_RE = re.compile(
|
||||
re.I,
|
||||
)
|
||||
|
||||
_CLASSIFIER_SYSTEM = (
|
||||
P_CLASSIFIER_SYSTEM = (
|
||||
"You route requests to a web-browsing agent. It drives a real signed-in browser: "
|
||||
"navigating sites, reading or extracting or counting what is on pages, clicking, "
|
||||
"typing, and acting inside web apps (sending messages on LinkedIn or any site, "
|
||||
@@ -87,10 +87,10 @@ def fast_path_eligible(
|
||||
return False
|
||||
if not prompt or not prompt.strip():
|
||||
return False
|
||||
return bool(_BROWSY_RE.search(prompt))
|
||||
return bool(P_BROWSY_RE.search(prompt))
|
||||
|
||||
|
||||
def _parse_verdict_and_brief(text: str) -> tuple[str, str]:
|
||||
def parse_verdict_and_brief(text: str) -> tuple[str, str]:
|
||||
"""Line 1 carries READ/ACT/NO; the rest is the routing brief. Anything
|
||||
unparseable is 'no' (normal path)."""
|
||||
lines = (text or "").strip().splitlines()
|
||||
@@ -105,14 +105,14 @@ def _parse_verdict_and_brief(text: str) -> tuple[str, str]:
|
||||
return verdict, brief[:700]
|
||||
|
||||
|
||||
_ENTRY_RE = re.compile(r"^\s*ENTRY:\s*(https?://\S+)", re.I | re.M)
|
||||
P_ENTRY_RE = re.compile(r"^\s*ENTRY:\s*(https?://\S+)", re.I | re.M)
|
||||
|
||||
|
||||
def entry_url_from_brief(brief: str) -> str:
|
||||
"""The brief's ENTRY deep URL, or ''. Powers dispatch pre-navigation: a NEW
|
||||
card opens directly on it instead of google, killing the orient+navigate
|
||||
turns; a REUSED card is never moved (its deeper live state wins)."""
|
||||
m = _ENTRY_RE.search(brief or "")
|
||||
m = P_ENTRY_RE.search(brief or "")
|
||||
return m.group(1).rstrip(".,;)") if m else ""
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ def unverifiable_reply(payload: str, first_report: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_for_classifier(prompt: str) -> str:
|
||||
def normalize_for_classifier(prompt: str) -> str:
|
||||
"""Haiku reads bare 'text him' as SMS even with a site as context. In the
|
||||
browsy-prefiltered pool, text-with-no-phone-number is in-site messaging,
|
||||
so spell it out for the small model. Only the classifier sees this."""
|
||||
@@ -237,13 +237,13 @@ async def classify_and_brief(prompt: str, settings, primary_api: str | None) ->
|
||||
model=aux_model,
|
||||
max_tokens=250,
|
||||
temperature=0,
|
||||
system=_CLASSIFIER_SYSTEM,
|
||||
messages=[{"role": "user", "content": _normalize_for_classifier(prompt[:2000])}],
|
||||
system=P_CLASSIFIER_SYSTEM,
|
||||
messages=[{"role": "user", "content": normalize_for_classifier(prompt[:2000])}],
|
||||
),
|
||||
timeout=8.0,
|
||||
)
|
||||
from backend.apps.agents.core.aux_llm import safe_resp_text
|
||||
verdict, brief = _parse_verdict_and_brief(safe_resp_text(resp))
|
||||
verdict, brief = parse_verdict_and_brief(safe_resp_text(resp))
|
||||
logger.info(
|
||||
f"[browser-fast-path] classifier: {verdict.upper()} brief={len(brief)}ch "
|
||||
f"model={aux_model} in {int((time.monotonic() - t0) * 1000)}ms"
|
||||
|
||||
@@ -13,12 +13,12 @@ import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ENTRY_RE = re.compile(r"^ENTRY:\s*(https?://\S+)", re.I | re.M)
|
||||
_MIN_PAGE_CHARS = 500
|
||||
_MAX_PAGE_CHARS = 24000
|
||||
_FETCH_ERROR_PREFIXES = ("HTTP error", "Error fetching", "Refused to fetch")
|
||||
P_ENTRY_RE = re.compile(r"^ENTRY:\s*(https?://\S+)", re.I | re.M)
|
||||
P_MIN_PAGE_CHARS = 500
|
||||
P_MAX_PAGE_CHARS = 24000
|
||||
P_FETCH_ERROR_PREFIXES = ("HTTP error", "Error fetching", "Refused to fetch")
|
||||
|
||||
_ANSWER_SYSTEM = (
|
||||
P_ANSWER_SYSTEM = (
|
||||
"Answer the user's request using ONLY the page text provided. Be direct and "
|
||||
"complete in a few sentences; quote exact titles/values from the page. End "
|
||||
"with nothing else.\n"
|
||||
@@ -28,16 +28,16 @@ _ANSWER_SYSTEM = (
|
||||
|
||||
|
||||
def extract_entry_url(brief: str) -> str:
|
||||
m = _ENTRY_RE.search(brief or "")
|
||||
m = P_ENTRY_RE.search(brief or "")
|
||||
return m.group(1).rstrip(").,") if m else ""
|
||||
|
||||
|
||||
def page_is_thin(text: str) -> bool:
|
||||
t = (text or "").strip()
|
||||
if not t or t.startswith(_FETCH_ERROR_PREFIXES):
|
||||
if not t or t.startswith(P_FETCH_ERROR_PREFIXES):
|
||||
return True
|
||||
body = t.split("\n\n", 1)[-1] if "\n\n" in t else t
|
||||
return len(body.strip()) < _MIN_PAGE_CHARS
|
||||
return len(body.strip()) < P_MIN_PAGE_CHARS
|
||||
|
||||
|
||||
async def try_fast_read(prompt: str, brief: str, settings, primary_api: str | None) -> str | None:
|
||||
@@ -75,10 +75,10 @@ async def try_fast_read(prompt: str, brief: str, settings, primary_api: str | No
|
||||
model=aux_model,
|
||||
max_tokens=500,
|
||||
temperature=0,
|
||||
system=_ANSWER_SYSTEM,
|
||||
system=P_ANSWER_SYSTEM,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": f"Request: {prompt}\n\nPage text from {entry}:\n{text[:_MAX_PAGE_CHARS]}",
|
||||
"content": f"Request: {prompt}\n\nPage text from {entry}:\n{text[:P_MAX_PAGE_CHARS]}",
|
||||
}],
|
||||
),
|
||||
timeout=15.0,
|
||||
|
||||
@@ -6,44 +6,44 @@ the same browser can resume rather than restart from scratch. Without this every
|
||||
"swipe right" / "swipe left" call has to take a new screenshot and re-orient
|
||||
itself, costing 30-60s per action.
|
||||
|
||||
The `_browser_history` mutable cache lives in EXACTLY this module; all reads and
|
||||
The `BROWSER_HISTORY` mutable cache lives in EXACTLY this module; all reads and
|
||||
writes route through here so there's a single source of truth.
|
||||
"""
|
||||
|
||||
# browser_id -> cached Anthropic message list for resume.
|
||||
_browser_history: dict[str, list[dict]] = {}
|
||||
BROWSER_HISTORY: dict[str, list[dict]] = {}
|
||||
# Cap history to prevent unbounded growth on long-lived browsers.
|
||||
_MAX_HISTORY_MESSAGES = 30
|
||||
MAX_HISTORY_MESSAGES = 30
|
||||
|
||||
# Per-apex-domain advisory notes, distilled from the agent's own ReportProgress
|
||||
# working_memory. Process-lifetime only (never written to disk); seeds a later
|
||||
# agent on the same domain so it skips re-learning the same quirks. Advisory
|
||||
# text only, never auto-executed.
|
||||
_domain_notes: dict[str, str] = {}
|
||||
_MAX_DOMAIN_NOTE_CHARS = 600
|
||||
DOMAIN_NOTES: dict[str, str] = {}
|
||||
MAX_DOMAIN_NOTE_CHARS = 600
|
||||
|
||||
|
||||
def get_domain_note(domain: str) -> str:
|
||||
"""Return the advisory note for a domain, or empty string if none."""
|
||||
return _domain_notes.get(domain, "")
|
||||
return DOMAIN_NOTES.get(domain, "")
|
||||
|
||||
|
||||
def set_domain_note(domain: str, note: str) -> None:
|
||||
"""Store/overwrite the advisory note for a domain (trimmed + capped)."""
|
||||
if not domain or not note or not note.strip():
|
||||
return
|
||||
_domain_notes[domain] = note.strip()[:_MAX_DOMAIN_NOTE_CHARS]
|
||||
DOMAIN_NOTES[domain] = note.strip()[:MAX_DOMAIN_NOTE_CHARS]
|
||||
|
||||
|
||||
def clear_browser_history(browser_id: str) -> None:
|
||||
"""Drop cached conversation history for a browser (e.g. when it's closed)."""
|
||||
_browser_history.pop(browser_id, None)
|
||||
BROWSER_HISTORY.pop(browser_id, None)
|
||||
|
||||
|
||||
_OMITTED_SCREENSHOT_STUB = "[earlier screenshot omitted to save context]"
|
||||
OMITTED_SCREENSHOT_STUB = "[earlier screenshot omitted to save context]"
|
||||
|
||||
|
||||
def _iter_image_block_refs(messages: list[dict]):
|
||||
def p_iter_image_block_refs(messages: list[dict]):
|
||||
"""Yield (container_list, index) for every image block, in document order.
|
||||
|
||||
Screenshots live either directly in a message's content list or nested inside
|
||||
@@ -76,7 +76,7 @@ def prune_old_screenshots(messages: list[dict], keep_first: bool = True, keep_re
|
||||
are dropped, not the memory. If the agent must re-see, it just re-screenshots.
|
||||
Returns how many images were collapsed.
|
||||
"""
|
||||
refs = list(_iter_image_block_refs(messages))
|
||||
refs = list(p_iter_image_block_refs(messages))
|
||||
keep_count = keep_recent + (1 if keep_first else 0)
|
||||
if len(refs) <= keep_count:
|
||||
return 0
|
||||
@@ -89,7 +89,7 @@ def prune_old_screenshots(messages: list[dict], keep_first: bool = True, keep_re
|
||||
for idx, (container, i) in enumerate(refs):
|
||||
if idx in keep:
|
||||
continue
|
||||
container[i] = {"type": "text", "text": _OMITTED_SCREENSHOT_STUB}
|
||||
container[i] = {"type": "text", "text": OMITTED_SCREENSHOT_STUB}
|
||||
collapsed += 1
|
||||
return collapsed
|
||||
|
||||
@@ -97,9 +97,9 @@ def prune_old_screenshots(messages: list[dict], keep_first: bool = True, keep_re
|
||||
# Sentinel prefixing the auto-attached element list on mutating action results.
|
||||
# Lives here so the attacher (browser_agent) and the pruner share one spelling.
|
||||
PAGE_STATE_MARKER = "[page state after action]"
|
||||
_STATE_STUB = "[stale page state pruned; see the latest action result for current state]"
|
||||
_HEAVY_READ_TOOLS = {"BrowserListInteractives", "BrowserGetText"}
|
||||
_HEAVY_READ_MIN_CHARS = 600
|
||||
P_STATE_STUB = "[stale page state pruned; see the latest action result for current state]"
|
||||
P_HEAVY_READ_TOOLS = {"BrowserListInteractives", "BrowserGetText"}
|
||||
P_HEAVY_READ_MIN_CHARS = 600
|
||||
|
||||
|
||||
def prune_stale_page_state(messages: list[dict], keep_recent: int = 2) -> int:
|
||||
@@ -136,12 +136,12 @@ def prune_stale_page_state(messages: list[dict], keep_recent: int = 2) -> int:
|
||||
txt = ib.get("text") or ""
|
||||
if PAGE_STATE_MARKER in txt:
|
||||
attached.append(ib)
|
||||
elif tool in _HEAVY_READ_TOOLS and len(txt) >= _HEAVY_READ_MIN_CHARS:
|
||||
elif tool in P_HEAVY_READ_TOOLS and len(txt) >= P_HEAVY_READ_MIN_CHARS:
|
||||
heavy.append(ib)
|
||||
pruned = 0
|
||||
for ib in attached[:-keep_recent] if keep_recent else attached:
|
||||
txt = ib["text"]
|
||||
ib["text"] = txt[: txt.index(PAGE_STATE_MARKER)] + _STATE_STUB
|
||||
ib["text"] = txt[: txt.index(PAGE_STATE_MARKER)] + P_STATE_STUB
|
||||
pruned += 1
|
||||
for ib in heavy[:-keep_recent] if keep_recent else heavy:
|
||||
head = (ib["text"] or "").splitlines()[0][:100]
|
||||
@@ -175,7 +175,7 @@ def place_cache_marker(messages: list[dict], depth: int = 8) -> None:
|
||||
return
|
||||
|
||||
|
||||
def _validate_message_pairing(messages: list[dict]) -> bool:
|
||||
def validate_message_pairing(messages: list[dict]) -> bool:
|
||||
"""Verify tool_use and tool_result blocks pair up BOTH ways, or the cached
|
||||
history 400s if sent to the API. Two failure shapes, both checked:
|
||||
- an orphan tool_result (references a tool_use_id that was never declared), and
|
||||
@@ -209,7 +209,7 @@ def _validate_message_pairing(messages: list[dict]) -> bool:
|
||||
return declared_tool_use_ids.issubset(answered_tool_use_ids)
|
||||
|
||||
|
||||
def _is_fresh_user_message(msg: dict) -> bool:
|
||||
def p_is_fresh_user_message(msg: dict) -> bool:
|
||||
"""A 'fresh' user message starts a new turn; string content or a list
|
||||
that contains no tool_result blocks. These are the only safe cut points
|
||||
because they don't reference any prior assistant tool_use blocks."""
|
||||
@@ -225,7 +225,7 @@ def _is_fresh_user_message(msg: dict) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _summarize_messages(messages: list[dict]) -> str:
|
||||
def p_summarize_messages(messages: list[dict]) -> str:
|
||||
"""Build a programmatic summary of older browser-agent messages.
|
||||
|
||||
Extracts the original user task, a count of tool calls by name with their
|
||||
@@ -312,7 +312,7 @@ def _summarize_messages(messages: list[dict]) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict]:
|
||||
def trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict]:
|
||||
"""Compact message history when it exceeds max_messages.
|
||||
|
||||
The Anthropic API requires every `tool_result` block to reference a
|
||||
@@ -341,7 +341,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
|
||||
# First pass: walk forward looking for the EARLIEST clean cut point that
|
||||
# gets us under the cap. This preserves the most recent detail.
|
||||
for i in range(1, len(messages)):
|
||||
if not _is_fresh_user_message(messages[i]):
|
||||
if not p_is_fresh_user_message(messages[i]):
|
||||
continue
|
||||
if len(messages) - i <= target_tail_size:
|
||||
cut_index = i
|
||||
@@ -353,7 +353,7 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
|
||||
# safe history we can produce; and any compaction is better than none.
|
||||
if cut_index is None:
|
||||
for i in range(len(messages) - 1, 0, -1):
|
||||
if _is_fresh_user_message(messages[i]):
|
||||
if p_is_fresh_user_message(messages[i]):
|
||||
cut_index = i
|
||||
break
|
||||
|
||||
@@ -364,6 +364,6 @@ def _trim_history_by_turns(messages: list[dict], max_messages: int) -> list[dict
|
||||
|
||||
# Compact: summarize messages[0..cut_index-1], prepend as a single
|
||||
# user-text message, then keep messages[cut_index..end] verbatim.
|
||||
summary_text = _summarize_messages(messages[:cut_index])
|
||||
summary_text = p_summarize_messages(messages[:cut_index])
|
||||
summary_msg = {"role": "user", "content": summary_text}
|
||||
return [summary_msg] + list(messages[cut_index:])
|
||||
|
||||
@@ -13,7 +13,7 @@ import re
|
||||
# Tools that are read-only / idempotent and should NOT count toward loop
|
||||
# detection. Repeating these is normal (scrolling through a feed, taking
|
||||
# successive screenshots, polling for an element to appear).
|
||||
_LOOP_DETECTION_EXCLUDED_TOOLS = {
|
||||
LOOP_DETECTION_EXCLUDED_TOOLS = {
|
||||
"BrowserScreenshot",
|
||||
"BrowserGetText",
|
||||
"BrowserGetConsole", # read-only diagnostic; reading it repeatedly is fine
|
||||
@@ -27,9 +27,9 @@ _LOOP_DETECTION_EXCLUDED_TOOLS = {
|
||||
"BrowserRepeatFlow", # batch: drives its own verified per-item loop
|
||||
}
|
||||
|
||||
_LOOP_WINDOW_SIZE = 5
|
||||
_LOOP_REPEAT_THRESHOLD = 2 # the SECOND identical (tool,input,result) is already a wall
|
||||
_LOOP_HARD_CAP = 5
|
||||
LOOP_WINDOW_SIZE = 5
|
||||
P_LOOP_REPEAT_THRESHOLD = 2 # the SECOND identical (tool,input,result) is already a wall
|
||||
LOOP_HARD_CAP = 5
|
||||
|
||||
|
||||
# Universal close-affordance vocabulary for blocking popups (cookie walls,
|
||||
@@ -39,13 +39,13 @@ _LOOP_HARD_CAP = 5
|
||||
# task required. Deliberately omits generic "Close"/"Dismiss"/"Skip", which DO
|
||||
# appear on needed dialogs (e.g. "Close your conversation"). Keys on the pattern,
|
||||
# not any one site, so it generalizes.
|
||||
_DISMISS_NAMES = frozenset({
|
||||
P_DISMISS_NAMES = frozenset({
|
||||
"no thanks", "no, thanks", "maybe later", "not now", "skip for now",
|
||||
"remind me later", "got it", "decline", "no, maybe later", "not interested",
|
||||
})
|
||||
# never dismiss anything that smells like security or a real decision
|
||||
_DANGER_NAME_RE = re.compile(r"verif|confirm|2fa|password|sign|pay|delete|send|post|submit", re.I)
|
||||
_ROW_RE = re.compile(r'<\s*([a-z]+)\s+"([^"]*)"', re.I) # matches a [i]<role "name"> row
|
||||
P_DANGER_NAME_RE = re.compile(r"verif|confirm|2fa|password|sign|pay|delete|send|post|submit", re.I)
|
||||
P_ROW_RE = re.compile(r'<\s*([a-z]+)\s+"([^"]*)"', re.I) # matches a [i]<role "name"> row
|
||||
|
||||
|
||||
def interstitial_dismiss_target(interactives_text: str) -> str | None:
|
||||
@@ -55,19 +55,19 @@ def interstitial_dismiss_target(interactives_text: str) -> str | None:
|
||||
never anything with security/confirm/commit wording, so a mechanical dismiss
|
||||
can never close a dialog the task actually required."""
|
||||
for line in (interactives_text or "").splitlines():
|
||||
m = _ROW_RE.search(line)
|
||||
m = P_ROW_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
role, name = m.group(1).lower(), m.group(2).strip()
|
||||
if role not in ("button", "link"):
|
||||
continue
|
||||
norm = re.sub(r"[^a-z, ]", "", name.lower()).strip()
|
||||
if norm in _DISMISS_NAMES and not _DANGER_NAME_RE.search(name):
|
||||
if norm in P_DISMISS_NAMES and not P_DANGER_NAME_RE.search(name):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _hash_tool_call(tool_name: str, tool_input: dict, result: dict) -> tuple[str, str, str]:
|
||||
def hash_tool_call(tool_name: str, tool_input: dict, result: dict) -> tuple[str, str, str]:
|
||||
"""Build a stable hash key for a tool call, including its result.
|
||||
|
||||
Including the result hash means that legitimate progress (same input,
|
||||
@@ -86,24 +86,24 @@ def _hash_tool_call(tool_name: str, tool_input: dict, result: dict) -> tuple[str
|
||||
return (tool_name, input_key, result_key)
|
||||
|
||||
|
||||
def _detect_loop(
|
||||
def detect_loop(
|
||||
recent_calls: list[tuple[str, str, str]],
|
||||
new_call: tuple[str, str, str],
|
||||
) -> bool:
|
||||
"""Return True if `new_call` constitutes a loop given recent history.
|
||||
|
||||
A loop is when the same (tool, input, result) has appeared at least
|
||||
`_LOOP_REPEAT_THRESHOLD` times within the last `_LOOP_WINDOW_SIZE`
|
||||
`P_LOOP_REPEAT_THRESHOLD` times within the last `LOOP_WINDOW_SIZE`
|
||||
state-mutating calls (the new call counts as one of those occurrences).
|
||||
"""
|
||||
if new_call[0] in _LOOP_DETECTION_EXCLUDED_TOOLS:
|
||||
if new_call[0] in LOOP_DETECTION_EXCLUDED_TOOLS:
|
||||
return False
|
||||
window = recent_calls[-(_LOOP_WINDOW_SIZE - 1):] + [new_call]
|
||||
window = recent_calls[-(LOOP_WINDOW_SIZE - 1):] + [new_call]
|
||||
matches = sum(1 for c in window if c == new_call)
|
||||
return matches >= _LOOP_REPEAT_THRESHOLD
|
||||
return matches >= P_LOOP_REPEAT_THRESHOLD
|
||||
|
||||
|
||||
_LOOP_WARNING_TEXT = (
|
||||
LOOP_WARNING_TEXT = (
|
||||
"LOOP DETECTED: the same action got the same result {count} times, so repeating "
|
||||
"it will NOT help. Diagnose the REAL cause before anything else, do not assume: "
|
||||
"read the exact error in the result; call BrowserGetConsole to see the page's own "
|
||||
@@ -128,20 +128,20 @@ _LOOP_WARNING_TEXT = (
|
||||
|
||||
# Read-only / meta tools don't count toward stagnation (same exemption set as
|
||||
# the loop detector): re-orienting is not "being stuck".
|
||||
_STAGNATION_NEUTRAL_TOOLS = _LOOP_DETECTION_EXCLUDED_TOOLS
|
||||
_STAGNATION_ESCALATION_AT = 3
|
||||
_STAGNATION_MAX = 5
|
||||
P_STAGNATION_NEUTRAL_TOOLS = LOOP_DETECTION_EXCLUDED_TOOLS
|
||||
STAGNATION_ESCALATION_AT = 3
|
||||
STAGNATION_MAX = 5
|
||||
|
||||
_FAILURE_MARKERS = (
|
||||
P_FAILURE_MARKERS = (
|
||||
"error", "not found", "no longer valid", "no box model",
|
||||
"no valid bounding rect", "failed", "rejected", "timed out",
|
||||
"could not", "unable to", "denied",
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_failure(text: str) -> bool:
|
||||
def looks_like_failure(text: str) -> bool:
|
||||
low = text.lower()
|
||||
return any(m in low for m in _FAILURE_MARKERS)
|
||||
return any(m in low for m in P_FAILURE_MARKERS)
|
||||
|
||||
|
||||
def is_unproductive(
|
||||
@@ -156,7 +156,7 @@ def is_unproductive(
|
||||
action, all with no URL change. Neutral tools (screenshot, get_text, etc.)
|
||||
never count.
|
||||
"""
|
||||
if tool_name in _STAGNATION_NEUTRAL_TOOLS:
|
||||
if tool_name in P_STAGNATION_NEUTRAL_TOOLS:
|
||||
return False
|
||||
new_url = str(result.get("url") or "")
|
||||
if new_url and prev_url and new_url != prev_url:
|
||||
@@ -164,14 +164,14 @@ def is_unproductive(
|
||||
if "error" in result:
|
||||
return True
|
||||
text = str(result.get("text") or result.get("error") or "")
|
||||
if _looks_like_failure(text):
|
||||
if looks_like_failure(text):
|
||||
return True
|
||||
if prev_text and text[:200] == prev_text[:200]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_STAGNATION_NUDGE = (
|
||||
P_STAGNATION_NUDGE = (
|
||||
"NO PROGRESS: your last {streak} actions changed nothing and looked like "
|
||||
"failures. Before trying yet another variation, find out WHY: read the exact "
|
||||
"errors; call BrowserGetConsole for the page's own JS/network errors; use "
|
||||
@@ -185,8 +185,8 @@ _STAGNATION_NUDGE = (
|
||||
|
||||
|
||||
def stagnation_nudge(streak: int) -> str:
|
||||
base = _STAGNATION_NUDGE.format(streak=streak)
|
||||
if streak >= _STAGNATION_MAX:
|
||||
base = P_STAGNATION_NUDGE.format(streak=streak)
|
||||
if streak >= STAGNATION_MAX:
|
||||
base += (
|
||||
" Switching selectors hasn't worked, so the PLAN itself is likely "
|
||||
"wrong: step back and revise your overall approach (a different page, "
|
||||
@@ -207,7 +207,7 @@ def advance_stagnation(
|
||||
return a nudge string when the streak crosses an escalation threshold.
|
||||
Returns (new_streak, new_prev_url, new_prev_text, nudge_or_None).
|
||||
"""
|
||||
if tool_name in _STAGNATION_NEUTRAL_TOOLS:
|
||||
if tool_name in P_STAGNATION_NEUTRAL_TOOLS:
|
||||
return streak, prev_url, prev_text, None
|
||||
if is_unproductive(tool_name, result, prev_url, prev_text):
|
||||
streak += 1
|
||||
@@ -217,7 +217,7 @@ def advance_stagnation(
|
||||
new_text = str(result.get("text") or result.get("error") or "")[:200]
|
||||
nudge = (
|
||||
stagnation_nudge(streak)
|
||||
if streak in (_STAGNATION_ESCALATION_AT, _STAGNATION_MAX)
|
||||
if streak in (STAGNATION_ESCALATION_AT, STAGNATION_MAX)
|
||||
else None
|
||||
)
|
||||
return streak, new_url, new_text, nudge
|
||||
@@ -226,7 +226,7 @@ def advance_stagnation(
|
||||
def stagnation_exhausted(streak: int) -> bool:
|
||||
"""True once deterministic nudging has been exhausted; the caller may then
|
||||
escalate to a one-shot aux-LLM adjudication (see browser_validator)."""
|
||||
return streak >= _STAGNATION_MAX
|
||||
return streak >= STAGNATION_MAX
|
||||
|
||||
|
||||
# --- completion honesty gate ----------------------------------------------
|
||||
@@ -236,12 +236,12 @@ def stagnation_exhausted(streak: int) -> bool:
|
||||
# status say "done", so a fake success is reported as the failure it actually is.
|
||||
|
||||
# State-changing tools: a task that needed to DO something must land one of these.
|
||||
_PRODUCTIVE_TOOLS = {
|
||||
P_PRODUCTIVE_TOOLS = {
|
||||
"BrowserClick", "BrowserClickIndex", "BrowserType", "BrowserNavigate",
|
||||
"BrowserPressKey", "BrowserScroll", "BrowserBatch",
|
||||
}
|
||||
# Read/extract tools: a look-only task's evidence is that a read returned content.
|
||||
_READ_TOOLS = {
|
||||
P_READ_TOOLS = {
|
||||
"BrowserGetText", "BrowserGetElements", "BrowserListInteractives",
|
||||
"BrowserListRoutes", "BrowserReplayRoute", "BrowserScreenshot", "BrowserEvaluate",
|
||||
}
|
||||
@@ -253,18 +253,18 @@ _READ_TOOLS = {
|
||||
# 20-minute LinkedIn spin), so we fail fast. The streak (reset on any good result)
|
||||
# absorbs a one-off transient; only a SUSTAINED pattern trips it, so a merely-busy
|
||||
# page that recovers is never mistaken for dead.
|
||||
_CARD_GONE_MARKERS = (
|
||||
P_CARD_GONE_MARKERS = (
|
||||
"not an electron webview", # card closed / destroyed
|
||||
"no dashboard is connected", # dashboard view not mounted
|
||||
"command timed out", # hung: the command never came back
|
||||
"page unresponsive", # hung: smart-wait gave up probing the tab
|
||||
)
|
||||
_CARD_GONE_LIMIT = 2 # consecutive misses before we give up (absorbs a transient)
|
||||
CARD_GONE_LIMIT = 2 # consecutive misses before we give up (absorbs a transient)
|
||||
|
||||
|
||||
def card_is_unavailable(result: dict) -> bool:
|
||||
err = str(result.get("error") or "").lower()
|
||||
return any(m in err for m in _CARD_GONE_MARKERS)
|
||||
return any(m in err for m in P_CARD_GONE_MARKERS)
|
||||
|
||||
|
||||
# Errors where the action MISSED but the page is alive (stale index after a
|
||||
@@ -272,7 +272,7 @@ def card_is_unavailable(result: dict) -> bool:
|
||||
# itself is fine, so re-attaching the CURRENT element list to the error lets the
|
||||
# model re-act next turn instead of burning a turn re-listing. This NEVER retries
|
||||
# the action (no double-send risk); it only enriches the error with fresh state.
|
||||
_RECOVERABLE_ERR_MARKERS = (
|
||||
P_RECOVERABLE_ERR_MARKERS = (
|
||||
"no longer valid", "no node with given id", "page may have changed",
|
||||
"covered it", "obscured", "intercepted", "not clickable",
|
||||
"box model", "try scrolling", "not visible",
|
||||
@@ -283,15 +283,15 @@ def recoverable_tool_error(err: str) -> bool:
|
||||
"""True for a 'the action missed but the page is alive' error worth showing
|
||||
fresh state for. False for a dead card (handled separately) or no error."""
|
||||
e = (err or "").lower()
|
||||
if not e or any(m in e for m in _CARD_GONE_MARKERS):
|
||||
if not e or any(m in e for m in P_CARD_GONE_MARKERS):
|
||||
return False
|
||||
return any(m in e for m in _RECOVERABLE_ERR_MARKERS)
|
||||
return any(m in e for m in P_RECOVERABLE_ERR_MARKERS)
|
||||
|
||||
|
||||
# Actions that DIRTY the page so replay-from-here is no longer equivalent to a
|
||||
# clean dispatch. Navigation and reads don't dirty anything (they just get us to
|
||||
# the page), so the deferred replay re-check is allowed after only those.
|
||||
_REPLAY_DIRTYING_TOOLS = {
|
||||
P_REPLAY_DIRTYING_TOOLS = {
|
||||
"BrowserType", "BrowserClick", "BrowserClickIndex",
|
||||
"BrowserPressKey", "BrowserScroll", "BrowserBatch",
|
||||
}
|
||||
@@ -301,18 +301,18 @@ def replay_recheck_is_safe(action_log: list[dict]) -> bool:
|
||||
"""True if nothing in the run so far has mutated page state, so switching to
|
||||
a learned-skill replay now is equivalent to replaying from a clean dispatch
|
||||
(the agent only navigated / looked around to get to the right host)."""
|
||||
return not any(a.get("tool") in _REPLAY_DIRTYING_TOOLS for a in action_log)
|
||||
return not any(a.get("tool") in P_REPLAY_DIRTYING_TOOLS for a in action_log)
|
||||
|
||||
|
||||
# What the user ASKED FOR outranks how the sub narrated it: an info ask can
|
||||
# never replay (the answer must be fresh), an action ask can.
|
||||
_INFO_ASK_RE = re.compile(
|
||||
P_INFO_ASK_RE = re.compile(
|
||||
r"\b(tell me|what(?:'s| is| are)|how (?:many|much)|count|list|summari[sz]e|"
|
||||
r"extract|find (?:me|out)|show me|look up|read (?:me|the)|get the|give me|which|"
|
||||
r"who (?:is|are)|report back|most (?:viewed|popular|liked|recent|rated|watched)|top \d+)\b",
|
||||
re.I,
|
||||
)
|
||||
_ACTION_ASK_RE = re.compile(
|
||||
P_ACTION_ASK_RE = re.compile(
|
||||
r"\b(open|go to|navigate|click|send|post|submit|fill|type|search for|log ?in|"
|
||||
r"sign ?in|upload|download|book|order|buy|add|create|delete|message|dm|text)\b",
|
||||
re.I,
|
||||
@@ -333,9 +333,9 @@ def deliverable_is_informational(summary: str, task: str = "") -> bool:
|
||||
(re-run via the LLM), never a ghost completion."""
|
||||
t = (task or "").strip()
|
||||
if t:
|
||||
if _INFO_ASK_RE.search(t):
|
||||
if P_INFO_ASK_RE.search(t):
|
||||
return True
|
||||
if _ACTION_ASK_RE.search(t):
|
||||
if P_ACTION_ASK_RE.search(t):
|
||||
return False
|
||||
s = (summary or "").strip()
|
||||
s = re.sub(r"OUTCOME:.*$", "", s, flags=re.S).strip()
|
||||
@@ -358,11 +358,11 @@ def completion_is_honest(action_log: list[dict]) -> tuple[bool, str]:
|
||||
"""
|
||||
if not action_log:
|
||||
return False, "declared done without taking a single action"
|
||||
actions = [a for a in action_log if a.get("tool") in _PRODUCTIVE_TOOLS]
|
||||
actions = [a for a in action_log if a.get("tool") in P_PRODUCTIVE_TOOLS]
|
||||
actions_ok = [a for a in actions if a.get("ok")]
|
||||
reads_ok = [
|
||||
a for a in action_log
|
||||
if a.get("tool") in _READ_TOOLS and a.get("ok")
|
||||
if a.get("tool") in P_READ_TOOLS and a.get("ok")
|
||||
and str(a.get("result_summary") or "").strip()
|
||||
]
|
||||
if actions and not actions_ok:
|
||||
|
||||
@@ -21,18 +21,18 @@ import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from backend.apps.agents.browser.browser_playbook import _clean_bullet
|
||||
from backend.apps.agents.browser.browser_playbook import clean_bullet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_VERSION = 1
|
||||
_MAX_BULLETS = 10 # a touch larger than per-site: these earn their keep everywhere
|
||||
_FILE = "meta_playbook.json"
|
||||
P_VERSION = 1
|
||||
MAX_BULLETS = 10 # a touch larger than per-site: these earn their keep everywhere
|
||||
P_FILE = "meta_playbook.json"
|
||||
|
||||
_cache: list[str] | None = None
|
||||
CACHE: list[str] | None = None
|
||||
|
||||
|
||||
def _dir() -> str | None:
|
||||
def p_dir() -> str | None:
|
||||
base = os.environ.get("OPENSWARM_BROWSER_META_DIR")
|
||||
if not base:
|
||||
try:
|
||||
@@ -47,34 +47,34 @@ def _dir() -> str | None:
|
||||
return base
|
||||
|
||||
|
||||
def _path() -> str | None:
|
||||
d = _dir()
|
||||
return os.path.join(d, _FILE) if d else None
|
||||
def p_path() -> str | None:
|
||||
d = p_dir()
|
||||
return os.path.join(d, P_FILE) if d else None
|
||||
|
||||
|
||||
def _load() -> list[str]:
|
||||
path = _path()
|
||||
def load() -> list[str]:
|
||||
path = p_path()
|
||||
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") != _VERSION:
|
||||
if data.get("version") != P_VERSION:
|
||||
return []
|
||||
return [b for b in (data.get("bullets") or []) if isinstance(b, str)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _persist(bullets: list[str]) -> None:
|
||||
path = _path()
|
||||
def persist(bullets: list[str]) -> None:
|
||||
path = p_path()
|
||||
if not path:
|
||||
return
|
||||
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({"version": _VERSION, "bullets": bullets, "updated_at": time.time()}, f)
|
||||
json.dump({"version": P_VERSION, "bullets": bullets, "updated_at": time.time()}, f)
|
||||
os.replace(tmp, path) # atomic
|
||||
except Exception as e:
|
||||
logger.debug(f"[browser-meta] persist failed: {e}")
|
||||
@@ -82,10 +82,10 @@ def _persist(bullets: list[str]) -> None:
|
||||
|
||||
def get_meta() -> list[str]:
|
||||
"""The cross-site bullets (cheap, no LLM). Cached after first read."""
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = _load() or list(_SEED)
|
||||
return _cache
|
||||
global CACHE
|
||||
if CACHE is None:
|
||||
CACHE = load() or list(P_SEED)
|
||||
return CACHE
|
||||
|
||||
|
||||
def format_for_prompt() -> str:
|
||||
@@ -94,7 +94,7 @@ def format_for_prompt() -> str:
|
||||
bullets = get_meta()
|
||||
if not bullets:
|
||||
return ""
|
||||
lines = "\n".join(f"- {b}" for b in bullets[:_MAX_BULLETS])
|
||||
lines = "\n".join(f"- {b}" for b in bullets[:MAX_BULLETS])
|
||||
return (
|
||||
"\n\n## General web priors (learned across many sites, verify against THIS page)\n"
|
||||
+ lines
|
||||
@@ -113,26 +113,26 @@ def absorb(universal_bullets: list[str]) -> bool:
|
||||
truly_new: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for b in universal_bullets:
|
||||
cb = _clean_bullet(b)
|
||||
cb = clean_bullet(b)
|
||||
if cb and cb.lower() not in existing_lower and cb.lower() not in seen:
|
||||
seen.add(cb.lower())
|
||||
truly_new.append(cb)
|
||||
if not truly_new:
|
||||
return False
|
||||
merged = (truly_new + existing)[:_MAX_BULLETS]
|
||||
global _cache
|
||||
_cache = merged
|
||||
_persist(merged)
|
||||
merged = (truly_new + existing)[:MAX_BULLETS]
|
||||
global CACHE
|
||||
CACHE = merged
|
||||
persist(merged)
|
||||
logger.info(f"[browser-meta] {len(merged)} cross-site prior(s) (was {len(existing)})")
|
||||
return True
|
||||
|
||||
|
||||
def clear(wipe_disk: bool = False) -> None:
|
||||
"""Test/maintenance reset of the in-memory cache (and optionally disk)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
global CACHE
|
||||
CACHE = None
|
||||
if wipe_disk:
|
||||
path = _path()
|
||||
path = p_path()
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -142,7 +142,7 @@ def clear(wipe_disk: bool = False) -> None:
|
||||
|
||||
# Shipped starting priors: the hard-won universal lessons from this codebase's own
|
||||
# browser work, so tier 3 is useful on day one and accrues more as sites confirm them.
|
||||
_SEED = (
|
||||
P_SEED = (
|
||||
"A message composer CLEARS when the send goes through; the empty box IS your "
|
||||
"confirmation, do not hunt the thread for the sent text to 'verify'.",
|
||||
"An opener (Message/DM/Compose) only OPENS the box and is reversible; only the "
|
||||
|
||||
@@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Map each tool to the waterfall tier it represents, so per-tier speed/cost
|
||||
# rolls up cleanly. Control/meta tools are their own bucket.
|
||||
_TIER = {
|
||||
P_TIER = {
|
||||
"BrowserDetectWebMCP": "t1_webmcp",
|
||||
"BrowserListRoutes": "t2_route_list",
|
||||
"BrowserReplayRoute": "t2_route_replay",
|
||||
@@ -54,17 +54,17 @@ _TIER = {
|
||||
|
||||
|
||||
def tier_for(tool_name: str) -> str:
|
||||
return _TIER.get(tool_name, "other")
|
||||
return P_TIER.get(tool_name, "other")
|
||||
|
||||
|
||||
_metrics_dir_cache: str | None = None
|
||||
p_metrics_dir_cache: str | None = None
|
||||
|
||||
|
||||
def _metrics_dir() -> str:
|
||||
def metrics_dir() -> str:
|
||||
# Resolved + mkdir'd once, not on every tool call (this runs in the hot path).
|
||||
global _metrics_dir_cache
|
||||
if _metrics_dir_cache is not None:
|
||||
return _metrics_dir_cache
|
||||
global p_metrics_dir_cache
|
||||
if p_metrics_dir_cache is not None:
|
||||
return p_metrics_dir_cache
|
||||
override = os.environ.get("OPENSWARM_BROWSER_METRICS_DIR")
|
||||
if override:
|
||||
base = override
|
||||
@@ -79,13 +79,13 @@ def _metrics_dir() -> str:
|
||||
os.makedirs(base, mode=0o700, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
_metrics_dir_cache = base
|
||||
p_metrics_dir_cache = base
|
||||
return base
|
||||
|
||||
|
||||
def _append(filename: str, obj: dict) -> None:
|
||||
def p_append(filename: str, obj: dict) -> None:
|
||||
try:
|
||||
path = os.path.join(_metrics_dir(), filename)
|
||||
path = os.path.join(metrics_dir(), filename)
|
||||
# owner-only: these lines can carry task text and error snippets
|
||||
fd = os.open(path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
||||
@@ -97,22 +97,22 @@ def _append(filename: str, obj: dict) -> None:
|
||||
# A task prompt can carry a literal secret ("log in with password hunter2");
|
||||
# scrub the value before it lands in tasks.jsonl. Keyword+value and known
|
||||
# token prefixes only; the task's normal words stay greppable.
|
||||
_TASK_SECRET_RE = re.compile(
|
||||
P_TASK_SECRET_RE = re.compile(
|
||||
r"\b(password|passcode|passphrase|pin|otp|token|secret|api[_-]?key)\b\s*(?:is|[:=])?\s*\S+",
|
||||
re.I,
|
||||
)
|
||||
_TASK_TOKEN_RE = re.compile(r"\b(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ)[A-Za-z0-9_\-.]{8,}")
|
||||
P_TASK_TOKEN_RE = re.compile(r"\b(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ)[A-Za-z0-9_\-.]{8,}")
|
||||
|
||||
|
||||
def _scrub_task(task: str) -> str:
|
||||
t = _TASK_SECRET_RE.sub(lambda m: f"{m.group(1)} [redacted]", task or "")
|
||||
return _TASK_TOKEN_RE.sub("[redacted]", t)
|
||||
def p_scrub_task(task: str) -> str:
|
||||
t = P_TASK_SECRET_RE.sub(lambda m: f"{m.group(1)} [redacted]", task or "")
|
||||
return P_TASK_TOKEN_RE.sub("[redacted]", t)
|
||||
|
||||
|
||||
def record_tool(session_id, browser_id, turn, tool, elapsed_ms, ok, error,
|
||||
is_loop, stagnation_streak, result_len) -> None:
|
||||
"""One line per executed tool call. Best-effort."""
|
||||
_append("events.jsonl", {
|
||||
p_append("events.jsonl", {
|
||||
"ts": time.time(),
|
||||
"session_id": session_id,
|
||||
"browser_id": browser_id,
|
||||
@@ -140,7 +140,7 @@ def record_skill_event(kind, host, task_sig, rev=0, state="", extra=None) -> Non
|
||||
learn / edit / promote / quarantine / demote / compose / invalidate. This is
|
||||
what lets the analyzer prove the skill layer is helping (promotes accumulate,
|
||||
repeats replay) vs. silently thrashing (re-learn loops, never promotes)."""
|
||||
_append("skill_events.jsonl", {
|
||||
p_append("skill_events.jsonl", {
|
||||
"ts": time.time(), "kind": kind, "host": host, "task_sig": task_sig,
|
||||
"rev": rev, "state": state, "extra": extra or {},
|
||||
})
|
||||
@@ -172,7 +172,7 @@ def record_task(session_id, browser_id, task, status, started_at, turns,
|
||||
"ts": time.time(),
|
||||
"session_id": session_id,
|
||||
"browser_id": browser_id,
|
||||
"task": _scrub_task(task)[:200],
|
||||
"task": p_scrub_task(task)[:200],
|
||||
"task_sig": task_sig,
|
||||
"path": path,
|
||||
"playbook_seeded": bool(playbook_seeded),
|
||||
@@ -186,27 +186,27 @@ def record_task(session_id, browser_id, task, status, started_at, turns,
|
||||
"by_tier": by_tier,
|
||||
"recurring_errors": err_counter.most_common(5),
|
||||
}
|
||||
_append("tasks.jsonl", summary)
|
||||
p_append("tasks.jsonl", summary)
|
||||
logger.info(
|
||||
f"[browser-metrics] TASK {status} path={path} total={total_ms}ms turns={turns} "
|
||||
f"tools={len(action_log)} tok_in={summary['tokens_in']} tok_out={summary['tokens_out']} "
|
||||
f"recurring_errs={summary['recurring_errors'][:2]}"
|
||||
)
|
||||
_maybe_self_audit()
|
||||
p_maybe_self_audit()
|
||||
return summary
|
||||
|
||||
|
||||
_AUDIT_EVERY_N = 25 # refresh the learning self-audit roughly this often
|
||||
_task_count = 0
|
||||
P_AUDIT_EVERY_N = 25 # refresh the learning self-audit roughly this often
|
||||
p_task_count = 0
|
||||
|
||||
|
||||
def _maybe_self_audit() -> None:
|
||||
def p_maybe_self_audit() -> None:
|
||||
"""Every N finished tasks, refresh the self-audit report in a daemon thread so
|
||||
it never adds latency to a run (the audit is ~3ms but stays off the hot path).
|
||||
Proposal-only: it writes a report a human reads, it changes nothing."""
|
||||
global _task_count
|
||||
_task_count += 1
|
||||
if _task_count % _AUDIT_EVERY_N != 0:
|
||||
global p_task_count
|
||||
p_task_count += 1
|
||||
if p_task_count % P_AUDIT_EVERY_N != 0:
|
||||
return
|
||||
|
||||
def _run():
|
||||
|
||||
@@ -38,18 +38,18 @@ from backend.apps.agents.browser.seed_playbooks import seed_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLAYBOOK_FORMAT_VERSION = 1
|
||||
_MAX_BULLETS = 8 # cap per site; reconcile keeps the most useful
|
||||
_MAX_BULLET_CHARS = 160
|
||||
_MAX_DISK_PLAYBOOKS = 500
|
||||
_MIN_TURNS_TO_LEARN = 4 # a 1-3 turn run taught nothing worth a durable bullet
|
||||
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]] = {}
|
||||
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.
|
||||
_SECRET_RE = re.compile(
|
||||
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
|
||||
@@ -64,19 +64,19 @@ def host_of(url: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _has_secret(text: str) -> bool:
|
||||
return bool(_SECRET_RE.search(text or ""))
|
||||
def p_has_secret(text: str) -> bool:
|
||||
return bool(P_SECRET_RE.search(text or ""))
|
||||
|
||||
|
||||
def _clean_bullet(b: str) -> str | None:
|
||||
def clean_bullet(b: str) -> str | None:
|
||||
b = re.sub(r"\s+", " ", str(b or "")).strip().lstrip("-*• ").strip()
|
||||
if not b or _has_secret(b):
|
||||
if not b or p_has_secret(b):
|
||||
return None
|
||||
return b[:_MAX_BULLET_CHARS]
|
||||
return b[:P_MAX_BULLET_CHARS]
|
||||
|
||||
|
||||
# --- persistence (mirrors browser_skills, separate dir) -------------------
|
||||
def _dir() -> str | None:
|
||||
def p_dir() -> str | None:
|
||||
base = os.environ.get("OPENSWARM_BROWSER_PLAYBOOK_DIR")
|
||||
if not base:
|
||||
try:
|
||||
@@ -91,20 +91,20 @@ def _dir() -> str | None:
|
||||
return base
|
||||
|
||||
|
||||
def _path(host: str) -> str | None:
|
||||
def p_path(host: str) -> str | None:
|
||||
import hashlib
|
||||
d = _dir()
|
||||
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 = _path(host)
|
||||
def persist(host: str, bullets: list[str]) -> None:
|
||||
path = p_path(host)
|
||||
if not path:
|
||||
return
|
||||
payload = {"version": _PLAYBOOK_FORMAT_VERSION, "host": host,
|
||||
payload = {"version": P_PLAYBOOK_FORMAT_VERSION, "host": host,
|
||||
"bullets": bullets, "updated_at": time.time()}
|
||||
try:
|
||||
d = os.path.dirname(path)
|
||||
@@ -112,18 +112,18 @@ def _persist(host: str, bullets: list[str]) -> None:
|
||||
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
|
||||
_evict_if_over_cap(d)
|
||||
p_evict_if_over_cap(d)
|
||||
except Exception as e:
|
||||
logger.debug(f"[browser-playbook] persist failed: {e}")
|
||||
|
||||
|
||||
def _evict_if_over_cap(d: str) -> None:
|
||||
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) <= _MAX_DISK_PLAYBOOKS:
|
||||
if len(files) <= P_MAX_DISK_PLAYBOOKS:
|
||||
return
|
||||
files.sort(key=lambda p: os.path.getmtime(p))
|
||||
for p in files[: len(files) - _MAX_DISK_PLAYBOOKS]:
|
||||
for p in files[: len(files) - P_MAX_DISK_PLAYBOOKS]:
|
||||
try:
|
||||
os.remove(p)
|
||||
except Exception:
|
||||
@@ -132,14 +132,14 @@ def _evict_if_over_cap(d: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _load(host: str) -> list[str]:
|
||||
path = _path(host)
|
||||
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") != _PLAYBOOK_FORMAT_VERSION:
|
||||
if data.get("version") != P_PLAYBOOK_FORMAT_VERSION:
|
||||
return []
|
||||
return [b for b in (data.get("bullets") or []) if isinstance(b, str)]
|
||||
except Exception:
|
||||
@@ -155,10 +155,10 @@ def get_playbook(host: str) -> list[str]:
|
||||
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
|
||||
if host in CACHE:
|
||||
return CACHE[host]
|
||||
bullets = load(host) or seed_for(host)
|
||||
CACHE[host] = bullets
|
||||
return bullets
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ def format_for_prompt(host: str) -> str:
|
||||
bullets = get_playbook(host)
|
||||
if not bullets:
|
||||
return ""
|
||||
lines = "\n".join(f"- {b}" for b in bullets[:_MAX_BULLETS])
|
||||
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 "
|
||||
@@ -175,19 +175,19 @@ def format_for_prompt(host: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _store(host: str, bullets: list[str]) -> list[str]:
|
||||
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)
|
||||
cb = clean_bullet(b)
|
||||
if cb and cb.lower() not in seen:
|
||||
seen.add(cb.lower())
|
||||
cleaned.append(cb)
|
||||
if len(cleaned) >= _MAX_BULLETS:
|
||||
if len(cleaned) >= MAX_BULLETS:
|
||||
break
|
||||
_cache[host] = cleaned
|
||||
_persist(host, cleaned)
|
||||
CACHE[host] = cleaned
|
||||
persist(host, cleaned)
|
||||
return cleaned
|
||||
|
||||
|
||||
@@ -195,10 +195,10 @@ def _store(host: str, bullets: list[str]) -> list[str]:
|
||||
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 >= _MIN_TURNS_TO_LEARN
|
||||
return bool(honest) and turns >= P_MIN_TURNS_TO_LEARN
|
||||
|
||||
|
||||
def _build_prompt(host: str, task: str, working_memory: str, summary: str,
|
||||
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 (
|
||||
@@ -214,7 +214,7 @@ def _build_prompt(host: str, task: str, working_memory: str, summary: str,
|
||||
"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 {_MAX_BULLET_CHARS} chars, "
|
||||
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"
|
||||
@@ -234,7 +234,7 @@ def _build_prompt(host: str, task: str, working_memory: str, summary: str,
|
||||
)
|
||||
|
||||
|
||||
def _parse(text: str) -> list[str] | None:
|
||||
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
|
||||
@@ -251,7 +251,7 @@ def _parse(text: str) -> list[str] | None:
|
||||
return [str(x) for x in pb if isinstance(x, (str, int, float))]
|
||||
|
||||
|
||||
def _parse_universal(text: str) -> list[str]:
|
||||
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:
|
||||
@@ -279,22 +279,22 @@ async def distill_and_store(host, task, working_memory, summary,
|
||||
if not host or not aux_client or not aux_model:
|
||||
return False
|
||||
existing = get_playbook(host)
|
||||
prompt = _build_prompt(host, task or "", working_memory or "", summary or "", existing)
|
||||
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 = _parse(text)
|
||||
new_bullets = p_parse(text)
|
||||
if new_bullets is None:
|
||||
return False
|
||||
stored = _store(host, new_bullets)
|
||||
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(_parse_universal(text))
|
||||
browser_meta_playbook.absorb(p_parse_universal(text))
|
||||
except Exception:
|
||||
pass
|
||||
if changed:
|
||||
@@ -310,7 +310,7 @@ async def distill_and_store(host, task, working_memory, summary,
|
||||
def list_hosts() -> list[dict]:
|
||||
"""Every site we have a playbook for, for a 'what has it learned' view."""
|
||||
out = []
|
||||
d = _dir()
|
||||
d = p_dir()
|
||||
if d:
|
||||
try:
|
||||
for f in os.listdir(d):
|
||||
@@ -333,8 +333,8 @@ 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 = _path(host)
|
||||
CACHE.pop(host, None)
|
||||
path = p_path(host)
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -347,9 +347,9 @@ def forget(host: str) -> bool:
|
||||
|
||||
def clear(wipe_disk: bool = False) -> None:
|
||||
"""Clear the in-memory cache (tests). With wipe_disk, also remove files."""
|
||||
_cache.clear()
|
||||
CACHE.clear()
|
||||
if wipe_disk:
|
||||
d = _dir()
|
||||
d = p_dir()
|
||||
if d:
|
||||
try:
|
||||
for f in os.listdir(d):
|
||||
|
||||
@@ -10,15 +10,15 @@ workspace subdir. Returns a short receipt for the model, never the data itself.
|
||||
import json
|
||||
import os
|
||||
|
||||
_MAX_BYTES = 25 * 1024 * 1024 # a page can't realistically hold more scraped data
|
||||
_ALLOWED_EXT = {".json", ".ndjson", ".csv", ".tsv", ".txt", ".md"}
|
||||
_SUBDIR = "browser-data" # never the workspace root, so we can't clobber project files
|
||||
MAX_BYTES = 25 * 1024 * 1024 # a page can't realistically hold more scraped data
|
||||
ALLOWED_EXT = {".json", ".ndjson", ".csv", ".tsv", ".txt", ".md"}
|
||||
SUBDIR = "browser-data" # never the workspace root, so we can't clobber project files
|
||||
|
||||
|
||||
def _dest_dir(cwd: str | None, session_id: str) -> str:
|
||||
def p_dest_dir(cwd: str | None, session_id: str) -> str:
|
||||
base = cwd if (cwd and os.path.isdir(cwd)) else os.path.join(
|
||||
os.path.expanduser("~"), ".openswarm", "workspaces", session_id or "browser")
|
||||
dest = os.path.join(base, _SUBDIR)
|
||||
dest = os.path.join(base, SUBDIR)
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
return dest
|
||||
|
||||
@@ -31,15 +31,15 @@ def save_page_data(cwd: str | None, session_id: str, filename: str, content: str
|
||||
if not name:
|
||||
return "Save failed: give a plain filename like results.json."
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext not in _ALLOWED_EXT:
|
||||
if ext not in ALLOWED_EXT:
|
||||
return (f"Save failed: '{ext or 'no extension'}' isn't allowed; this tool is for data, "
|
||||
f"not code. Use one of: {', '.join(sorted(_ALLOWED_EXT))}.")
|
||||
f"not code. Use one of: {', '.join(sorted(ALLOWED_EXT))}.")
|
||||
body = content or ""
|
||||
if len(body.encode("utf-8", "ignore")) > _MAX_BYTES:
|
||||
return f"Save failed: that's over the {_MAX_BYTES // (1024 * 1024)}MB cap; save fewer fields or rows."
|
||||
if len(body.encode("utf-8", "ignore")) > MAX_BYTES:
|
||||
return f"Save failed: that's over the {MAX_BYTES // (1024 * 1024)}MB cap; save fewer fields or rows."
|
||||
|
||||
try:
|
||||
dest_dir = _dest_dir(cwd, session_id)
|
||||
dest_dir = p_dest_dir(cwd, session_id)
|
||||
dest_real = os.path.realpath(dest_dir)
|
||||
full = os.path.realpath(os.path.join(dest_dir, name))
|
||||
# realpath + os.sep guard: defeats traversal, absolute paths, symlinks, AND a
|
||||
|
||||
@@ -10,7 +10,7 @@ purpose because it is one cohesive data blob, not multiple responsibilities.
|
||||
# (no prose beside action tools; ReportProgress IS the thinking) cut per-turn output
|
||||
# ~28% and roughly halved narration turns. MERGE_VERIFY (a confirmed `expect` is the
|
||||
# proof, skip the re-check) drops a wasted round-trip at the end.
|
||||
_THINK_SHORTER = (
|
||||
P_THINK_SHORTER = (
|
||||
"Do NOT write a free-text sentence next to your action tools: your ReportProgress "
|
||||
"fields ARE your thinking, and a separate prose explanation just repeats them and slows "
|
||||
"the turn. Don't narrate to the user as you go either. When the task is done you finish by "
|
||||
@@ -18,7 +18,7 @@ _THINK_SHORTER = (
|
||||
"tools, no prose.\n"
|
||||
)
|
||||
|
||||
_MERGE_VERIFY = (
|
||||
P_MERGE_VERIFY = (
|
||||
"When that `expect` CONFIRMS (the result says 'Confirmed: ...'), that IS your "
|
||||
"verification: go STRAIGHT to calling Done. Do NOT spend an "
|
||||
"extra screenshot or read turn to re-check what the confirmation already proved, that "
|
||||
@@ -35,7 +35,7 @@ MODEL_MAP = {
|
||||
# The change an action should cause, declared by the agent and CONFIRMED after the
|
||||
# action runs (success is observed, never assumed). A hit returns fast; a miss tells
|
||||
# the agent it may not have worked instead of letting it claim a false success.
|
||||
_EXPECT_DESC = {
|
||||
P_EXPECT_DESC = {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional but recommended: LITERAL text that should be VISIBLE on the page "
|
||||
@@ -264,7 +264,7 @@ BROWSER_TOOLS_SCHEMA = [
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector": {"type": "string", "description": "CSS selector of the element to click."},
|
||||
"expect": _EXPECT_DESC,
|
||||
"expect": P_EXPECT_DESC,
|
||||
},
|
||||
"required": ["selector"],
|
||||
},
|
||||
@@ -387,7 +387,7 @@ BROWSER_TOOLS_SCHEMA = [
|
||||
"to fill a compose/message box reliably."
|
||||
),
|
||||
},
|
||||
"expect": _EXPECT_DESC,
|
||||
"expect": P_EXPECT_DESC,
|
||||
},
|
||||
"required": ["index"],
|
||||
},
|
||||
@@ -653,8 +653,8 @@ BROWSER_TOOLS_SCHEMA = [
|
||||
# tools are not offered to it at all; acting means a BrowserBatch array, and
|
||||
# the one deliberate solo path is BrowserClickIndex (irreversible step with
|
||||
# expect, or a text-box fill). Executors and replay still support everything.
|
||||
_SOLO_MUTATORS_HIDDEN = {"BrowserNavigate", "BrowserClick", "BrowserType", "BrowserScroll", "BrowserPressKey"}
|
||||
MODEL_VISIBLE_TOOLS = [t for t in BROWSER_TOOLS_SCHEMA if t["name"] not in _SOLO_MUTATORS_HIDDEN]
|
||||
P_SOLO_MUTATORS_HIDDEN = {"BrowserNavigate", "BrowserClick", "BrowserType", "BrowserScroll", "BrowserPressKey"}
|
||||
MODEL_VISIBLE_TOOLS = [t for t in BROWSER_TOOLS_SCHEMA if t["name"] not in P_SOLO_MUTATORS_HIDDEN]
|
||||
|
||||
ACTION_MAP = {
|
||||
"BrowserScreenshot": "screenshot",
|
||||
@@ -720,7 +720,7 @@ SYSTEM_PROMPT = (
|
||||
"needs (the exact selector, index, or value). Each token you write is generated one at a "
|
||||
"time and is the main thing that slows a turn, so write the fewest that still carry the "
|
||||
"plan forward. Only write working_memory when you learn something NEW this turn; else 'none'.\n"
|
||||
+ _THINK_SHORTER +
|
||||
+ P_THINK_SHORTER +
|
||||
"Emit ReportProgress and your action tool(s) together in the same response. "
|
||||
"If you skip ReportProgress, your action tools will be REJECTED with an error "
|
||||
"and you will have to retry. This is not optional. Read-only tools "
|
||||
@@ -751,7 +751,7 @@ SYSTEM_PROMPT = (
|
||||
"inspections, or screenshots just because the post-type state only re-lists the rows "
|
||||
"that CHANGED, the unchanged Send is still there at its number. If clicking it comes "
|
||||
"back 'NOT confirmed', only THEN re-list to find where it moved.\n"
|
||||
+ _MERGE_VERIFY + "\n"
|
||||
+ P_MERGE_VERIFY + "\n"
|
||||
|
||||
"## Loop awareness\n"
|
||||
"If you see a tool result containing 'LOOP DETECTED' or '⚠️', it means you "
|
||||
@@ -908,7 +908,7 @@ MAX_TURNS = 40
|
||||
# Tools that count as "action tools"; calling any of these in a turn requires
|
||||
# the model to also call ReportProgress in the same turn (after the first
|
||||
# turn). Read-only tools and meta tools are exempt.
|
||||
_ACTION_TOOLS_REQUIRING_REPORT = {
|
||||
ACTION_TOOLS_REQUIRING_REPORT = {
|
||||
"BrowserClick",
|
||||
"BrowserType",
|
||||
"BrowserNavigate",
|
||||
|
||||
@@ -21,13 +21,13 @@ from collections import defaultdict
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thresholds for flagging; conservative so the report stays signal, not noise.
|
||||
_THRASH_MIN_RELEARNS = 3 # a skill re-versioned this many times with 0 replays = stuck
|
||||
_STALL_TURN_FACTOR = 2.0 # a run 2x the host's median turns is a stall worth noting
|
||||
_MIN_RUNS_FOR_MEDIAN = 4 # don't call a "norm" from too few runs
|
||||
_ERROR_RATE_FLAG = 0.25 # >25% of a host's tool calls erroring = something systemic
|
||||
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 _read_jsonl(path: str, cap: int = 20000) -> list[dict]:
|
||||
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
|
||||
@@ -48,7 +48,7 @@ def _read_jsonl(path: str, cap: int = 20000) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _median(xs: list[float]) -> float:
|
||||
def p_median(xs: list[float]) -> float:
|
||||
s = sorted(xs)
|
||||
n = len(s)
|
||||
if not n:
|
||||
@@ -59,8 +59,8 @@ def _median(xs: list[float]) -> float:
|
||||
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 = _read_jsonl(os.path.join(metrics_dir, "tasks.jsonl"))
|
||||
skill_events = _read_jsonl(os.path.join(metrics_dir, "skill_events.jsonl"))
|
||||
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
|
||||
@@ -76,7 +76,7 @@ def audit(metrics_dir: str) -> dict:
|
||||
elif kind == "promote":
|
||||
promotes[key] += 1
|
||||
for key, n in churn.items():
|
||||
if n >= _THRASH_MIN_RELEARNS and promotes.get(key, 0) == 0:
|
||||
if n >= P_THRASH_MIN_RELEARNS and promotes.get(key, 0) == 0:
|
||||
findings.append({
|
||||
"kind": "thrash",
|
||||
"host": key[0],
|
||||
@@ -89,19 +89,19 @@ def audit(metrics_dir: str) -> dict:
|
||||
# 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 = _host_of_task(t)
|
||||
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) < _MIN_RUNS_FOR_MEDIAN:
|
||||
if len(turns) < P_MIN_RUNS_FOR_MEDIAN:
|
||||
continue
|
||||
med = _median([float(x) for x in turns])
|
||||
stalls = [x for x in turns if med and x >= med * _STALL_TURN_FACTOR]
|
||||
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 >= {_STALL_TURN_FACTOR}x the median "
|
||||
"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 "
|
||||
@@ -112,13 +112,13 @@ def audit(metrics_dir: str) -> dict:
|
||||
err = defaultdict(int)
|
||||
tot = defaultdict(int)
|
||||
for t in tasks:
|
||||
h = _host_of_task(t)
|
||||
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]) >= _ERROR_RATE_FLAG:
|
||||
if tot[h] >= 20 and err[h] / max(1, tot[h]) >= P_ERROR_RATE_FLAG:
|
||||
findings.append({
|
||||
"kind": "error_rate",
|
||||
"host": h,
|
||||
@@ -135,7 +135,7 @@ def audit(metrics_dir: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _host_of_task(task: dict) -> str:
|
||||
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"
|
||||
@@ -171,7 +171,7 @@ def run_and_write(metrics_dir: str | None = None) -> str | None:
|
||||
try:
|
||||
if metrics_dir is None:
|
||||
from backend.apps.agents.browser import browser_metrics
|
||||
metrics_dir = browser_metrics._metrics_dir()
|
||||
metrics_dir = browser_metrics.metrics_dir()
|
||||
result = audit(metrics_dir)
|
||||
report = render_report(result)
|
||||
path = os.path.join(metrics_dir, "self_audit_report.md")
|
||||
|
||||
@@ -55,7 +55,7 @@ from urllib.parse import urlparse, urlunparse
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _event(kind: str, host: str, sig: str, rev: int = 0, state: str = "", **extra) -> None:
|
||||
def p_event(kind: str, host: str, sig: str, rev: int = 0, state: str = "", **extra) -> None:
|
||||
"""Mirror a lifecycle transition into the metrics sink so the analyzer can
|
||||
prove the skill layer helps vs. silently thrashes. Lazy + best-effort: this
|
||||
module never hard-depends on metrics, and a metrics failure never propagates."""
|
||||
@@ -67,61 +67,61 @@ def _event(kind: str, host: str, sig: str, rev: int = 0, state: str = "", **extr
|
||||
|
||||
|
||||
# In-memory hot cache: key "host::task_sig" -> skill dict. Bounded.
|
||||
_skills: dict[str, dict] = {}
|
||||
_MAX_MEM_SKILLS = 200
|
||||
_MAX_DISK_SKILLS = 1000 # bound the on-disk library; evict oldest by mtime
|
||||
_SKILL_FORMAT_VERSION = 1
|
||||
SKILLS: dict[str, dict] = {}
|
||||
P_MAX_MEM_SKILLS = 200
|
||||
P_MAX_DISK_SKILLS = 1000 # bound the on-disk library; evict oldest by mtime
|
||||
P_SKILL_FORMAT_VERSION = 1
|
||||
|
||||
# Trust state (the verify gate). A skill moves PROBATION -> TRUSTED only by a
|
||||
# successful end-to-end replay; an unproven (probation) skill that fails a replay
|
||||
# goes to QUARANTINE and is never replayed again (task falls back to pure LLM).
|
||||
_PROBATION = "probation"
|
||||
_TRUSTED = "trusted"
|
||||
_QUARANTINE = "quarantine"
|
||||
PROBATION = "probation"
|
||||
TRUSTED = "trusted"
|
||||
QUARANTINE = "quarantine"
|
||||
# A proven skill tolerates this many consecutive transient replay misses before
|
||||
# it's demoted back to probation (forced to re-earn trust).
|
||||
_FAIL_DEMOTE_THRESHOLD = 2
|
||||
P_FAIL_DEMOTE_THRESHOLD = 2
|
||||
|
||||
# Tools that change page state (worth replaying). Reads/meta are never recorded.
|
||||
_PRODUCTIVE = {"BrowserType", "BrowserClickIndex", "BrowserClick", "BrowserPressKey", "BrowserScroll"}
|
||||
PRODUCTIVE = {"BrowserType", "BrowserClickIndex", "BrowserClick", "BrowserPressKey", "BrowserScroll"}
|
||||
|
||||
_URL_RE = re.compile(r"https?://\S+")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
_PUNCT_RE = re.compile(r"[^a-z0-9 ]+")
|
||||
_STOP = {
|
||||
P_URL_RE = re.compile(r"https?://\S+")
|
||||
P_WS_RE = re.compile(r"\s+")
|
||||
P_PUNCT_RE = re.compile(r"[^a-z0-9 ]+")
|
||||
P_STOP = {
|
||||
"the", "a", "an", "to", "into", "on", "this", "that", "page", "please",
|
||||
"then", "and", "go", "open", "browser", "tell", "me", "whether", "it",
|
||||
"of", "in", "for", "with", "your", "after", "if", "you", "can",
|
||||
}
|
||||
|
||||
# --- sensitivity detection (gate for what may touch disk) ------------------
|
||||
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
_SSN_RE = re.compile(r"\b\d{3}-\d{2}-\d{4}\b")
|
||||
_CARD_RE = re.compile(r"\b(?:\d[ -]?){13,19}\b")
|
||||
_PHONE_RE = re.compile(r"\b(?:\+?\d[ -]?){10,15}\b")
|
||||
_TOKEN_PREFIX_RE = re.compile(r"\b(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ)")
|
||||
_SENSITIVE_FIELD_RE = re.compile(
|
||||
P_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
P_SSN_RE = re.compile(r"\b\d{3}-\d{2}-\d{4}\b")
|
||||
P_CARD_RE = re.compile(r"\b(?:\d[ -]?){13,19}\b")
|
||||
P_PHONE_RE = re.compile(r"\b(?:\+?\d[ -]?){10,15}\b")
|
||||
P_TOKEN_PREFIX_RE = re.compile(r"\b(sk-|ghp_|gho_|pk_|xox[bap]-|AIza|eyJ)")
|
||||
P_SENSITIVE_FIELD_RE = re.compile(
|
||||
r"pass|pwd|secret|otp|cvv|cvc|ssn|card|token|api[_-]?key|security"
|
||||
r"|user|login|sign[-_]?in|email|auth|seed|recovery|phrase|\bpin\b|2fa|verif|code",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _looks_sensitive(text: str, selector: str = "") -> bool:
|
||||
def looks_sensitive(text: str, selector: str = "") -> bool:
|
||||
"""Conservative: err toward 'sensitive' so secrets never persist. Catches
|
||||
emails, SSNs, card/phone-shaped digit runs, known key prefixes, long
|
||||
high-entropy tokens, bare one-time-code digit runs, and anything typed into
|
||||
a credential-shaped field (a wrongly-blocked persist just keeps the skill
|
||||
in-memory, so false positives are cheap; a leak is not)."""
|
||||
if selector and _SENSITIVE_FIELD_RE.search(selector):
|
||||
if selector and P_SENSITIVE_FIELD_RE.search(selector):
|
||||
return True
|
||||
if not text:
|
||||
return False
|
||||
if _EMAIL_RE.search(text) or _SSN_RE.search(text) or _CARD_RE.search(text):
|
||||
if P_EMAIL_RE.search(text) or P_SSN_RE.search(text) or P_CARD_RE.search(text):
|
||||
return True
|
||||
if _TOKEN_PREFIX_RE.search(text):
|
||||
if P_TOKEN_PREFIX_RE.search(text):
|
||||
return True
|
||||
if _PHONE_RE.search(text):
|
||||
if P_PHONE_RE.search(text):
|
||||
return True
|
||||
stripped = text.strip()
|
||||
# bare 6-8 digit run: the shape of every 2FA/SMS code; never worth persisting
|
||||
@@ -133,7 +133,7 @@ def _looks_sensitive(text: str, selector: str = "") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_url(url: str) -> str:
|
||||
def p_sanitize_url(url: str) -> str:
|
||||
"""Strip userinfo (user:pass@) and fragment from a URL before it persists."""
|
||||
try:
|
||||
p = urlparse(url)
|
||||
@@ -148,9 +148,9 @@ def _sanitize_url(url: str) -> str:
|
||||
def normalize_task(task: str) -> str:
|
||||
"""Stable task signature: lowercase, drop urls/punct/filler, collapse ws."""
|
||||
t = (task or "").lower()
|
||||
t = _URL_RE.sub(" ", t)
|
||||
t = _PUNCT_RE.sub(" ", t)
|
||||
toks = [w for w in _WS_RE.sub(" ", t).strip().split(" ") if w and w not in _STOP]
|
||||
t = P_URL_RE.sub(" ", t)
|
||||
t = P_PUNCT_RE.sub(" ", t)
|
||||
toks = [w for w in P_WS_RE.sub(" ", t).strip().split(" ") if w and w not in P_STOP]
|
||||
return " ".join(toks)
|
||||
|
||||
|
||||
@@ -163,8 +163,8 @@ def normalize_task(task: str) -> str:
|
||||
# Lookarounds keep word-internal apostrophes (chen's, don't) from opening a
|
||||
# span; without them every possessive made each task wording a unique sig and
|
||||
# silently disabled skill matching for those tasks.
|
||||
_QUOTE_RE = re.compile(r'(?<!\w)["“”‘’\']([^"“”‘’\']{1,200})["“”‘’\'](?!\w)')
|
||||
_SLOT_TOKEN = " slotvalue "
|
||||
P_QUOTE_RE = re.compile(r'(?<!\w)["“”‘’\']([^"“”‘’\']{1,200})["“”‘’\'](?!\w)')
|
||||
P_SLOT_TOKEN = " slotvalue "
|
||||
|
||||
|
||||
def template_task(task: str) -> tuple[str, list[str]]:
|
||||
@@ -173,19 +173,19 @@ def template_task(task: str) -> tuple[str, list[str]]:
|
||||
|
||||
def _repl(m):
|
||||
values.append(m.group(1))
|
||||
return _SLOT_TOKEN
|
||||
return P_SLOT_TOKEN
|
||||
|
||||
return _QUOTE_RE.sub(_repl, task or ""), values
|
||||
return P_QUOTE_RE.sub(_repl, task or ""), values
|
||||
|
||||
|
||||
def _sig(task: str) -> str:
|
||||
def compute_sig(task: str) -> str:
|
||||
"""Skill key signature: template out quoted values, then normalize, so the
|
||||
same task with different quoted inputs maps to the same key."""
|
||||
templated, _ = template_task(task)
|
||||
return normalize_task(templated)
|
||||
|
||||
|
||||
def _parameterize(steps: list[dict], task: str) -> list[dict]:
|
||||
def p_parameterize(steps: list[dict], task: str) -> list[dict]:
|
||||
"""Convert any BrowserType whose text is a quoted task value into a slot
|
||||
step (value_slot index), so the value is sourced live at replay, not stored."""
|
||||
_, values = template_task(task)
|
||||
@@ -309,10 +309,10 @@ def distill_steps(action_log: list[dict]) -> list[dict]:
|
||||
productive_count += 1
|
||||
if productive_count == 0:
|
||||
return []
|
||||
return _prune_detours(steps)
|
||||
return p_prune_detours(steps)
|
||||
|
||||
|
||||
def _prune_detours(steps: list[dict]) -> list[dict]:
|
||||
def p_prune_detours(steps: list[dict]) -> list[dict]:
|
||||
"""Drop an abandoned-page detour: a BrowserNavigate whose page was never
|
||||
acted on because the very next step navigates somewhere else. Conservative
|
||||
on purpose, only consecutive navigates qualify (if a page had been used,
|
||||
@@ -381,17 +381,17 @@ def steps_are_persistable(steps: list[dict]) -> bool:
|
||||
for s in steps:
|
||||
p = s.get("params", {})
|
||||
if s["tool"] == "BrowserType":
|
||||
if _looks_sensitive(p.get("text", ""), p.get("selector", "")):
|
||||
if looks_sensitive(p.get("text", ""), p.get("selector", "")):
|
||||
return False
|
||||
elif s["tool"] == "BrowserNavigate":
|
||||
url = p.get("url", "")
|
||||
# a tokenized/credentialed URL is both sensitive and non-reproducible
|
||||
if "@" in (urlparse(url).netloc or "") or _looks_sensitive(url):
|
||||
if "@" in (urlparse(url).netloc or "") or looks_sensitive(url):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _step_key(s: dict) -> tuple:
|
||||
def p_step_key(s: dict) -> tuple:
|
||||
"""Canonical identity of a step, ignoring volatile detail, so we can tell a
|
||||
real EDIT (page changed -> different steps) from a transient re-derivation
|
||||
(same steps, the miss was just a timing blip). A slot and a literal are
|
||||
@@ -407,7 +407,7 @@ def _step_key(s: dict) -> tuple:
|
||||
if tool == "BrowserClick":
|
||||
return (tool, p.get("selector"))
|
||||
if tool == "BrowserNavigate":
|
||||
return (tool, _sanitize_url(p.get("url", "")))
|
||||
return (tool, p_sanitize_url(p.get("url", "")))
|
||||
if tool == "BrowserPressKey":
|
||||
return (tool, p.get("key"))
|
||||
if tool == "BrowserScroll":
|
||||
@@ -415,23 +415,23 @@ def _step_key(s: dict) -> tuple:
|
||||
return (tool, json.dumps(p, sort_keys=True, default=str))
|
||||
|
||||
|
||||
def _steps_equal(a: list[dict], b: list[dict]) -> bool:
|
||||
return [_step_key(s) for s in a] == [_step_key(s) for s in b]
|
||||
def steps_equal(a: list[dict], b: list[dict]) -> bool:
|
||||
return [p_step_key(s) for s in a] == [p_step_key(s) for s in b]
|
||||
|
||||
|
||||
def _sanitized_steps_for_disk(steps: list[dict]) -> list[dict]:
|
||||
def p_sanitized_steps_for_disk(steps: list[dict]) -> list[dict]:
|
||||
"""Copy of steps safe to persist: navigate URLs stripped of userinfo+fragment."""
|
||||
out = []
|
||||
for s in steps:
|
||||
if s["tool"] == "BrowserNavigate":
|
||||
out.append({"tool": "BrowserNavigate", "params": {"url": _sanitize_url(s["params"].get("url", ""))}})
|
||||
out.append({"tool": "BrowserNavigate", "params": {"url": p_sanitize_url(s["params"].get("url", ""))}})
|
||||
else:
|
||||
out.append({"tool": s["tool"], "params": dict(s.get("params", {}))})
|
||||
return out
|
||||
|
||||
|
||||
# --- persistence ----------------------------------------------------------
|
||||
def _skills_dir() -> str | None:
|
||||
def p_skills_dir() -> str | None:
|
||||
override = os.environ.get("OPENSWARM_BROWSER_SKILLS_DIR")
|
||||
base = override
|
||||
if not base:
|
||||
@@ -447,31 +447,31 @@ def _skills_dir() -> str | None:
|
||||
return base
|
||||
|
||||
|
||||
def _key(host: str, sig: str) -> str:
|
||||
def p_key(host: str, sig: str) -> str:
|
||||
return f"{host}::{sig}"
|
||||
|
||||
|
||||
def _skill_path(host: str, sig: str) -> str | None:
|
||||
d = _skills_dir()
|
||||
def skill_path(host: str, sig: str) -> str | None:
|
||||
d = p_skills_dir()
|
||||
if not d:
|
||||
return None
|
||||
h = hashlib.sha256(_key(host, sig).encode("utf-8")).hexdigest()[:32]
|
||||
h = hashlib.sha256(p_key(host, sig).encode("utf-8")).hexdigest()[:32]
|
||||
return os.path.join(d, f"{h}.json")
|
||||
|
||||
|
||||
def _persist(host: str, sig: str, skill: dict) -> None:
|
||||
def persist(host: str, sig: str, skill: dict) -> None:
|
||||
"""Atomic per-skill write. Best-effort; never raises. Evicts oldest on cap."""
|
||||
path = _skill_path(host, sig)
|
||||
path = skill_path(host, sig)
|
||||
if not path:
|
||||
return
|
||||
payload = {
|
||||
"version": _SKILL_FORMAT_VERSION,
|
||||
"version": P_SKILL_FORMAT_VERSION,
|
||||
"host": host, "task_sig": sig,
|
||||
"steps": _sanitized_steps_for_disk(skill["steps"]),
|
||||
"steps": p_sanitized_steps_for_disk(skill["steps"]),
|
||||
"recorded_at": skill.get("recorded_at", time.time()),
|
||||
"replays": skill.get("replays", 0),
|
||||
"rev": skill.get("rev", 1),
|
||||
"state": skill.get("state", _PROBATION),
|
||||
"state": skill.get("state", PROBATION),
|
||||
"fails": skill.get("fails", 0),
|
||||
"composed_of": skill.get("composed_of", []),
|
||||
}
|
||||
@@ -481,18 +481,18 @@ def _persist(host: str, sig: str, skill: dict) -> None:
|
||||
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
|
||||
_evict_disk_if_over_cap(d)
|
||||
p_evict_disk_if_over_cap(d)
|
||||
except Exception as e:
|
||||
logger.debug(f"[browser-skills] persist failed: {e}")
|
||||
|
||||
|
||||
def _evict_disk_if_over_cap(d: str) -> None:
|
||||
def p_evict_disk_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) <= _MAX_DISK_SKILLS:
|
||||
if len(files) <= P_MAX_DISK_SKILLS:
|
||||
return
|
||||
files.sort(key=lambda p: os.path.getmtime(p)) # oldest first
|
||||
for p in files[: len(files) - _MAX_DISK_SKILLS]:
|
||||
for p in files[: len(files) - P_MAX_DISK_SKILLS]:
|
||||
try:
|
||||
os.remove(p)
|
||||
except Exception:
|
||||
@@ -501,14 +501,14 @@ def _evict_disk_if_over_cap(d: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _load_from_disk(host: str, sig: str) -> dict | None:
|
||||
path = _skill_path(host, sig)
|
||||
def p_load_from_disk(host: str, sig: str) -> dict | None:
|
||||
path = skill_path(host, sig)
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if data.get("version") != _SKILL_FORMAT_VERSION:
|
||||
if data.get("version") != P_SKILL_FORMAT_VERSION:
|
||||
return None # format changed -> ignore stale file
|
||||
if not data.get("steps"):
|
||||
return None
|
||||
@@ -516,7 +516,7 @@ def _load_from_disk(host: str, sig: str) -> dict | None:
|
||||
"host": data.get("host", host), "task_sig": data.get("task_sig", sig),
|
||||
"steps": data["steps"], "recorded_at": data.get("recorded_at", 0),
|
||||
"replays": data.get("replays", 0), "persisted": True,
|
||||
"rev": data.get("rev", 1), "state": data.get("state", _PROBATION),
|
||||
"rev": data.get("rev", 1), "state": data.get("state", PROBATION),
|
||||
"fails": data.get("fails", 0), "composed_of": data.get("composed_of", []),
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -524,12 +524,12 @@ def _load_from_disk(host: str, sig: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _host_skills(host: str) -> dict[str, dict]:
|
||||
def p_host_skills(host: str) -> dict[str, dict]:
|
||||
"""Every skill for one host, keyed by task_sig, in-memory authoritative over
|
||||
disk. One flat scan of the library dir (same cost list_skills always paid);
|
||||
callers that run per-record gate on cheap pre-checks before calling."""
|
||||
out: dict[str, dict] = {}
|
||||
d = _skills_dir()
|
||||
d = p_skills_dir()
|
||||
if d:
|
||||
try:
|
||||
for f in os.listdir(d):
|
||||
@@ -544,7 +544,7 @@ def _host_skills(host: str) -> dict[str, dict]:
|
||||
out[data["task_sig"]] = {**data, "persisted": True}
|
||||
except Exception:
|
||||
pass
|
||||
for s in _skills.values():
|
||||
for s in SKILLS.values():
|
||||
if s.get("host") == host and s.get("task_sig"):
|
||||
out[s["task_sig"]] = s
|
||||
return out
|
||||
@@ -557,41 +557,41 @@ def _host_skills(host: str) -> dict[str, dict]:
|
||||
# its own); the link is provenance + a safety wire: if that foundation is later
|
||||
# deprecated or goes stale, every skill built on it is knocked back to probation
|
||||
# so it must re-prove instead of silently riding a now-broken sub-sequence.
|
||||
_COMPOSE_MIN_SUB_STEPS = 2
|
||||
P_COMPOSE_MIN_SUB_STEPS = 2
|
||||
|
||||
|
||||
def _detect_composition(host: str, sig: str, steps: list[dict]) -> list[str]:
|
||||
def p_detect_composition(host: str, sig: str, steps: list[dict]) -> list[str]:
|
||||
"""Sigs of TRUSTED host skills whose full step list is a strict opening
|
||||
prefix of `steps`. Gated: needs a tail, so only runs for >=3-step skills."""
|
||||
if len(steps) < _COMPOSE_MIN_SUB_STEPS + 1:
|
||||
if len(steps) < P_COMPOSE_MIN_SUB_STEPS + 1:
|
||||
return []
|
||||
keys = [_step_key(s) for s in steps]
|
||||
keys = [p_step_key(s) for s in steps]
|
||||
found: list[str] = []
|
||||
for other_sig, other in _host_skills(host).items():
|
||||
if other_sig == sig or other.get("state") != _TRUSTED:
|
||||
for other_sig, other in p_host_skills(host).items():
|
||||
if other_sig == sig or other.get("state") != TRUSTED:
|
||||
continue
|
||||
osteps = other.get("steps", [])
|
||||
if len(osteps) < _COMPOSE_MIN_SUB_STEPS or len(osteps) >= len(steps):
|
||||
if len(osteps) < P_COMPOSE_MIN_SUB_STEPS or len(osteps) >= len(steps):
|
||||
continue
|
||||
if [_step_key(s) for s in osteps] == keys[: len(osteps)]:
|
||||
if [p_step_key(s) for s in osteps] == keys[: len(osteps)]:
|
||||
found.append(other_sig)
|
||||
return found
|
||||
|
||||
|
||||
def _invalidate_dependents(host: str, sub_sig: str) -> None:
|
||||
def p_invalidate_dependents(host: str, sub_sig: str) -> None:
|
||||
"""Knock every skill that builds on `sub_sig` back to probation: its proven
|
||||
foundation just moved (edited/deprecated/demoted), so it must re-earn trust
|
||||
rather than ghost-ride a sub-sequence that may no longer hold."""
|
||||
for dep_sig, dep in _host_skills(host).items():
|
||||
if sub_sig in dep.get("composed_of", []) and dep.get("state") == _TRUSTED:
|
||||
k = _key(host, dep_sig)
|
||||
live = _skills.get(k) or dep
|
||||
live["state"] = _PROBATION
|
||||
for dep_sig, dep in p_host_skills(host).items():
|
||||
if sub_sig in dep.get("composed_of", []) and dep.get("state") == TRUSTED:
|
||||
k = p_key(host, dep_sig)
|
||||
live = SKILLS.get(k) or dep
|
||||
live["state"] = PROBATION
|
||||
live["fails"] = 0
|
||||
_skills[k] = live
|
||||
SKILLS[k] = live
|
||||
if live.get("persisted"):
|
||||
_persist(host, dep_sig, live)
|
||||
_event("invalidate", host, dep_sig, rev=live.get("rev", 1), state=_PROBATION, foundation=sub_sig)
|
||||
persist(host, dep_sig, live)
|
||||
p_event("invalidate", host, dep_sig, rev=live.get("rev", 1), state=PROBATION, foundation=sub_sig)
|
||||
logger.info(f"[browser-skills] {host}::{dep_sig} knocked to probation "
|
||||
f"(its foundation {sub_sig} changed)")
|
||||
|
||||
@@ -610,15 +610,15 @@ def record_skill(host: str, task: str, action_log: list[dict]) -> bool:
|
||||
steps = distill_steps(action_log)
|
||||
if not steps:
|
||||
return False
|
||||
sig = _sig(task)
|
||||
sig = compute_sig(task)
|
||||
if not sig:
|
||||
return False
|
||||
steps = _parameterize(steps, task) # quoted values -> slots (not stored)
|
||||
steps = p_parameterize(steps, task) # quoted values -> slots (not stored)
|
||||
persistable = steps_are_persistable(steps)
|
||||
k = _key(host, sig)
|
||||
existing = _skills.get(k) or _load_from_disk(host, sig)
|
||||
k = p_key(host, sig)
|
||||
existing = SKILLS.get(k) or p_load_from_disk(host, sig)
|
||||
|
||||
if existing and _steps_equal(existing.get("steps", []), steps):
|
||||
if existing and steps_equal(existing.get("steps", []), steps):
|
||||
# Same skill re-derived: the replay that triggered this was a transient
|
||||
# miss, not a stale skill. Keep rev + trust; just clear the fail streak.
|
||||
# If it was quarantined (a known-bad distillation), leave it quarantined
|
||||
@@ -626,9 +626,9 @@ def record_skill(host: str, task: str, action_log: list[dict]) -> bool:
|
||||
existing["fails"] = 0
|
||||
existing["recorded_at"] = time.time()
|
||||
existing["persisted"] = persistable
|
||||
_skills[k] = existing
|
||||
SKILLS[k] = existing
|
||||
if persistable:
|
||||
_persist(host, sig, existing)
|
||||
persist(host, sig, existing)
|
||||
logger.info(f"[browser-skills] re-derived identical {len(steps)}-step skill for {host} "
|
||||
f"(rev {existing.get('rev', 1)}, state={existing.get('state')}, transient miss)")
|
||||
return True
|
||||
@@ -637,25 +637,25 @@ def record_skill(host: str, task: str, action_log: list[dict]) -> bool:
|
||||
skill = {
|
||||
"host": host, "task_sig": sig, "steps": steps,
|
||||
"recorded_at": time.time(), "replays": 0, "persisted": persistable,
|
||||
"rev": rev, "state": _PROBATION, "fails": 0,
|
||||
"composed_of": _detect_composition(host, sig, steps),
|
||||
"rev": rev, "state": PROBATION, "fails": 0,
|
||||
"composed_of": p_detect_composition(host, sig, steps),
|
||||
}
|
||||
_skills[k] = skill
|
||||
if len(_skills) > _MAX_MEM_SKILLS:
|
||||
oldest = min(_skills, key=lambda kk: _skills[kk]["recorded_at"])
|
||||
_skills.pop(oldest, None)
|
||||
SKILLS[k] = skill
|
||||
if len(SKILLS) > P_MAX_MEM_SKILLS:
|
||||
oldest = min(SKILLS, key=lambda kk: SKILLS[kk]["recorded_at"])
|
||||
SKILLS.pop(oldest, None)
|
||||
if persistable:
|
||||
_persist(host, sig, skill)
|
||||
persist(host, sig, skill)
|
||||
verb = "EDITED" if existing else "learned"
|
||||
comp = f", builds on {skill['composed_of']}" if skill["composed_of"] else ""
|
||||
logger.info(f"[browser-skills] {verb} {len(steps)}-step skill for {host} "
|
||||
f"(rev {rev}, probationary{', persisted' if persistable else ', in-memory only: sensitive'}{comp})")
|
||||
_event("edit" if existing else "learn", host, sig, rev=rev, state=_PROBATION,
|
||||
p_event("edit" if existing else "learn", host, sig, rev=rev, state=PROBATION,
|
||||
steps=len(steps), composed_of=skill["composed_of"], persisted=persistable)
|
||||
if skill["composed_of"]:
|
||||
_event("compose", host, sig, rev=rev, state=_PROBATION, builds_on=skill["composed_of"])
|
||||
p_event("compose", host, sig, rev=rev, state=PROBATION, builds_on=skill["composed_of"])
|
||||
if existing:
|
||||
_invalidate_dependents(host, sig) # anything built on the OLD version must re-prove
|
||||
p_invalidate_dependents(host, sig) # anything built on the OLD version must re-prove
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"[browser-skills] record failed: {e}")
|
||||
@@ -669,17 +669,17 @@ def find_skill(host: str, task: str) -> dict | None:
|
||||
re-attempting a known-bad replay. Cheap + flat as the library grows."""
|
||||
if not host:
|
||||
return None
|
||||
sig = _sig(task)
|
||||
sig = compute_sig(task)
|
||||
if not sig:
|
||||
return None
|
||||
k = _key(host, sig)
|
||||
hit = _skills.get(k)
|
||||
k = p_key(host, sig)
|
||||
hit = SKILLS.get(k)
|
||||
if not hit:
|
||||
loaded = _load_from_disk(host, sig)
|
||||
loaded = p_load_from_disk(host, sig)
|
||||
if loaded:
|
||||
_skills[k] = loaded # warm the hot cache (even if quarantined)
|
||||
SKILLS[k] = loaded # warm the hot cache (even if quarantined)
|
||||
hit = loaded
|
||||
if not hit or hit.get("state") == _QUARANTINE:
|
||||
if not hit or hit.get("state") == QUARANTINE:
|
||||
return None
|
||||
return hit
|
||||
|
||||
@@ -690,8 +690,8 @@ def find_skill(host: str, task: str) -> dict | None:
|
||||
# similar skill is rendered as advisory text the live agent adapts and verifies,
|
||||
# so it generalizes across wordings and stays send-safe (the agent still
|
||||
# confirms everything; a stale hint just wastes one glance).
|
||||
_HINT_MIN_OVERLAP = 0.5
|
||||
_HINT_MAX_STEPS = 10
|
||||
P_HINT_MIN_OVERLAP = 0.5
|
||||
P_HINT_MAX_STEPS = 10
|
||||
|
||||
|
||||
def find_similar_skill(host: str, task: str) -> tuple[dict | None, float]:
|
||||
@@ -700,13 +700,13 @@ def find_similar_skill(host: str, task: str) -> tuple[dict | None, float]:
|
||||
stays exact-key; this feeds route hints, never mechanical execution."""
|
||||
if not host:
|
||||
return None, 0.0
|
||||
sig = _sig(task)
|
||||
sig = compute_sig(task)
|
||||
stoks = set(sig.split())
|
||||
if not stoks:
|
||||
return None, 0.0
|
||||
best, best_score = None, 0.0
|
||||
for other_sig, s in _host_skills(host).items():
|
||||
if s.get("state") == _QUARANTINE or not s.get("steps"):
|
||||
for other_sig, s in p_host_skills(host).items():
|
||||
if s.get("state") == QUARANTINE or not s.get("steps"):
|
||||
continue
|
||||
otoks = set(other_sig.split())
|
||||
if not otoks:
|
||||
@@ -714,14 +714,14 @@ def find_similar_skill(host: str, task: str) -> tuple[dict | None, float]:
|
||||
score = len(stoks & otoks) / len(stoks | otoks)
|
||||
# a proven skill wins ties against an unproven one
|
||||
if score > best_score or (score == best_score and best is not None
|
||||
and s.get("state") == _TRUSTED and best.get("state") != _TRUSTED):
|
||||
and s.get("state") == TRUSTED and best.get("state") != TRUSTED):
|
||||
best, best_score = s, score
|
||||
if best and best_score >= _HINT_MIN_OVERLAP:
|
||||
if best and best_score >= P_HINT_MIN_OVERLAP:
|
||||
return best, best_score
|
||||
return None, 0.0
|
||||
|
||||
|
||||
def _hint_step_line(step: dict, values: list[str]) -> str:
|
||||
def p_hint_step_line(step: dict, values: list[str]) -> str:
|
||||
tool = step.get("tool", "")
|
||||
p = step.get("params", {}) or {}
|
||||
if tool == "BrowserNavigate":
|
||||
@@ -750,7 +750,7 @@ def render_route_hint(skill: dict, task: str, score: float) -> tuple[str, list[t
|
||||
"""Compact advisory route block from a skill's steps, plus the step keys for
|
||||
adoption measurement. Slots are filled from the LIVE task's quoted values
|
||||
(never from disk); the first irreversible step is flagged solo-only."""
|
||||
steps = (skill.get("steps") or [])[:_HINT_MAX_STEPS]
|
||||
steps = (skill.get("steps") or [])[:P_HINT_MAX_STEPS]
|
||||
if not steps:
|
||||
return "", []
|
||||
from backend.apps.agents.browser import browser_batch_replay
|
||||
@@ -766,8 +766,8 @@ def render_route_hint(skill: dict, task: str, score: float) -> tuple[str, list[t
|
||||
name = p.get("name") or p.get("selector") or ""
|
||||
if len(name) <= 40 and browser_batch_replay.is_replay_boundary({"action": "click", "name": name}):
|
||||
mark = " [IRREVERSIBLE: do this SOLO with `expect` proof, never in a batch]"
|
||||
lines.append(f"{i + 1}. {_hint_step_line(s, values)}{mark}")
|
||||
trust = "proven by a verified rerun" if skill.get("state") == _TRUSTED else "from one verified success"
|
||||
lines.append(f"{i + 1}. {p_hint_step_line(s, values)}{mark}")
|
||||
trust = "proven by a verified rerun" if skill.get("state") == TRUSTED else "from one verified success"
|
||||
safe_until = unsafe_i if unsafe_i >= 0 else len(steps)
|
||||
batch_line = (
|
||||
f"Steps 1-{safe_until} are routine; combine them into ONE BrowserBatch where the page allows."
|
||||
@@ -778,7 +778,7 @@ def render_route_hint(skill: dict, task: str, score: float) -> tuple[str, list[t
|
||||
"Adapt where the live page differs and verify each step as usual:\n"
|
||||
+ "\n".join(lines) + (f"\n{batch_line}" if batch_line else "")
|
||||
)
|
||||
return hint, [_step_key(s) for s in steps]
|
||||
return hint, [p_step_key(s) for s in steps]
|
||||
|
||||
|
||||
def hint_step_adopted(step_key: tuple, action_log: list[dict]) -> bool:
|
||||
@@ -817,13 +817,13 @@ def mark_replay_succeeded(host: str, task: str) -> None:
|
||||
return
|
||||
s["replays"] = s.get("replays", 0) + 1
|
||||
s["fails"] = 0
|
||||
promoted = s.get("state") != _TRUSTED
|
||||
s["state"] = _TRUSTED
|
||||
promoted = s.get("state") != TRUSTED
|
||||
s["state"] = TRUSTED
|
||||
if s.get("persisted"):
|
||||
_persist(host, s["task_sig"], s) # keep the on-disk count + state fresh
|
||||
persist(host, s["task_sig"], s) # keep the on-disk count + state fresh
|
||||
if promoted:
|
||||
logger.info(f"[browser-skills] {host}::{s['task_sig']} PROVEN by replay (rev {s.get('rev', 1)}) -> trusted")
|
||||
_event("promote", host, s["task_sig"], rev=s.get("rev", 1), state=_TRUSTED, replays=s["replays"])
|
||||
p_event("promote", host, s["task_sig"], rev=s.get("rev", 1), state=TRUSTED, replays=s["replays"])
|
||||
|
||||
|
||||
def mark_replay_failed(host: str, task: str) -> str:
|
||||
@@ -840,27 +840,27 @@ def mark_replay_failed(host: str, task: str) -> str:
|
||||
if not s:
|
||||
return "none"
|
||||
sig = s["task_sig"]
|
||||
if s.get("state") != _TRUSTED:
|
||||
s["state"] = _QUARANTINE
|
||||
if s.get("state") != TRUSTED:
|
||||
s["state"] = QUARANTINE
|
||||
s["fails"] = s.get("fails", 0) + 1
|
||||
if s.get("persisted"):
|
||||
_persist(host, sig, s)
|
||||
_event("quarantine", host, sig, rev=s.get("rev", 1), state=_QUARANTINE)
|
||||
_invalidate_dependents(host, sig)
|
||||
persist(host, sig, s)
|
||||
p_event("quarantine", host, sig, rev=s.get("rev", 1), state=QUARANTINE)
|
||||
p_invalidate_dependents(host, sig)
|
||||
logger.info(f"[browser-skills] {host}::{sig} (unproven) failed replay -> quarantined (baseline from here)")
|
||||
return "quarantined"
|
||||
s["fails"] = s.get("fails", 0) + 1
|
||||
if s["fails"] >= _FAIL_DEMOTE_THRESHOLD:
|
||||
s["state"] = _PROBATION
|
||||
if s["fails"] >= P_FAIL_DEMOTE_THRESHOLD:
|
||||
s["state"] = PROBATION
|
||||
if s.get("persisted"):
|
||||
_persist(host, sig, s)
|
||||
_event("demote", host, sig, rev=s.get("rev", 1), state=_PROBATION, fails=s["fails"])
|
||||
_invalidate_dependents(host, sig)
|
||||
persist(host, sig, s)
|
||||
p_event("demote", host, sig, rev=s.get("rev", 1), state=PROBATION, fails=s["fails"])
|
||||
p_invalidate_dependents(host, sig)
|
||||
logger.info(f"[browser-skills] {host}::{sig} failed {s['fails']}x -> demoted to probation")
|
||||
return "demoted"
|
||||
if s.get("persisted"):
|
||||
_persist(host, sig, s)
|
||||
logger.info(f"[browser-skills] {host}::{sig} transient replay miss ({s['fails']}/{_FAIL_DEMOTE_THRESHOLD}), trust kept")
|
||||
persist(host, sig, s)
|
||||
logger.info(f"[browser-skills] {host}::{sig} transient replay miss ({s['fails']}/{P_FAIL_DEMOTE_THRESHOLD}), trust kept")
|
||||
return "kept"
|
||||
|
||||
|
||||
@@ -870,28 +870,28 @@ def list_skills(host: str) -> list[dict]:
|
||||
agent can ask "what shortcuts do I have here?" without pulling a wall of
|
||||
detail into context. Reads in-memory + the on-disk library for this host."""
|
||||
out = []
|
||||
for sig, s in _host_skills(host).items():
|
||||
for sig, s in p_host_skills(host).items():
|
||||
out.append({
|
||||
"task": sig, "steps": len(s.get("steps", [])),
|
||||
"replays": s.get("replays", 0), "persisted": s.get("persisted", False),
|
||||
"state": s.get("state", _PROBATION), "rev": s.get("rev", 1),
|
||||
"state": s.get("state", PROBATION), "rev": s.get("rev", 1),
|
||||
"builds_on": list(s.get("composed_of", [])),
|
||||
})
|
||||
# trusted first, then most-reused
|
||||
return sorted(out, key=lambda x: (x["state"] != _TRUSTED, -x["replays"]))
|
||||
return sorted(out, key=lambda x: (x["state"] != TRUSTED, -x["replays"]))
|
||||
|
||||
|
||||
def deprecate_skill(host: str, task: str) -> bool:
|
||||
"""Remove a skill (in-memory + disk) so it stops being replayed, and knock
|
||||
any skill that was built on it back to probation. The agent calls this when
|
||||
it judges a saved shortcut is stale / wrong (page changed). Accepts either the
|
||||
raw task or the task_sig from list_skills (sig is idempotent under _sig).
|
||||
raw task or the task_sig from list_skills (sig is idempotent under sig).
|
||||
Returns True if something was removed."""
|
||||
if not host:
|
||||
return False
|
||||
sig = _sig(task)
|
||||
removed = _skills.pop(_key(host, sig), None) is not None
|
||||
path = _skill_path(host, sig)
|
||||
sig = compute_sig(task)
|
||||
removed = SKILLS.pop(p_key(host, sig), None) is not None
|
||||
path = skill_path(host, sig)
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -899,7 +899,7 @@ def deprecate_skill(host: str, task: str) -> bool:
|
||||
except Exception:
|
||||
pass
|
||||
if removed:
|
||||
_invalidate_dependents(host, sig)
|
||||
p_invalidate_dependents(host, sig)
|
||||
logger.info(f"[browser-skills] deprecated skill {host}::{sig}")
|
||||
return removed
|
||||
|
||||
@@ -910,9 +910,9 @@ def forget_host(host: str) -> int:
|
||||
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)
|
||||
for sig in list(p_host_skills(host).keys()):
|
||||
removed = SKILLS.pop(p_key(host, sig), None) is not None
|
||||
path = skill_path(host, sig)
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -929,9 +929,9 @@ def forget_host(host: str) -> int:
|
||||
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)."""
|
||||
_skills.clear()
|
||||
SKILLS.clear()
|
||||
if wipe_disk:
|
||||
d = _skills_dir()
|
||||
d = p_skills_dir()
|
||||
if d:
|
||||
try:
|
||||
for f in os.listdir(d):
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ADJUDICATION_PROMPT = (
|
||||
P_ADJUDICATION_PROMPT = (
|
||||
"A browser automation agent is stuck: its recent actions produced no "
|
||||
"progress on the page.\n\n"
|
||||
"GOAL: {goal}\n\n"
|
||||
@@ -27,7 +27,7 @@ _ADJUDICATION_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
def _extract_text(response) -> str:
|
||||
def extract_text(response) -> str:
|
||||
"""Pull the text out of an Anthropic-style response object."""
|
||||
parts = []
|
||||
for block in getattr(response, "content", None) or []:
|
||||
@@ -40,7 +40,7 @@ async def adjudicate_stuck(
|
||||
client, model: str, goal: str, recent_actions: str, page_text: str,
|
||||
) -> str:
|
||||
"""One cheap aux call returning concrete guidance, or "" on any failure."""
|
||||
prompt = _ADJUDICATION_PROMPT.format(
|
||||
prompt = P_ADJUDICATION_PROMPT.format(
|
||||
goal=(goal or "(unknown)")[:400],
|
||||
recent=(recent_actions or "(none)")[:1200],
|
||||
page=(page_text or "(empty)")[:1500],
|
||||
@@ -54,4 +54,4 @@ async def adjudicate_stuck(
|
||||
except Exception as e:
|
||||
logger.warning(f"[browser-validator] adjudication call failed: {e}")
|
||||
return ""
|
||||
return _extract_text(response)
|
||||
return extract_text(response)
|
||||
|
||||
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
|
||||
# elems - element count; the loop watches it stop changing = DOM/visual settle
|
||||
# found - the agent's `until` target is present + visible (visible text or selector)
|
||||
# `until` is JSON-encoded into a string literal, so it's data, never executable.
|
||||
def _probe_js(until: str) -> str:
|
||||
def p_probe_js(until: str) -> str:
|
||||
spec = json.dumps(until or "")
|
||||
return (
|
||||
"(()=>{const n=performance.now();"
|
||||
@@ -50,21 +50,21 @@ def _probe_js(until: str) -> str:
|
||||
"quiet:Math.round(n-last),elems:document.getElementsByTagName('*').length,found});})()"
|
||||
)
|
||||
|
||||
_QUIET_WINDOW_MS = 400 # network must be silent this long to count as settled
|
||||
_FLOOR_MS = 250 # never return before this (a momentary gap isn't 'settled')
|
||||
_POLL_MS = 150
|
||||
P_QUIET_WINDOW_MS = 400 # network must be silent this long to count as settled
|
||||
P_FLOOR_MS = 250 # never return before this (a momentary gap isn't 'settled')
|
||||
P_POLL_MS = 150
|
||||
# A healthy probe is tens of ms. A busy-but-fine SPA (heavy main-thread work mid-
|
||||
# hydration) can occasionally block longer, so a slow probe is NOT proof of death,
|
||||
# it's just a reason to stop THIS wait early instead of inheriting the 30s command
|
||||
# timeout. We bound each probe at this, and after a few consecutive non-responses
|
||||
# we surface hung=True as a SIGNAL (the loop folds it into a cross-command streak
|
||||
# and only then acts), never as a unilateral abort from a single wait.
|
||||
_PROBE_TIMEOUT_S = 2.5
|
||||
_MAX_PROBE_TIMEOUTS = 3
|
||||
P_PROBE_TIMEOUT_S = 2.5
|
||||
MAX_PROBE_TIMEOUTS = 3
|
||||
|
||||
|
||||
def decide_stop(ready, quiet_ms, dom_stable_ms, found, elapsed_ms,
|
||||
floor_ms=_FLOOR_MS, settle_window_ms=_QUIET_WINDOW_MS) -> bool:
|
||||
floor_ms=P_FLOOR_MS, settle_window_ms=P_QUIET_WINDOW_MS) -> bool:
|
||||
"""Pure decision. Stop the INSTANT the agent's target is present (no floor, it's
|
||||
exactly what we were waiting for). Otherwise, past the floor and once the document
|
||||
is complete, stop as soon as it has gone quiet by EITHER the network OR the DOM
|
||||
@@ -81,9 +81,9 @@ def decide_stop(ready, quiet_ms, dom_stable_ms, found, elapsed_ms,
|
||||
|
||||
|
||||
async def smart_wait(execute_fn, browser_id, tab_id, max_ms, *, until="",
|
||||
poll_ms=_POLL_MS, floor_ms=_FLOOR_MS,
|
||||
quiet_window_ms=_QUIET_WINDOW_MS,
|
||||
probe_timeout_s=_PROBE_TIMEOUT_S, target_only=False) -> dict:
|
||||
poll_ms=P_POLL_MS, floor_ms=P_FLOOR_MS,
|
||||
quiet_window_ms=P_QUIET_WINDOW_MS,
|
||||
probe_timeout_s=P_PROBE_TIMEOUT_S, target_only=False) -> dict:
|
||||
"""Wait up to `max_ms`, returning early once the page is ready. `until` (optional)
|
||||
is a label / visible text / selector the agent expects to appear; the wait ends the
|
||||
INSTANT it's present, so the agent isn't waiting blind. `execute_fn` is an async
|
||||
@@ -96,7 +96,7 @@ async def smart_wait(execute_fn, browser_id, tab_id, max_ms, *, until="",
|
||||
a result that renders a beat AFTER settle (a sent message landing in a thread under
|
||||
load), which the settle-early path otherwise misses, reporting a false 'not confirmed'."""
|
||||
max_ms = max(100, min(int(max_ms or 1000), 10000))
|
||||
probe_js = _probe_js(until)
|
||||
probe_js = p_probe_js(until)
|
||||
start = time.monotonic()
|
||||
settled = False
|
||||
found = False
|
||||
@@ -124,7 +124,7 @@ async def smart_wait(execute_fn, browser_id, tab_id, max_ms, *, until="",
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
probe_timeouts += 1
|
||||
if probe_timeouts >= _MAX_PROBE_TIMEOUTS:
|
||||
if probe_timeouts >= MAX_PROBE_TIMEOUTS:
|
||||
hung = True
|
||||
break
|
||||
continue
|
||||
|
||||
@@ -32,7 +32,7 @@ def _isolate_browser_state(monkeypatch):
|
||||
# where ITS env var points, not where the first test's pointed
|
||||
try:
|
||||
from backend.apps.agents.browser import browser_metrics as _bm
|
||||
_bm._metrics_dir_cache = None
|
||||
_bm.p_metrics_dir_cache = None
|
||||
except Exception:
|
||||
pass
|
||||
_reset()
|
||||
|
||||
@@ -135,7 +135,7 @@ def _install(monkeypatch, primary, aux):
|
||||
|
||||
|
||||
def test_full_loop_goal_stagnation_adjudication_and_hint_write(monkeypatch):
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("click the Search button"), _tu("BrowserListInteractives")]),
|
||||
Resp([_rp("click submit"), _tu("BrowserClick", selector=".s1")]),
|
||||
@@ -174,7 +174,7 @@ def test_action_with_expect_is_confirmed(monkeypatch):
|
||||
# An action that declares `expect` is CONFIRMED after it runs: the loop issues a
|
||||
# target-aware confirm probe and feeds the next turn a tool_result stating the
|
||||
# expected change is present (observed success, never assumed).
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("click submit and confirm"),
|
||||
_tu("BrowserClickIndex", index=1, expect="Submitted")]),
|
||||
@@ -196,7 +196,7 @@ def test_action_with_expect_is_confirmed(monkeypatch):
|
||||
def test_missing_report_progress_runs_the_action_and_reminds_not_rejects(monkeypatch):
|
||||
# The model acts WITHOUT ReportProgress. Old behavior rejected the turn (wasted
|
||||
# a round-trip); new behavior runs the action and folds in a one-line reminder.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_tu("BrowserClickIndex", index=2)]), # NO ReportProgress this turn
|
||||
Resp([Blk("text", "done")], stop_reason="end_turn"),
|
||||
@@ -218,7 +218,7 @@ def test_confirmed_send_ends_the_run_instead_of_stalling(monkeypatch):
|
||||
# After an irreversible send CONFIRMS, the model must not burn turns re-verifying.
|
||||
# Here it sends (index 99 = "Send", expect confirms) then tries to stall forever
|
||||
# with pure-perception turns; the loop must END within a turn or two, not spin.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("send the message"), _tu("BrowserClickIndex", index=99, expect="Sent")]),
|
||||
# the model now STALLS, re-looking instead of finishing (the bug)
|
||||
@@ -243,7 +243,7 @@ def test_confirmed_send_ends_the_run_instead_of_stalling(monkeypatch):
|
||||
def test_done_tool_delivers_a_clean_human_summary(monkeypatch):
|
||||
# Canonical finish: the model calls Done(message); that message is the user's
|
||||
# reply verbatim (no OUTCOME tag, no UI mechanics) and `done` is True.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("open profile + send"), _tu("BrowserClickIndex", index=5, expect="Sent")]),
|
||||
Resp([_tu("Done", message="Sent your message to Tyler, it's in the thread now.")]),
|
||||
@@ -259,7 +259,7 @@ def test_done_tool_delivers_a_clean_human_summary(monkeypatch):
|
||||
def test_done_tool_success_false_marks_not_done(monkeypatch):
|
||||
# Done(success=false) is the honest "couldn't finish": done is False so the
|
||||
# fast path knows to recover, and the message still reads like a person wrote it.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("look for thread"), _tu("BrowserClickIndex", index=3)]),
|
||||
Resp([_tu("Done", message="I hit a login wall, so I couldn't open the chat.", success=False)]),
|
||||
@@ -275,7 +275,7 @@ def test_run_that_never_calls_done_is_not_a_clean_success(monkeypatch):
|
||||
# A run that does real work but stops with plain text (never calls Done) is a
|
||||
# half-finish, not a clean success: done must be False so the fast path recovers
|
||||
# instead of shipping a silent stop (the 'Task completed.' that wasn't).
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("click it"), _tu("BrowserClickIndex", index=3)]),
|
||||
Resp([Blk("text", "I clicked the thing.")], stop_reason="end_turn"),
|
||||
@@ -292,7 +292,7 @@ def test_send_shortcut_does_not_arm_on_a_gather_task(monkeypatch):
|
||||
# arm the send-completion shortcut, there is no send to confirm. If it did, the
|
||||
# run cuts at the 2-turn post-send limit and leaks the canned "message went
|
||||
# through" line. On a gather task it should run the full perception budget.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("dismiss the cookie banner"), _tu("BrowserClickIndex", index=99)]),
|
||||
*[Resp([_rp("keep reading the list"), _tu("BrowserScreenshot")]) for _ in range(8)],
|
||||
@@ -313,7 +313,7 @@ def test_browser_save_data_writes_a_file_and_returns_a_receipt(monkeypatch, tmp_
|
||||
# of a dozen reply-chunks. The mock's evaluate echoes its expression as the data.
|
||||
import os as _os
|
||||
monkeypatch.setattr(_os.path, "expanduser", lambda p: str(tmp_path)) # fallback workspace -> tmp
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("save the rows"), _tu("BrowserSaveData", expression="JSON.stringify(window.__rows)", filename="rows.json")]),
|
||||
Resp([_tu("Done", message="Saved the full set to rows.json.")]),
|
||||
@@ -329,7 +329,7 @@ def test_browser_save_data_writes_a_file_and_returns_a_receipt(monkeypatch, tmp_
|
||||
# listings every turn) must NOT trip the spin backstop, gathering is the work,
|
||||
# not spinning. Here 9 straight Extract turns each return distinct data; the run
|
||||
# should keep going (no early wrap-up nudge) and finish on the model's own Done.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
# each turn reads a DIFFERENT page (distinct expression -> distinct result)
|
||||
*[Resp([_rp(f"page {i}"), _tu("BrowserEvaluate", expression=f"parsePage({i})")]) for i in range(9)],
|
||||
@@ -349,7 +349,7 @@ def test_spin_backstop_nudges_a_clean_wrapup_instead_of_a_midthought(monkeypatch
|
||||
# The Airbnb mid-thought bug: a read-heavy run that trips the spin backstop must
|
||||
# get ONE wrap-up nudge to summarize via Done, not be cut off mid-sentence. The
|
||||
# final reply is the model's clean Done answer, and the nudge actually reached it.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("open the list"), _tu("BrowserClickIndex", index=3)]), # an action arms the backstop
|
||||
# repeated identical screenshots (same result, no new data) = genuine spinning
|
||||
@@ -368,7 +368,7 @@ def test_early_perception_is_not_cut_short_before_any_action(monkeypatch):
|
||||
# Orienting on a cold/slow page can take several look-only turns; the stall
|
||||
# backstop must NOT fire before the agent has done anything (it only bounds a
|
||||
# POST-action spin). Here 7 perception turns precede the finish; all must run.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
# varied read tools so the (separate) identical-repeat loop detector doesn't trip;
|
||||
# this isolates the stall backstop, which must NOT fire pre-action
|
||||
_reads = ["BrowserListInteractives", "BrowserGetText", "BrowserScreenshot"]
|
||||
@@ -387,7 +387,7 @@ def test_aux_adjudication_fires_even_when_loop_detector_trips(monkeypatch):
|
||||
# Repeated IDENTICAL failing clicks trip the exact-repeat loop detector AND
|
||||
# reach stagnation exhaustion on the same turn. The aux escape hatch must
|
||||
# still fire (it was previously suppressed by the `not is_loop` guard).
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("click submit"), _tu("BrowserListInteractives")]),
|
||||
*[Resp([_rp("retry same"), _tu("BrowserClick", selector=".same")]) for _ in range(6)],
|
||||
@@ -410,7 +410,7 @@ def test_aux_adjudication_fires_even_when_loop_detector_trips(monkeypatch):
|
||||
def test_tier1_and_tier2_tools_drive_through_the_real_loop(monkeypatch):
|
||||
# The agent can call the new tier-1 (WebMCP detect) and tier-2 (list/replay)
|
||||
# tools through the actual run_browser_agent loop, and replay threads its url.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("check for a faster path"), _tu("BrowserDetectWebMCP")]),
|
||||
Resp([_rp("list captured routes"), _tu("BrowserListRoutes")]),
|
||||
@@ -438,7 +438,7 @@ def test_skill_is_recorded_then_replayed_with_zero_llm_calls(monkeypatch):
|
||||
# Run 2: same task/host -> replays via the no-LLM fast path (the speed win).
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("click submit"), _tu("BrowserListInteractives")]),
|
||||
Resp([_rp("click it"), _tu("BrowserClickIndex", index=1)]),
|
||||
@@ -471,7 +471,7 @@ def test_replay_falls_back_to_full_agent_when_a_step_fails(monkeypatch):
|
||||
# the full LLM agent instead (never ghost-succeed on a stale skill).
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
# Pre-seed a skill whose click target no longer exists on the page.
|
||||
SK.record_skill("docs.google.com", "click the Save button", [
|
||||
{"tool": "BrowserClickIndex", "input": {"index": 1}, "ok": True,
|
||||
@@ -504,7 +504,7 @@ def test_deferred_replay_fires_after_navigating_to_the_right_host(monkeypatch):
|
||||
# deferred re-check must switch to replay instead of grinding the LLM loop.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
SK.record_skill("docs.google.com", "click the Search button", [
|
||||
{"tool": "BrowserClickIndex", "input": {}, "ok": True,
|
||||
"clicked_role": "button", "clicked_name": "Search"},
|
||||
@@ -535,7 +535,7 @@ def test_deferred_replay_fires_after_navigating_to_the_right_host(monkeypatch):
|
||||
assert any(c["action"] == "click_by_name" for c in sent), "replay re-resolved by name"
|
||||
assert len(primary.calls) == 1, "only the navigate turn ran; the re-check preempted the rest"
|
||||
# and the deferred replay still promotes the skill through the trust gate
|
||||
assert SK.find_skill("docs.google.com", "click the Search button")["state"] == SK._TRUSTED
|
||||
assert SK.find_skill("docs.google.com", "click the Search button")["state"] == SK.TRUSTED
|
||||
|
||||
|
||||
def test_deferred_replay_does_not_fire_after_the_page_was_dirtied(monkeypatch):
|
||||
@@ -544,7 +544,7 @@ def test_deferred_replay_does_not_fire_after_the_page_was_dirtied(monkeypatch):
|
||||
# state is dirty), so the re-check must stay disabled and the LLM finishes.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
SK.record_skill("docs.google.com", "click the Search button", [
|
||||
{"tool": "BrowserClickIndex", "input": {}, "ok": True,
|
||||
"clicked_role": "button", "clicked_name": "Search"},
|
||||
@@ -582,7 +582,7 @@ def test_replay_resolves_host_from_live_page_when_no_initial_url(monkeypatch):
|
||||
# orchestrated flow (records skills it can never look up again).
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
# a skill exists for the host the live page will report (DOC_URL -> docs.google.com)
|
||||
SK.record_skill("docs.google.com", "click the Search button", [
|
||||
{"tool": "BrowserClickIndex", "input": {}, "ok": True,
|
||||
@@ -608,7 +608,7 @@ def test_skill_keys_on_parent_user_message_so_reformulations_share_a_skill(monke
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
import backend.apps.agents.agent_manager as am_mod
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, role, content):
|
||||
@@ -651,7 +651,7 @@ def test_skill_key_falls_back_to_delegated_task_on_multi_quote_message(monkeypat
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
import backend.apps.agents.agent_manager as am_mod
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, role, content):
|
||||
@@ -680,7 +680,7 @@ def test_replay_success_promotes_skill_to_trusted_through_the_loop(monkeypatch):
|
||||
# it successfully, which must PROMOTE it to trusted (proven by a real replay).
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("click submit"), _tu("BrowserListInteractives")]),
|
||||
Resp([_rp("click it"), _tu("BrowserClickIndex", index=1)]),
|
||||
@@ -691,12 +691,12 @@ def test_replay_success_promotes_skill_to_trusted_through_the_loop(monkeypatch):
|
||||
asyncio.run(BA.run_browser_agent(
|
||||
task="click the Search button", browser_id="b1", model="sonnet", initial_url=DOC_URL,
|
||||
))
|
||||
assert SK.find_skill("docs.google.com", "click the Search button")["state"] == SK._PROBATION
|
||||
assert SK.find_skill("docs.google.com", "click the Search button")["state"] == SK.PROBATION
|
||||
r2 = asyncio.run(BA.run_browser_agent(
|
||||
task="click the Search button", browser_id="b1", model="sonnet", initial_url=DOC_URL,
|
||||
))
|
||||
assert r2.get("replayed") is True
|
||||
assert SK.find_skill("docs.google.com", "click the Search button")["state"] == SK._TRUSTED
|
||||
assert SK.find_skill("docs.google.com", "click the Search button")["state"] == SK.TRUSTED
|
||||
|
||||
|
||||
def test_skill_with_send_step_never_replays_silently(monkeypatch):
|
||||
@@ -705,7 +705,7 @@ def test_skill_with_send_step_never_replays_silently(monkeypatch):
|
||||
# confirms before anything outward) runs instead, and trust is untouched.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
SK.record_skill("docs.google.com", "message tyler saying hi", [
|
||||
{"tool": "BrowserClickIndex", "input": {}, "ok": True,
|
||||
"clicked_role": "button", "clicked_name": "Send"},
|
||||
@@ -718,7 +718,7 @@ def test_skill_with_send_step_never_replays_silently(monkeypatch):
|
||||
assert not r.get("replayed"), "a send-step skill must never auto-replay"
|
||||
assert not any(c["action"] == "click_by_name" for c in sent), "the recorded Send was not re-fired"
|
||||
assert len(primary.calls) > 0, "the live agent ran instead"
|
||||
assert SK.find_skill("docs.google.com", "message tyler saying hi")["state"] == SK._PROBATION, \
|
||||
assert SK.find_skill("docs.google.com", "message tyler saying hi")["state"] == SK.PROBATION, \
|
||||
"skipping replay is not a replay failure; trust stays untouched"
|
||||
|
||||
|
||||
@@ -728,7 +728,7 @@ def test_unproven_skill_that_fails_is_quarantined_and_never_retried(monkeypatch)
|
||||
# it goes straight to the pure-LLM baseline. A silent re-fail would be a ghost.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
SK.record_skill("docs.google.com", "click the Save button", [
|
||||
{"tool": "BrowserClickIndex", "input": {"index": 1}, "ok": True,
|
||||
"clicked_role": "button", "clicked_name": "Save"},
|
||||
@@ -750,7 +750,7 @@ def test_unproven_skill_that_fails_is_quarantined_and_never_retried(monkeypatch)
|
||||
task="click the Save button", browser_id="b1", model="sonnet", initial_url=DOC_URL,
|
||||
))
|
||||
assert any(c["action"] == "click_by_name" for c in sent), "run 1 DID attempt the replay"
|
||||
assert SK.list_skills("docs.google.com")[0]["state"] == SK._QUARANTINE
|
||||
assert SK.list_skills("docs.google.com")[0]["state"] == SK.QUARANTINE
|
||||
|
||||
# Run 2: the quarantined skill must NOT be replayed again.
|
||||
sent.clear()
|
||||
@@ -769,7 +769,7 @@ def test_informational_run_records_no_skill_to_avoid_thin_ghost(monkeypatch):
|
||||
# falsely claim the whole task done without regenerating the judged list.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
ten = "\n".join(f"{i}. Engineer {i}, very cracked, at Startup{i}" for i in range(1, 11))
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("search"), _tu("BrowserClickIndex", index=1)]), # a real productive action
|
||||
@@ -790,7 +790,7 @@ def test_read_answered_from_frontloaded_perception_is_not_a_ghost(monkeypatch):
|
||||
# a read task straight from that (zero further tools), the honesty gate must
|
||||
# NOT flag it as 'declared done without taking a single action'. The front-
|
||||
# loaded reads are real and seed action_log. (This bug caused retry loops.)
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
primary = FakeLLM([
|
||||
# the model answers immediately from the front-loaded page text, no tools
|
||||
Resp([Blk("text", "The first sentence is: Alan Turing was a mathematician.")], stop_reason="end_turn"),
|
||||
@@ -819,7 +819,7 @@ def test_ghost_completion_is_reported_as_error_not_completed(monkeypatch):
|
||||
# and must NOT record a skill from a run that accomplished nothing.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("click submit"), _tu("BrowserClick", selector=".s1")]),
|
||||
Resp([_rp("retry"), _tu("BrowserClick", selector=".s2")]),
|
||||
@@ -854,7 +854,7 @@ def test_dead_browser_card_aborts_fast_without_spinning(monkeypatch):
|
||||
# turns, not the whole budget) and report the precise reason.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
# the model would happily keep clicking for 8 turns if we let it
|
||||
primary = FakeLLM(
|
||||
[Resp([_rp("click"), _tu("BrowserClick", selector=f".s{i}")]) for i in range(8)]
|
||||
@@ -890,7 +890,7 @@ def test_hung_browser_card_aborts_fast_not_a_20_minute_loop(monkeypatch):
|
||||
# feeds the same fast-fail streak and aborts in a couple of turns.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
primary = FakeLLM(
|
||||
[Resp([_rp("read"), _tu("BrowserGetText")]) for _ in range(8)]
|
||||
+ [Resp([Blk("text", "done")], stop_reason="end_turn")]
|
||||
@@ -921,7 +921,7 @@ def test_perception_is_frontloaded_into_first_turn(monkeypatch):
|
||||
# With a known start URL, the agent should prefetch the element list + page
|
||||
# text and put them in the FIRST user message, so the model can act on turn 1
|
||||
# instead of spending early turns orienting.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([Resp([Blk("text", "done")], stop_reason="end_turn")])
|
||||
aux = FakeAux()
|
||||
_install(monkeypatch, primary, aux)
|
||||
@@ -939,7 +939,7 @@ def test_prompt_caching_markers_present(monkeypatch):
|
||||
# The fixed system+tools prefix must carry cache_control so it's cached
|
||||
# across turns (the first-run speed/cost win). Without the marker the
|
||||
# ~4k-token prefix is reprocessed every turn.
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
primary = FakeLLM([Resp([Blk("text", "done")], stop_reason="end_turn")])
|
||||
aux = FakeAux()
|
||||
_install(monkeypatch, primary, aux)
|
||||
@@ -958,7 +958,7 @@ def test_agent_can_list_and_deprecate_its_own_skills(monkeypatch):
|
||||
# handled, never sent to the webview), giving it agency over its own memory.
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear()
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
# pre-seed a skill on this host
|
||||
SK.record_skill("docs.google.com", "share the doc now", [
|
||||
{"tool": "BrowserClickIndex", "input": {}, "ok": True, "clicked_role": "button", "clicked_name": "Share"},
|
||||
@@ -992,7 +992,7 @@ def test_playbook_distills_on_success_survives_restart_and_seeds_next_run(monkey
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
import json as _json
|
||||
SK.clear(); PB.clear(wipe_disk=True)
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
|
||||
# aux returns a strategy playbook as JSON (the distill+reconcile reply)
|
||||
class PBAux:
|
||||
@@ -1025,7 +1025,7 @@ def test_playbook_distills_on_success_survives_restart_and_seeds_next_run(monkey
|
||||
|
||||
# Restart: drop in-memory, keep disk.
|
||||
PB.clear(wipe_disk=False)
|
||||
assert not PB._cache
|
||||
assert not PB.CACHE
|
||||
|
||||
# Run 2: fresh task, same host -> playbook must be seeded into the system prompt.
|
||||
primary2 = FakeLLM([Resp([Blk("text", "done")], stop_reason="end_turn")])
|
||||
@@ -1047,7 +1047,7 @@ def test_ambient_memory_signals_fire_calmly(monkeypatch):
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
import json as _json
|
||||
SK.clear(); PB.clear(wipe_disk=True)
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
|
||||
class PBAux:
|
||||
def __init__(self): self.messages = self
|
||||
@@ -1095,7 +1095,7 @@ def test_playbook_not_learned_from_a_ghost_completion(monkeypatch):
|
||||
import backend.apps.agents.browser.browser_playbook as PB
|
||||
import backend.apps.agents.browser.browser_skills as SK
|
||||
SK.clear(); PB.clear(wipe_disk=True)
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
|
||||
class CountingAux:
|
||||
def __init__(self):
|
||||
@@ -1126,7 +1126,7 @@ def test_playbook_not_learned_from_a_ghost_completion(monkeypatch):
|
||||
def test_batch_replay_runs_a_read_loop_for_all_values(monkeypatch):
|
||||
# The win: do one item the slow way, then BrowserRepeatFlow runs the same
|
||||
# read flow for the rest at machine speed, one tool turn, no screenshots.
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
steps = [{"action": "navigate", "url": "https://docs.google.com/in/{{value}}"},
|
||||
{"action": "evaluate", "expression": "read('{{value}}')"}]
|
||||
primary = FakeLLM([
|
||||
@@ -1159,7 +1159,7 @@ def test_batch_replay_is_ghost_proof_when_an_item_does_not_match(monkeypatch):
|
||||
# THE anti-ghost test: per-item pages vary. Value 'grace' errors mid-flow ->
|
||||
# it must be reported as needs-manual, the others still succeed, and the tally
|
||||
# is HONEST ('2 of 3'), never a silent 'did them all'.
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
steps = [{"action": "navigate", "url": "https://docs.google.com/in/{{value}}"},
|
||||
{"action": "evaluate", "expression": "read('{{value}}')"}]
|
||||
primary = FakeLLM([
|
||||
@@ -1191,7 +1191,7 @@ def test_batch_replay_is_ghost_proof_when_an_item_does_not_match(monkeypatch):
|
||||
def test_batch_replay_refuses_a_send_loop_and_executes_nothing(monkeypatch):
|
||||
# The send gate: a flow that clicks 'Send message' must be REFUSED outright,
|
||||
# nothing is clicked, so we can never auto-message N people.
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
steps = [{"action": "navigate", "url": "https://docs.google.com/in/{{value}}"},
|
||||
{"action": "click", "role": "button", "name": "Message"},
|
||||
{"action": "type", "selector": "#msg", "text": "hi {{value}}"},
|
||||
@@ -1212,7 +1212,7 @@ def test_batch_replay_refuses_a_send_loop_and_executes_nothing(monkeypatch):
|
||||
def test_batch_replay_uses_the_fast_network_route_per_value(monkeypatch):
|
||||
# Folds in the audit finding: a read-loop can hit a captured API endpoint
|
||||
# (replay_route) per value instead of clicking the UI, the fast tier.
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
steps = [{"action": "replay_route", "url": "https://docs.google.com/api/p?u={{value}}"}]
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("fetch via api"), _tu("BrowserRepeatFlow", steps=steps, values=["ada", "grace"])]),
|
||||
@@ -1228,7 +1228,7 @@ def test_captured_routes_are_surfaced_once_per_host(monkeypatch):
|
||||
# Drives the dead network tier: when a READ shows safe GET routes were captured
|
||||
# (sampled on get_text, after the SPA's XHRs fired, not on navigate), the agent
|
||||
# gets a ONE-TIME nudge per host toward BrowserReplayRoute, not on every read.
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("read 1"), _tu("BrowserEvaluate", expression="document.title")]),
|
||||
Resp([_rp("read 2"), _tu("BrowserEvaluate", expression="document.title")]),
|
||||
@@ -1253,7 +1253,7 @@ def test_captured_routes_are_surfaced_once_per_host(monkeypatch):
|
||||
def test_browser_wait_routes_through_smart_wait_and_returns_early(monkeypatch):
|
||||
# BrowserWait must no longer be a blind sleep: it probes the page (evaluate)
|
||||
# and returns as soon as it's settled, well under the requested cap.
|
||||
BH._browser_history.clear()
|
||||
BH.BROWSER_HISTORY.clear()
|
||||
primary = FakeLLM([
|
||||
Resp([_rp("let it settle"), _tu("BrowserWait", milliseconds=8000)]),
|
||||
Resp([Blk("text", "Settled, moving on.")], stop_reason="end_turn"),
|
||||
@@ -1271,7 +1271,7 @@ def test_browser_wait_routes_through_smart_wait_and_returns_early(monkeypatch):
|
||||
|
||||
|
||||
def test_prior_domain_hint_is_seeded_into_system_prompt(monkeypatch):
|
||||
BH._browser_history.clear(); BH._domain_notes.clear()
|
||||
BH.BROWSER_HISTORY.clear(); BH.DOMAIN_NOTES.clear()
|
||||
BH.set_domain_note("google.com", "REMEMBERED: Share button is index 43; Tab into the dialog.")
|
||||
primary = FakeLLM([Resp([Blk("text", "done")], stop_reason="end_turn")])
|
||||
aux = FakeAux()
|
||||
@@ -1323,22 +1323,22 @@ def test_find_reusable_card_reuses_own_then_orphan_never_user(monkeypatch):
|
||||
|
||||
target = "https://www.linkedin.com/search/results/people/?keywords=t"
|
||||
# the parent's own same-host card wins
|
||||
assert BA._find_reusable_card("d1", target, "p1") == "b-own"
|
||||
assert BA.find_reusable_card("d1", target, "p1") == "b-own"
|
||||
# a different parent skips p1's... unless that parent finished (orphan); first orphan wins
|
||||
assert BA._find_reusable_card("d1", target, "p2") == "b-orphan"
|
||||
assert BA.find_reusable_card("d1", target, "p2") == "b-orphan"
|
||||
# never a different host
|
||||
assert BA._find_reusable_card("d1", "https://example.com/", "p1") == ""
|
||||
assert BA.find_reusable_card("d1", "https://example.com/", "p1") == ""
|
||||
# an actively-driven card is never grabbed
|
||||
BA._active_agent_cards.update({"b-own", "b-orphan"})
|
||||
BA.ACTIVE_AGENT_CARDS.update({"b-own", "b-orphan"})
|
||||
try:
|
||||
assert BA._find_reusable_card("d1", target, "p1") == ""
|
||||
assert BA.find_reusable_card("d1", target, "p1") == ""
|
||||
finally:
|
||||
BA._active_agent_cards.clear()
|
||||
BA.ACTIVE_AGENT_CARDS.clear()
|
||||
# cards of a still-RUNNING other parent are off limits
|
||||
class _Running:
|
||||
status = "running"
|
||||
monkeypatch.setattr(am_mod.agent_manager, "get_session", lambda sid: _Running(), raising=False)
|
||||
assert BA._find_reusable_card("d1", target, "p2") == ""
|
||||
assert BA.find_reusable_card("d1", target, "p2") == ""
|
||||
|
||||
|
||||
def _fake_settle(calls):
|
||||
@@ -1360,7 +1360,7 @@ def test_post_action_state_settles_then_attaches(monkeypatch):
|
||||
from backend.apps.agents.browser import browser_agent as ba
|
||||
calls = []
|
||||
monkeypatch.setattr(ba.browser_wait, "smart_wait", _fake_settle(calls))
|
||||
out = asyncio.run(ba._post_action_state(
|
||||
out = asyncio.run(ba.post_action_state(
|
||||
"BrowserClickIndex", {"index": 1}, {"text": "Clicked"},
|
||||
"b1", "", _fake_exec(calls), "find tyler",
|
||||
))
|
||||
@@ -1374,7 +1374,7 @@ def test_post_action_state_navigate_gets_longer_settle(monkeypatch):
|
||||
from backend.apps.agents.browser import browser_agent as ba
|
||||
calls = []
|
||||
monkeypatch.setattr(ba.browser_wait, "smart_wait", _fake_settle(calls))
|
||||
asyncio.run(ba._post_action_state(
|
||||
asyncio.run(ba.post_action_state(
|
||||
"BrowserNavigate", {"url": "https://x.com"}, {"text": "Navigated"},
|
||||
"b1", "", _fake_exec(calls), "",
|
||||
))
|
||||
@@ -1386,7 +1386,7 @@ def test_post_action_state_expect_skips_double_settle(monkeypatch):
|
||||
from backend.apps.agents.browser import browser_agent as ba
|
||||
calls = []
|
||||
monkeypatch.setattr(ba.browser_wait, "smart_wait", _fake_settle(calls))
|
||||
out = asyncio.run(ba._post_action_state(
|
||||
out = asyncio.run(ba.post_action_state(
|
||||
"BrowserClickIndex", {"index": 2, "expect": "Sent"}, {"text": "Clicked"},
|
||||
"b1", "", _fake_exec(calls), "",
|
||||
))
|
||||
@@ -1400,13 +1400,13 @@ def test_post_action_state_skips_errors_reads_and_batch_reads(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(ba.browser_wait, "smart_wait", _fake_settle(calls))
|
||||
exec_fn = _fake_exec(calls)
|
||||
assert asyncio.run(ba._post_action_state(
|
||||
assert asyncio.run(ba.post_action_state(
|
||||
"BrowserClickIndex", {"index": 1}, {"error": "nope"}, "b", "", exec_fn, "")) == ""
|
||||
assert asyncio.run(ba._post_action_state(
|
||||
assert asyncio.run(ba.post_action_state(
|
||||
"BrowserGetText", {}, {"text": "page text"}, "b", "", exec_fn, "")) == ""
|
||||
batch_in = {"actions": [{"type": "click_index", "params": {"index": 1}},
|
||||
{"type": "list_interactives", "params": {}}]}
|
||||
assert asyncio.run(ba._post_action_state(
|
||||
assert asyncio.run(ba.post_action_state(
|
||||
"BrowserBatch", batch_in, {"text": "ran 2"}, "b", "", exec_fn, "")) == ""
|
||||
assert calls == []
|
||||
|
||||
@@ -1417,7 +1417,7 @@ def test_post_action_state_truncates_long_lists(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(ba.browser_wait, "smart_wait", _fake_settle(calls))
|
||||
long_list = "\n".join(f'[{i}]<button "b{i}">' for i in range(60))
|
||||
out = asyncio.run(ba._post_action_state(
|
||||
out = asyncio.run(ba.post_action_state(
|
||||
"BrowserType", {"selector": "#q", "text": "hi"}, {"text": "Typed"},
|
||||
"b1", "", _fake_exec(calls, long_list), "",
|
||||
))
|
||||
@@ -1431,7 +1431,7 @@ def test_post_action_state_hung_settle_attaches_nothing(monkeypatch):
|
||||
async def hung_wait(execute_fn, browser_id, tab_id, max_ms, **kw):
|
||||
return {"settled": False, "hung": True}
|
||||
monkeypatch.setattr(ba.browser_wait, "smart_wait", hung_wait)
|
||||
out = asyncio.run(ba._post_action_state(
|
||||
out = asyncio.run(ba.post_action_state(
|
||||
"BrowserClick", {"selector": "a"}, {"text": "Clicked"},
|
||||
"b1", "", _fake_exec(calls), "",
|
||||
))
|
||||
@@ -1439,20 +1439,20 @@ def test_post_action_state_hung_settle_attaches_nothing(monkeypatch):
|
||||
|
||||
|
||||
def test_delta_state_first_attach_sends_full_list():
|
||||
from backend.apps.agents.browser.browser_agent import _delta_state
|
||||
from backend.apps.agents.browser.browser_agent import delta_state
|
||||
seen = set()
|
||||
full = "8 interactive elements:\n" + "\n".join(f'[{i}]<button "b{i}">' for i in range(1, 9))
|
||||
assert _delta_state(full, seen) == full
|
||||
assert delta_state(full, seen) == full
|
||||
assert len(seen) == 8
|
||||
|
||||
|
||||
def test_delta_state_shrinks_to_changed_rows():
|
||||
from backend.apps.agents.browser.browser_agent import _delta_state
|
||||
from backend.apps.agents.browser.browser_agent import delta_state
|
||||
rows = [f'[{i}]<button "b{i}">' for i in range(1, 11)]
|
||||
seen = set()
|
||||
_delta_state("\n".join(rows), seen)
|
||||
delta_state("\n".join(rows), seen)
|
||||
nxt = rows[:9] + ['[10]<button "b10" value="typed">', '[11]*<button "new">']
|
||||
out = _delta_state("\n".join(nxt), seen)
|
||||
out = delta_state("\n".join(nxt), seen)
|
||||
assert '[11]*<button "new">' in out and 'value="typed"' in out
|
||||
assert '[3]<button "b3">' not in out
|
||||
assert "+9 rows unchanged" in out
|
||||
@@ -1460,20 +1460,20 @@ def test_delta_state_shrinks_to_changed_rows():
|
||||
|
||||
|
||||
def test_delta_state_no_changes_collapses_to_one_line():
|
||||
from backend.apps.agents.browser.browser_agent import _delta_state
|
||||
from backend.apps.agents.browser.browser_agent import delta_state
|
||||
rows = "\n".join(f'[{i}]<link "l{i}">' for i in range(1, 13))
|
||||
seen = set()
|
||||
_delta_state(rows, seen)
|
||||
out = _delta_state(rows, seen)
|
||||
delta_state(rows, seen)
|
||||
out = delta_state(rows, seen)
|
||||
assert out.startswith("(all 12 element rows unchanged")
|
||||
|
||||
|
||||
def test_delta_state_reshuffle_resends_full():
|
||||
from backend.apps.agents.browser.browser_agent import _delta_state
|
||||
from backend.apps.agents.browser.browser_agent import delta_state
|
||||
seen = set()
|
||||
_delta_state("\n".join(f'[{i}]<button "a{i}">' for i in range(1, 11)), seen)
|
||||
delta_state("\n".join(f'[{i}]<button "a{i}">' for i in range(1, 11)), seen)
|
||||
new_page = "10 interactive elements:\n" + "\n".join(f'[{i}]<button "z{i}">' for i in range(1, 11))
|
||||
assert _delta_state(new_page, seen) == new_page
|
||||
assert delta_state(new_page, seen) == new_page
|
||||
|
||||
|
||||
def test_informational_gate_judges_the_task_ask_first():
|
||||
@@ -1553,58 +1553,58 @@ def test_recoverable_tool_error_classifier():
|
||||
|
||||
|
||||
def test_message_pairing_validator_catches_both_orphan_and_dangling():
|
||||
from backend.apps.agents.browser.browser_history import _validate_message_pairing
|
||||
from backend.apps.agents.browser.browser_history import validate_message_pairing
|
||||
au = lambda i: {"role": "assistant", "content": [{"type": "tool_use", "id": i, "name": "X", "input": {}}]}
|
||||
tr = lambda i: {"role": "user", "content": [{"type": "tool_result", "tool_use_id": i, "content": []}]}
|
||||
# well-formed: every tool_use answered
|
||||
assert _validate_message_pairing([au("t1"), tr("t1")]) is True
|
||||
assert validate_message_pairing([au("t1"), tr("t1")]) is True
|
||||
# DANGLING tool_use (the exact 400: a call with no result) -> invalid
|
||||
assert _validate_message_pairing([au("t1")]) is False
|
||||
assert _validate_message_pairing([au("t1"), tr("t1"), au("t2")]) is False
|
||||
assert validate_message_pairing([au("t1")]) is False
|
||||
assert validate_message_pairing([au("t1"), tr("t1"), au("t2")]) is False
|
||||
# ORPHAN tool_result (result for a never-declared id) -> invalid
|
||||
assert _validate_message_pairing([tr("ghost")]) is False
|
||||
assert validate_message_pairing([tr("ghost")]) is False
|
||||
# plain text turns are fine
|
||||
assert _validate_message_pairing([{"role": "user", "content": "hi"},
|
||||
assert validate_message_pairing([{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "done"}]) is True
|
||||
|
||||
|
||||
def test_composer_fill_detection():
|
||||
# detecting a composer fill is what arms the post-type wait for the Send button
|
||||
# to render before we re-list (so the model sees it instead of hunting)
|
||||
from backend.apps.agents.browser.browser_agent import _is_composer_fill
|
||||
assert _is_composer_fill("BrowserClickIndex", {"index": 4, "text": "hello world"})
|
||||
assert _is_composer_fill("BrowserType", {"selector": "#m", "text": "hi"})
|
||||
assert _is_composer_fill("BrowserBatch", {"actions": [
|
||||
from backend.apps.agents.browser.browser_agent import is_composer_fill
|
||||
assert is_composer_fill("BrowserClickIndex", {"index": 4, "text": "hello world"})
|
||||
assert is_composer_fill("BrowserType", {"selector": "#m", "text": "hi"})
|
||||
assert is_composer_fill("BrowserBatch", {"actions": [
|
||||
{"type": "click_index", "params": {"index": 4, "text": "hi there"}}]})
|
||||
# a plain click (no text) is NOT a fill
|
||||
assert not _is_composer_fill("BrowserClickIndex", {"index": 4})
|
||||
assert not _is_composer_fill("BrowserScroll", {})
|
||||
assert not is_composer_fill("BrowserClickIndex", {"index": 4})
|
||||
assert not is_composer_fill("BrowserScroll", {})
|
||||
|
||||
|
||||
def test_send_index_handoff_points_only_at_a_real_send_button():
|
||||
# after a composer fill we hand the model the Send button's index so it clicks
|
||||
# it directly instead of hunting; must never mistake an upsell/profile link for it
|
||||
from backend.apps.agents.browser.browser_agent import _send_index_in_state
|
||||
from backend.apps.agents.browser.browser_agent import send_index_in_state
|
||||
page = '[1]<link "Tyler Chen">\n[33]<textbox "Write a message">\n[44]<button "Send">'
|
||||
assert _send_index_in_state(page) == (44, "Send")
|
||||
assert _send_index_in_state('[12]<button "Send InMail credit">') is None
|
||||
assert _send_index_in_state('[5]<button "Send a message to Maya">') is None
|
||||
assert _send_index_in_state("") is None
|
||||
assert send_index_in_state(page) == (44, "Send")
|
||||
assert send_index_in_state('[12]<button "Send InMail credit">') is None
|
||||
assert send_index_in_state('[5]<button "Send a message to Maya">') is None
|
||||
assert send_index_in_state("") is None
|
||||
|
||||
|
||||
def test_strip_lone_surrogates():
|
||||
from backend.apps.agents.browser.browser_agent import _strip_lone_surrogates, _format_tool_result
|
||||
from backend.apps.agents.browser.browser_agent import strip_lone_surrogates, format_tool_result
|
||||
# an orphan UTF-16 surrogate (half an emoji from the webview) is what crashes
|
||||
# the turn at .encode('utf-8'); it must be swapped, not carried through
|
||||
out = _strip_lone_surrogates("Twitch \ud83e live")
|
||||
out = strip_lone_surrogates("Twitch \ud83e live")
|
||||
assert "\ud83e" not in out and "�" in out
|
||||
out.encode("utf-8") # the operation that used to raise "surrogates not allowed"
|
||||
# valid emoji (a real code point) and plain text are left alone
|
||||
assert _strip_lone_surrogates("cheese \U0001f9c0 ok") == "cheese \U0001f9c0 ok"
|
||||
assert _strip_lone_surrogates("Search Amazon") == "Search Amazon"
|
||||
assert _strip_lone_surrogates("") == ""
|
||||
assert strip_lone_surrogates("cheese \U0001f9c0 ok") == "cheese \U0001f9c0 ok"
|
||||
assert strip_lone_surrogates("Search Amazon") == "Search Amazon"
|
||||
assert strip_lone_surrogates("") == ""
|
||||
# the boundary that feeds the model is sanitized for both result and error text
|
||||
blocks = _format_tool_result({"text": "name \ud83e here"}, "BrowserListInteractives")
|
||||
blocks = format_tool_result({"text": "name \ud83e here"}, "BrowserListInteractives")
|
||||
blocks[0]["text"].encode("utf-8")
|
||||
err = _format_tool_result({"error": "bad \ud83e node"}, "BrowserClickIndex")
|
||||
err = format_tool_result({"error": "bad \ud83e node"}, "BrowserClickIndex")
|
||||
err[0]["text"].encode("utf-8")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from backend.apps.agents.browser.browser_fast_path import (
|
||||
_normalize_for_classifier,
|
||||
_parse_verdict_and_brief,
|
||||
normalize_for_classifier,
|
||||
parse_verdict_and_brief,
|
||||
compose_task,
|
||||
dispatch_failed,
|
||||
fast_path_eligible,
|
||||
@@ -30,15 +30,15 @@ def test_non_browsy_or_gated_messages_fall_through():
|
||||
|
||||
|
||||
def test_verdict_parsing_is_strict():
|
||||
v, brief = _parse_verdict_and_brief("READ\nENTRY: https://news.ycombinator.com\n1. read top story")
|
||||
v, brief = parse_verdict_and_brief("READ\nENTRY: https://news.ycombinator.com\n1. read top story")
|
||||
assert v == "read" and brief.startswith("ENTRY:") and "top story" in brief
|
||||
assert _parse_verdict_and_brief("ACT\nENTRY: https://x.com")[0] == "act"
|
||||
assert _parse_verdict_and_brief("yes") == ("act", "")
|
||||
assert _parse_verdict_and_brief("NO") == ("no", "")
|
||||
assert _parse_verdict_and_brief("Maybe\nENTRY: x") == ("no", "")
|
||||
assert _parse_verdict_and_brief("") == ("no", "")
|
||||
assert parse_verdict_and_brief("ACT\nENTRY: https://x.com")[0] == "act"
|
||||
assert parse_verdict_and_brief("yes") == ("act", "")
|
||||
assert parse_verdict_and_brief("NO") == ("no", "")
|
||||
assert parse_verdict_and_brief("Maybe\nENTRY: x") == ("no", "")
|
||||
assert parse_verdict_and_brief("") == ("no", "")
|
||||
long_brief = "ACT\n" + "x" * 2000
|
||||
assert len(_parse_verdict_and_brief(long_brief)[1]) == 700
|
||||
assert len(parse_verdict_and_brief(long_brief)[1]) == 700
|
||||
|
||||
|
||||
def test_fast_read_entry_extraction_and_thin_detection():
|
||||
@@ -84,14 +84,14 @@ def test_recovery_task_verifies_before_repeating():
|
||||
|
||||
def test_text_normalizes_to_message_without_phone_number():
|
||||
assert (
|
||||
_normalize_for_classifier("go to maya's linkedin and text her thanks")
|
||||
normalize_for_classifier("go to maya's linkedin and text her thanks")
|
||||
== "go to maya's linkedin and message her thanks"
|
||||
)
|
||||
assert _normalize_for_classifier("keep texting until he replies").startswith("keep message")
|
||||
assert normalize_for_classifier("keep texting until he replies").startswith("keep message")
|
||||
sms = "text 4085551234 saying im running late"
|
||||
assert _normalize_for_classifier(sms) == sms
|
||||
assert normalize_for_classifier(sms) == sms
|
||||
count = "count messages containing the exact text r10-os"
|
||||
assert "message r10-os" in _normalize_for_classifier(count)
|
||||
assert "message r10-os" in normalize_for_classifier(count)
|
||||
|
||||
|
||||
def test_dispatch_refused_when_no_dashboard_connected(monkeypatch):
|
||||
@@ -163,7 +163,7 @@ def test_entry_url_extracted_from_brief():
|
||||
|
||||
|
||||
def test_results_url_shapes():
|
||||
from backend.apps.agents.browser.browser_agent import _RESULTS_URL_RE
|
||||
from backend.apps.agents.browser.browser_agent import RESULTS_URL_RE
|
||||
hits = [
|
||||
"https://www.linkedin.com/search/results/people/?keywords=tyler+chen",
|
||||
"https://www.google.com/search?q=anything",
|
||||
@@ -176,6 +176,6 @@ def test_results_url_shapes():
|
||||
"https://www.linkedin.com/messaging/thread/abc123/",
|
||||
]
|
||||
for u in hits:
|
||||
assert _RESULTS_URL_RE.search(u), u
|
||||
assert RESULTS_URL_RE.search(u), u
|
||||
for u in misses:
|
||||
assert not _RESULTS_URL_RE.search(u), u
|
||||
assert not RESULTS_URL_RE.search(u), u
|
||||
|
||||
@@ -4,7 +4,7 @@ from backend.apps.agents.browser import browser_history as bh
|
||||
|
||||
|
||||
def setup_function(_):
|
||||
bh._domain_notes.clear()
|
||||
bh.DOMAIN_NOTES.clear()
|
||||
|
||||
|
||||
def test_set_get_roundtrip():
|
||||
@@ -14,7 +14,7 @@ def test_set_get_roundtrip():
|
||||
|
||||
def test_caps_length():
|
||||
bh.set_domain_note("x.com", "a" * 5000)
|
||||
assert len(bh.get_domain_note("x.com")) == bh._MAX_DOMAIN_NOTE_CHARS
|
||||
assert len(bh.get_domain_note("x.com")) == bh.MAX_DOMAIN_NOTE_CHARS
|
||||
|
||||
|
||||
def test_ignores_empty_domain_or_note():
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
"""Hot-path waste removals in the browser sub-agent loop.
|
||||
|
||||
Two per-action costs that were pure waste:
|
||||
1. browser_metrics._metrics_dir() ran os.makedirs() on EVERY tool call.
|
||||
1. browser_metrics.metrics_dir() ran os.makedirs() on EVERY tool call.
|
||||
2. The loop-detection hash serialized a tool's full result (a ~1MB screenshot
|
||||
or 15KB read) even for tools that are excluded from loop detection, where
|
||||
_detect_loop ignores the hash entirely. These pin both fixes.
|
||||
detect_loop ignores the hash entirely. These pin both fixes.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import backend.apps.agents.browser.browser_metrics as M
|
||||
from backend.apps.agents.browser.browser_loop import (
|
||||
_detect_loop,
|
||||
_LOOP_DETECTION_EXCLUDED_TOOLS,
|
||||
detect_loop,
|
||||
LOOP_DETECTION_EXCLUDED_TOOLS,
|
||||
)
|
||||
|
||||
|
||||
def test_metrics_dir_is_cached_makedirs_runs_once(monkeypatch):
|
||||
M._metrics_dir_cache = None
|
||||
M.p_metrics_dir_cache = None
|
||||
calls = {"n": 0}
|
||||
real = os.makedirs
|
||||
|
||||
@@ -26,9 +26,9 @@ def test_metrics_dir_is_cached_makedirs_runs_once(monkeypatch):
|
||||
return real(*a, **k)
|
||||
|
||||
monkeypatch.setattr(os, "makedirs", counting)
|
||||
d1 = M._metrics_dir()
|
||||
d2 = M._metrics_dir()
|
||||
d3 = M._metrics_dir()
|
||||
d1 = M.metrics_dir()
|
||||
d2 = M.metrics_dir()
|
||||
d3 = M.metrics_dir()
|
||||
assert d1 == d2 == d3
|
||||
assert calls["n"] == 1, f"makedirs must run once, ran {calls['n']}x"
|
||||
|
||||
@@ -37,9 +37,9 @@ def test_excluded_tools_never_register_a_loop():
|
||||
# The invariant the hash-skip relies on: for every excluded tool, even ten
|
||||
# identical calls in a row are NOT a loop, so computing/storing the hash for
|
||||
# them was dead work. Setting is_loop=False directly is therefore equivalent.
|
||||
for tool in _LOOP_DETECTION_EXCLUDED_TOOLS:
|
||||
for tool in LOOP_DETECTION_EXCLUDED_TOOLS:
|
||||
key = (tool, "in", "out")
|
||||
assert _detect_loop([key] * 10, key) is False, f"{tool} wrongly looped"
|
||||
assert detect_loop([key] * 10, key) is False, f"{tool} wrongly looped"
|
||||
|
||||
|
||||
def test_non_excluded_tool_still_loops_after_threshold():
|
||||
@@ -47,6 +47,6 @@ def test_non_excluded_tool_still_loops_after_threshold():
|
||||
# that need it (clicks/types/etc.).
|
||||
key = ("BrowserClick", '{"selector":"#x"}', "clicked")
|
||||
# below threshold -> not a loop; at/over threshold within the window -> loop
|
||||
assert _detect_loop([], key) is False # 1st occurrence: not yet a wall
|
||||
assert _detect_loop([key], key) is True # 2nd identical (threshold=2): a wall
|
||||
assert _detect_loop([key] * 5, key) is True
|
||||
assert detect_loop([], key) is False # 1st occurrence: not yet a wall
|
||||
assert detect_loop([key], key) is True # 2nd identical (threshold=2): a wall
|
||||
assert detect_loop([key] * 5, key) is True
|
||||
|
||||
@@ -33,13 +33,13 @@ def test_absorb_adds_dedups_and_caps():
|
||||
assert meta.absorb([]) is False
|
||||
# capped: flooding never exceeds the cap
|
||||
meta.absorb([f"unique universal lesson number {i}" for i in range(50)])
|
||||
assert len(meta.get_meta()) <= meta._MAX_BULLETS
|
||||
assert len(meta.get_meta()) <= meta.MAX_BULLETS
|
||||
|
||||
|
||||
def test_survives_a_restart():
|
||||
meta.absorb(["a durable cross-site lesson worth keeping"])
|
||||
meta.clear(wipe_disk=False) # in-memory gone, disk intact (== restart)
|
||||
assert meta._cache is None
|
||||
assert meta.CACHE is None
|
||||
assert any("durable cross-site lesson" in x for x in meta.get_meta())
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ def metrics(monkeypatch):
|
||||
from backend.apps.agents.browser import browser_metrics as bm
|
||||
# The dir is memoized once for the prod hot path; drop the cache so each test
|
||||
# re-resolves to its own temp dir instead of inheriting a prior test's.
|
||||
bm._metrics_dir_cache = None
|
||||
bm.p_metrics_dir_cache = None
|
||||
return bm, d
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ def test_metrics_never_raises_on_bad_dir(monkeypatch):
|
||||
# An unwritable dir must not throw into the agent loop.
|
||||
monkeypatch.setenv("OPENSWARM_BROWSER_METRICS_DIR", "/proc/cannot/write/here")
|
||||
from backend.apps.agents.browser import browser_metrics as bm
|
||||
bm._metrics_dir_cache = None # re-resolve so we actually hit the bad dir
|
||||
bm.p_metrics_dir_cache = None # re-resolve so we actually hit the bad dir
|
||||
bm.record_tool("s", "b", 1, "BrowserScreenshot", 5, ok=True, error="",
|
||||
is_loop=False, stagnation_streak=0, result_len=1) # must not raise
|
||||
bm.record_task("s", "b", "t", "error", __import__("time").time(), 1, [], {})
|
||||
@@ -94,7 +94,7 @@ def test_task_secrets_are_scrubbed_from_tasks_jsonl(tmp_path, monkeypatch):
|
||||
import os as _os
|
||||
import time as _time
|
||||
monkeypatch.setenv("OPENSWARM_BROWSER_METRICS_DIR", str(tmp_path))
|
||||
bm._metrics_dir_cache = None
|
||||
bm.p_metrics_dir_cache = None
|
||||
bm.record_task("s1", "b1", "log into acme with password hunter2 then post sk-abc12345678901234567",
|
||||
"completed", _time.time() - 1, 2, [], {})
|
||||
line = open(_os.path.join(str(tmp_path), "tasks.jsonl")).read()
|
||||
|
||||
@@ -103,7 +103,7 @@ async def test_playbook_is_capped():
|
||||
pb.clear(wipe_disk=True)
|
||||
many = [f"strategy bullet number {i}" for i in range(20)]
|
||||
await _distill("big.com", "t", "m", "s", FakeAux(_pb(*many)))
|
||||
assert len(pb.get_playbook("big.com")) <= pb._MAX_BULLETS
|
||||
assert len(pb.get_playbook("big.com")) <= pb.MAX_BULLETS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -119,7 +119,7 @@ async def test_playbook_survives_a_restart():
|
||||
pb.clear(wipe_disk=True)
|
||||
await _distill("persist.com", "t", "m", "s", FakeAux(_pb("durable lesson one", "durable lesson two")))
|
||||
pb.clear(wipe_disk=False) # restart: memory gone, disk intact
|
||||
assert not pb._cache
|
||||
assert not pb.CACHE
|
||||
bullets = pb.get_playbook("persist.com")
|
||||
assert len(bullets) == 2 and "durable lesson one" in bullets
|
||||
|
||||
@@ -184,6 +184,6 @@ def test_seed_playbook_fallback_and_supersede():
|
||||
assert seeded and any("github.com/search" in b for b in seeded)
|
||||
# a learned playbook supersedes the seed (real usage wins)
|
||||
pb.clear(wipe_disk=True)
|
||||
pb._persist("github.com", ["learned: use the org filter"])
|
||||
pb._cache.clear()
|
||||
pb.persist("github.com", ["learned: use the org filter"])
|
||||
pb.CACHE.clear()
|
||||
assert pb.get_playbook("github.com") == ["learned: use the org filter"]
|
||||
|
||||
@@ -4,7 +4,7 @@ and extension allowlist are tested hard, including hostile filenames."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from backend.apps.agents.browser.browser_save import save_page_data, _ALLOWED_EXT, _MAX_BYTES, _SUBDIR
|
||||
from backend.apps.agents.browser.browser_save import save_page_data, ALLOWED_EXT, MAX_BYTES, SUBDIR
|
||||
|
||||
|
||||
def test_happy_path_writes_into_browser_data_subdir(tmp_path):
|
||||
@@ -12,7 +12,7 @@ def test_happy_path_writes_into_browser_data_subdir(tmp_path):
|
||||
msg = save_page_data(str(tmp_path), "sid", "rows.json", payload)
|
||||
assert msg.startswith("Saved")
|
||||
assert "3 items" in msg
|
||||
out = tmp_path / _SUBDIR / "rows.json"
|
||||
out = tmp_path / SUBDIR / "rows.json"
|
||||
assert out.is_file()
|
||||
assert json.loads(out.read_text()) == json.loads(payload)
|
||||
|
||||
@@ -26,7 +26,7 @@ def test_traversal_filename_is_confined_not_escaped(tmp_path):
|
||||
# a '../../evil.json' must NOT land outside the sandbox; basename flattens it
|
||||
msg = save_page_data(str(tmp_path), "sid", "../../evil.json", "[]")
|
||||
assert msg.startswith("Saved")
|
||||
assert (tmp_path / _SUBDIR / "evil.json").is_file()
|
||||
assert (tmp_path / SUBDIR / "evil.json").is_file()
|
||||
# nothing was written two levels up
|
||||
assert not (tmp_path.parent.parent / "evil.json").exists()
|
||||
|
||||
@@ -34,7 +34,7 @@ def test_traversal_filename_is_confined_not_escaped(tmp_path):
|
||||
def test_absolute_path_filename_is_confined(tmp_path):
|
||||
msg = save_page_data(str(tmp_path), "sid", "/etc/evil.json", "[]")
|
||||
assert msg.startswith("Saved")
|
||||
assert (tmp_path / _SUBDIR / "evil.json").is_file()
|
||||
assert (tmp_path / SUBDIR / "evil.json").is_file()
|
||||
assert not os.path.exists("/etc/evil.json")
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_disallowed_extension_is_rejected(tmp_path):
|
||||
msg = save_page_data(str(tmp_path), "sid", bad, "data")
|
||||
assert msg.startswith("Save failed"), bad
|
||||
# the allowed ones all pass
|
||||
for good in sorted(_ALLOWED_EXT):
|
||||
for good in sorted(ALLOWED_EXT):
|
||||
msg = save_page_data(str(tmp_path), "sid", f"file{good}", "x")
|
||||
assert msg.startswith("Saved"), good
|
||||
|
||||
@@ -54,21 +54,21 @@ def test_empty_filename_is_rejected(tmp_path):
|
||||
|
||||
|
||||
def test_oversize_payload_is_rejected(tmp_path):
|
||||
big = "x" * (_MAX_BYTES + 1)
|
||||
big = "x" * (MAX_BYTES + 1)
|
||||
msg = save_page_data(str(tmp_path), "sid", "big.txt", big)
|
||||
assert msg.startswith("Save failed")
|
||||
assert not (tmp_path / _SUBDIR / "big.txt").exists()
|
||||
assert not (tmp_path / SUBDIR / "big.txt").exists()
|
||||
|
||||
|
||||
def test_falls_back_to_home_workspace_when_no_cwd(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(os.path, "expanduser", lambda p: str(tmp_path))
|
||||
msg = save_page_data(None, "sess-xyz", "f.json", "[]")
|
||||
assert msg.startswith("Saved")
|
||||
assert (tmp_path / ".openswarm" / "workspaces" / "sess-xyz" / _SUBDIR / "f.json").is_file()
|
||||
assert (tmp_path / ".openswarm" / "workspaces" / "sess-xyz" / SUBDIR / "f.json").is_file()
|
||||
|
||||
|
||||
def test_non_json_content_still_saves_without_count(tmp_path):
|
||||
msg = save_page_data(str(tmp_path), "sid", "notes.txt", "just some text")
|
||||
assert msg.startswith("Saved")
|
||||
assert "items" not in msg and "keys" not in msg
|
||||
assert (tmp_path / _SUBDIR / "notes.txt").read_text() == "just some text"
|
||||
assert (tmp_path / SUBDIR / "notes.txt").read_text() == "just some text"
|
||||
|
||||
@@ -8,7 +8,7 @@ text stay). These pin the keep-set, the in-place mutation, and tool_result safet
|
||||
|
||||
from backend.apps.agents.browser.browser_history import (
|
||||
prune_old_screenshots,
|
||||
_OMITTED_SCREENSHOT_STUB,
|
||||
OMITTED_SCREENSHOT_STUB,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_stub_preserves_the_url_text_block():
|
||||
prune_old_screenshots(msgs)
|
||||
# the collapsed shot (#1) keeps its "URL:" text, only the image became a stub
|
||||
collapsed_tr = msgs[1]["content"][0]["content"]
|
||||
assert any(b.get("text") == _OMITTED_SCREENSHOT_STUB for b in collapsed_tr)
|
||||
assert any(b.get("text") == OMITTED_SCREENSHOT_STUB for b in collapsed_tr)
|
||||
assert any("URL: https://site/1" in b.get("text", "") for b in collapsed_tr)
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ def test_handles_direct_image_blocks_too():
|
||||
]
|
||||
collapsed = prune_old_screenshots(msgs)
|
||||
assert collapsed == 1 # keep a (first), c+d (last two); stub b
|
||||
assert msgs[1]["content"][0] == {"type": "text", "text": _OMITTED_SCREENSHOT_STUB}
|
||||
assert msgs[1]["content"][0] == {"type": "text", "text": OMITTED_SCREENSHOT_STUB}
|
||||
|
||||
|
||||
def test_keep_recent_is_tunable():
|
||||
|
||||
@@ -65,9 +65,9 @@ def test_audit_fires_every_n_finished_tasks(monkeypatch, tmp_path):
|
||||
# threads synchronous so the test is deterministic, and use a small N.
|
||||
from backend.apps.agents.browser import browser_metrics as m
|
||||
monkeypatch.setenv("OPENSWARM_BROWSER_METRICS_DIR", str(tmp_path))
|
||||
m._metrics_dir_cache = None
|
||||
m._task_count = 0
|
||||
monkeypatch.setattr(m, "_AUDIT_EVERY_N", 5)
|
||||
m.p_metrics_dir_cache = None
|
||||
m.p_task_count = 0
|
||||
monkeypatch.setattr(m, "P_AUDIT_EVERY_N", 5)
|
||||
|
||||
class _SyncThread:
|
||||
def __init__(self, target=None, **kw):
|
||||
|
||||
@@ -124,7 +124,7 @@ def test_skill_persists_across_restart(_isolated_skills):
|
||||
# find must re-load it from disk.
|
||||
assert sk.record_skill("localhost:8901", "type hello and click Send", _log()) is True
|
||||
sk.clear(wipe_disk=False) # in-memory gone, disk intact (== restart)
|
||||
assert not sk._skills # cache truly empty
|
||||
assert not sk.SKILLS # cache truly empty
|
||||
found = sk.find_skill("localhost:8901", "type hello and click Send")
|
||||
assert found is not None and found.get("persisted") is True
|
||||
assert [s["tool"] for s in found["steps"]] == ["BrowserNavigate", "BrowserType", "BrowserClickByName"]
|
||||
@@ -138,7 +138,7 @@ def test_sensitive_text_is_NOT_persisted(_isolated_skills):
|
||||
]
|
||||
assert sk.record_skill("site.com", "enter email and submit", log) is True # stored in memory
|
||||
# nothing on disk for this skill
|
||||
path = sk._skill_path("site.com", sk.normalize_task("enter email and submit"))
|
||||
path = sk.skill_path("site.com", sk.normalize_task("enter email and submit"))
|
||||
assert path is not None and not os.path.exists(path)
|
||||
# and after a "restart" it's gone (was never persisted)
|
||||
sk.clear(wipe_disk=False)
|
||||
@@ -151,18 +151,18 @@ def test_password_field_selector_blocks_persistence(_isolated_skills):
|
||||
{"tool": "BrowserClickIndex", "input": {}, "ok": True, "clicked_role": "button", "clicked_name": "Log in"},
|
||||
]
|
||||
sk.record_skill("site.com", "log in", log)
|
||||
assert not os.path.exists(sk._skill_path("site.com", sk.normalize_task("log in")))
|
||||
assert not os.path.exists(sk.skill_path("site.com", sk.normalize_task("log in")))
|
||||
|
||||
|
||||
def test_sensitivity_detector():
|
||||
assert sk._looks_sensitive("eric@example.com")
|
||||
assert sk._looks_sensitive("4111 1111 1111 1111") # card-shaped
|
||||
assert sk._looks_sensitive("123-45-6789") # ssn
|
||||
assert sk._looks_sensitive("sk-ant-api03-abc123") # token prefix
|
||||
assert sk._looks_sensitive("anything", selector="#pwd") # password field
|
||||
assert sk._looks_sensitive("aB3xK9mQ2pL7wR4tY8nZ") # long high-entropy
|
||||
assert not sk._looks_sensitive("hello world")
|
||||
assert not sk._looks_sensitive("openswarm", selector="#search")
|
||||
assert sk.looks_sensitive("eric@example.com")
|
||||
assert sk.looks_sensitive("4111 1111 1111 1111") # card-shaped
|
||||
assert sk.looks_sensitive("123-45-6789") # ssn
|
||||
assert sk.looks_sensitive("sk-ant-api03-abc123") # token prefix
|
||||
assert sk.looks_sensitive("anything", selector="#pwd") # password field
|
||||
assert sk.looks_sensitive("aB3xK9mQ2pL7wR4tY8nZ") # long high-entropy
|
||||
assert not sk.looks_sensitive("hello world")
|
||||
assert not sk.looks_sensitive("openswarm", selector="#search")
|
||||
|
||||
|
||||
def test_navigate_url_userinfo_and_fragment_stripped_on_disk(_isolated_skills):
|
||||
@@ -172,7 +172,7 @@ def test_navigate_url_userinfo_and_fragment_stripped_on_disk(_isolated_skills):
|
||||
]
|
||||
# userinfo in the URL makes the whole skill non-persistable (credentialed URL)
|
||||
sk.record_skill("site.com", "search shoes", log)
|
||||
assert not os.path.exists(sk._skill_path("site.com", sk.normalize_task("search shoes")))
|
||||
assert not os.path.exists(sk.skill_path("site.com", sk.normalize_task("search shoes")))
|
||||
# but a clean URL with a fragment persists with the fragment stripped
|
||||
log2 = [
|
||||
{"tool": "BrowserNavigate", "input": {"url": "https://site.com/app#section"}, "ok": True},
|
||||
@@ -189,7 +189,7 @@ def test_navigate_url_userinfo_and_fragment_stripped_on_disk(_isolated_skills):
|
||||
def test_format_version_mismatch_is_ignored(_isolated_skills, monkeypatch):
|
||||
sk.record_skill("v.com", "do a thing now", _log())
|
||||
sk.clear(wipe_disk=False)
|
||||
monkeypatch.setattr(sk, "_SKILL_FORMAT_VERSION", 999) # pretend the format moved on
|
||||
monkeypatch.setattr(sk, "P_SKILL_FORMAT_VERSION", 999) # pretend the format moved on
|
||||
assert sk.find_skill("v.com", "do a thing now") is None
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ def test_parameterized_value_is_not_persisted(_isolated_skills):
|
||||
{"tool": "BrowserClickIndex", "input": {}, "ok": True, "clicked_role": "button", "clicked_name": "Search"},
|
||||
]
|
||||
sk.record_skill("shop.com", 'search for "running shoes"', log)
|
||||
path = sk._skill_path("shop.com", sk._sig('search for "running shoes"'))
|
||||
path = sk.skill_path("shop.com", sk.compute_sig('search for "running shoes"'))
|
||||
blob = open(path).read()
|
||||
assert "running shoes" not in blob # the quoted value never hits disk
|
||||
assert '"value_slot": 0' in blob or '"value_slot":0' in blob
|
||||
@@ -263,11 +263,11 @@ def test_list_skills_reads_disk_after_restart(_isolated_skills):
|
||||
|
||||
def test_deprecate_removes_skill_from_memory_and_disk(_isolated_skills):
|
||||
sk.record_skill("shop.com", "search for shoes now", _log())
|
||||
sig = sk._sig("search for shoes now")
|
||||
assert os.path.exists(sk._skill_path("shop.com", sig))
|
||||
sig = sk.compute_sig("search for shoes now")
|
||||
assert os.path.exists(sk.skill_path("shop.com", sig))
|
||||
# deprecate using the task_sig as list_skills would surface it
|
||||
assert sk.deprecate_skill("shop.com", sig) is True
|
||||
assert not os.path.exists(sk._skill_path("shop.com", sig))
|
||||
assert not os.path.exists(sk.skill_path("shop.com", sig))
|
||||
assert sk.find_skill("shop.com", "search for shoes now") is None
|
||||
|
||||
|
||||
@@ -283,14 +283,14 @@ def test_deprecate_unknown_is_false(_isolated_skills):
|
||||
def test_new_skill_starts_on_probation(_isolated_skills):
|
||||
sk.record_skill("shop.com", "do a thing now", _log())
|
||||
s = sk.find_skill("shop.com", "do a thing now")
|
||||
assert s["state"] == sk._PROBATION and s["rev"] == 1 and s["replays"] == 0
|
||||
assert s["state"] == sk.PROBATION and s["rev"] == 1 and s["replays"] == 0
|
||||
|
||||
|
||||
def test_replay_success_promotes_probation_to_trusted(_isolated_skills):
|
||||
sk.record_skill("shop.com", "do a thing now", _log())
|
||||
sk.mark_replay_succeeded("shop.com", "do a thing now")
|
||||
s = sk.find_skill("shop.com", "do a thing now")
|
||||
assert s["state"] == sk._TRUSTED and s["replays"] == 1 and s["fails"] == 0
|
||||
assert s["state"] == sk.TRUSTED and s["replays"] == 1 and s["fails"] == 0
|
||||
|
||||
|
||||
def test_probation_failure_quarantines_and_blocks_future_replay(_isolated_skills):
|
||||
@@ -301,7 +301,7 @@ def test_probation_failure_quarantines_and_blocks_future_replay(_isolated_skills
|
||||
assert sk.find_skill("shop.com", "do a thing now") is None
|
||||
# ...but the record still exists (visible + deprecatable), it just won't run
|
||||
listed = sk.list_skills("shop.com")
|
||||
assert len(listed) == 1 and listed[0]["state"] == sk._QUARANTINE
|
||||
assert len(listed) == 1 and listed[0]["state"] == sk.QUARANTINE
|
||||
|
||||
|
||||
def test_quarantined_skill_re_recorded_identical_stays_quarantined(_isolated_skills):
|
||||
@@ -311,7 +311,7 @@ def test_quarantined_skill_re_recorded_identical_stays_quarantined(_isolated_ski
|
||||
sk.record_skill("shop.com", "do a thing now", _log())
|
||||
# it must stay quarantined -> pure-LLM baseline, never a wasted replay again
|
||||
assert sk.find_skill("shop.com", "do a thing now") is None
|
||||
assert sk.list_skills("shop.com")[0]["state"] == sk._QUARANTINE
|
||||
assert sk.list_skills("shop.com")[0]["state"] == sk.QUARANTINE
|
||||
|
||||
|
||||
def test_quarantined_skill_unquarantines_on_a_real_edit(_isolated_skills):
|
||||
@@ -323,7 +323,7 @@ def test_quarantined_skill_unquarantines_on_a_real_edit(_isolated_skills):
|
||||
"clicked_role": "button", "clicked_name": "Submit"}]
|
||||
sk.record_skill("shop.com", "do a thing now", edited)
|
||||
s = sk.find_skill("shop.com", "do a thing now")
|
||||
assert s is not None and s["state"] == sk._PROBATION and s["rev"] == 2
|
||||
assert s is not None and s["state"] == sk.PROBATION and s["rev"] == 2
|
||||
|
||||
|
||||
def test_trusted_skill_tolerates_one_transient_miss_then_demotes(_isolated_skills):
|
||||
@@ -331,9 +331,9 @@ def test_trusted_skill_tolerates_one_transient_miss_then_demotes(_isolated_skill
|
||||
sk.mark_replay_succeeded("shop.com", "do a thing now") # trusted
|
||||
assert sk.mark_replay_failed("shop.com", "do a thing now") == "kept"
|
||||
s = sk.find_skill("shop.com", "do a thing now")
|
||||
assert s["state"] == sk._TRUSTED and s["fails"] == 1 # still usable
|
||||
assert s["state"] == sk.TRUSTED and s["fails"] == 1 # still usable
|
||||
assert sk.mark_replay_failed("shop.com", "do a thing now") == "demoted"
|
||||
assert sk.find_skill("shop.com", "do a thing now")["state"] == sk._PROBATION
|
||||
assert sk.find_skill("shop.com", "do a thing now")["state"] == sk.PROBATION
|
||||
|
||||
|
||||
def test_re_record_identical_keeps_trust_and_rev(_isolated_skills):
|
||||
@@ -342,7 +342,7 @@ def test_re_record_identical_keeps_trust_and_rev(_isolated_skills):
|
||||
sk.find_skill("shop.com", "do a thing now")["replays"] = 5 # pretend reused a lot
|
||||
sk.record_skill("shop.com", "do a thing now", _log()) # identical re-derive
|
||||
s = sk.find_skill("shop.com", "do a thing now")
|
||||
assert s["state"] == sk._TRUSTED and s["rev"] == 1 and s["replays"] == 5
|
||||
assert s["state"] == sk.TRUSTED and s["rev"] == 1 and s["replays"] == 5
|
||||
|
||||
|
||||
def test_re_record_different_is_an_edit_that_reversions_to_probation(_isolated_skills):
|
||||
@@ -352,7 +352,7 @@ def test_re_record_different_is_an_edit_that_reversions_to_probation(_isolated_s
|
||||
"clicked_role": "button", "clicked_name": "Submit"}]
|
||||
sk.record_skill("shop.com", "do a thing now", edited) # different -> EDIT
|
||||
s = sk.find_skill("shop.com", "do a thing now")
|
||||
assert s["rev"] == 2 and s["state"] == sk._PROBATION and s["replays"] == 0
|
||||
assert s["rev"] == 2 and s["state"] == sk.PROBATION and s["replays"] == 0
|
||||
cbn = next(x for x in s["steps"] if x["tool"] == "BrowserClickByName")
|
||||
assert cbn["params"]["name"] == "Submit" # the new step stuck
|
||||
|
||||
@@ -365,7 +365,7 @@ def test_rev_and_state_persist_across_restart(_isolated_skills):
|
||||
sk.record_skill("shop.com", "do a thing now", edited) # rev 2, probation
|
||||
sk.clear(wipe_disk=False) # restart
|
||||
s = sk.find_skill("shop.com", "do a thing now")
|
||||
assert s["rev"] == 2 and s["state"] == sk._PROBATION
|
||||
assert s["rev"] == 2 and s["state"] == sk.PROBATION
|
||||
|
||||
|
||||
def test_steps_equal_distinguishes_slot_from_literal_and_changed_click():
|
||||
@@ -375,9 +375,9 @@ def test_steps_equal_distinguishes_slot_from_literal_and_changed_click():
|
||||
slot = {"tool": "BrowserType", "params": {"selector": "#q", "value_slot": 0}}
|
||||
send = {"tool": "BrowserClickByName", "params": {"role": "button", "name": "Send"}}
|
||||
submit = {"tool": "BrowserClickByName", "params": {"role": "button", "name": "Submit"}}
|
||||
assert sk._steps_equal([nav], [nav2]) # fragment-only diff is NOT an edit
|
||||
assert not sk._steps_equal([lit], [slot]) # literal vs parameterized IS an edit
|
||||
assert not sk._steps_equal([send], [submit]) # renamed button IS an edit
|
||||
assert sk.steps_equal([nav], [nav2]) # fragment-only diff is NOT an edit
|
||||
assert not sk.steps_equal([lit], [slot]) # literal vs parameterized IS an edit
|
||||
assert not sk.steps_equal([send], [submit]) # renamed button IS an edit
|
||||
|
||||
|
||||
def test_mark_replay_helpers_on_unknown_are_safe(_isolated_skills):
|
||||
@@ -390,9 +390,9 @@ def test_demoted_skill_can_be_re_proven(_isolated_skills):
|
||||
sk.mark_replay_succeeded("shop.com", "do a thing now") # trusted
|
||||
sk.mark_replay_failed("shop.com", "do a thing now")
|
||||
sk.mark_replay_failed("shop.com", "do a thing now") # demoted to probation
|
||||
assert sk.find_skill("shop.com", "do a thing now")["state"] == sk._PROBATION
|
||||
assert sk.find_skill("shop.com", "do a thing now")["state"] == sk.PROBATION
|
||||
sk.mark_replay_succeeded("shop.com", "do a thing now") # earns trust back
|
||||
assert sk.find_skill("shop.com", "do a thing now")["state"] == sk._TRUSTED
|
||||
assert sk.find_skill("shop.com", "do a thing now")["state"] == sk.TRUSTED
|
||||
|
||||
|
||||
# --- composition: build on what's already proven, propagate staleness -------
|
||||
@@ -412,7 +412,7 @@ def test_composition_links_to_trusted_sub_skill(_isolated_skills):
|
||||
_trust("shop.com", "search shoes now", _log()) # trusted foundation
|
||||
sk.record_skill("shop.com", "search shoes and checkout now", _log_plus())
|
||||
c = sk.find_skill("shop.com", "search shoes and checkout now")
|
||||
assert c["composed_of"] == [sk._sig("search shoes now")]
|
||||
assert c["composed_of"] == [sk.compute_sig("search shoes now")]
|
||||
|
||||
|
||||
def test_composition_ignores_untrusted_foundation(_isolated_skills):
|
||||
@@ -425,11 +425,11 @@ def test_composition_ignores_untrusted_foundation(_isolated_skills):
|
||||
def test_deprecating_a_foundation_demotes_everything_built_on_it(_isolated_skills):
|
||||
_trust("shop.com", "search shoes now", _log())
|
||||
_trust("shop.com", "search shoes and checkout now", _log_plus()) # composed + trusted
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk._TRUSTED
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk.TRUSTED
|
||||
sk.deprecate_skill("shop.com", "search shoes now") # foundation pulled
|
||||
# the ghost guard for composition: the dependent must NOT stay trusted on a
|
||||
# foundation that no longer exists; it's knocked back to re-prove
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk._PROBATION
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk.PROBATION
|
||||
|
||||
|
||||
def test_demoting_a_foundation_demotes_its_dependents(_isolated_skills):
|
||||
@@ -437,7 +437,7 @@ def test_demoting_a_foundation_demotes_its_dependents(_isolated_skills):
|
||||
_trust("shop.com", "search shoes and checkout now", _log_plus())
|
||||
sk.mark_replay_failed("shop.com", "search shoes now")
|
||||
sk.mark_replay_failed("shop.com", "search shoes now") # foundation demoted
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk._PROBATION
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk.PROBATION
|
||||
|
||||
|
||||
def test_editing_a_foundation_demotes_its_dependents(_isolated_skills):
|
||||
@@ -446,17 +446,17 @@ def test_editing_a_foundation_demotes_its_dependents(_isolated_skills):
|
||||
edited = _log()[:-1] + [{"tool": "BrowserClickIndex", "input": {}, "ok": True,
|
||||
"clicked_role": "button", "clicked_name": "Find"}]
|
||||
sk.record_skill("shop.com", "search shoes now", edited) # foundation changed
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk._PROBATION
|
||||
assert sk.find_skill("shop.com", "search shoes and checkout now")["state"] == sk.PROBATION
|
||||
|
||||
|
||||
def test_list_skills_surfaces_state_rev_and_builds_on(_isolated_skills):
|
||||
_trust("shop.com", "search shoes now", _log())
|
||||
sk.record_skill("shop.com", "search shoes and checkout now", _log_plus())
|
||||
listed = {x["task"]: x for x in sk.list_skills("shop.com")}
|
||||
foundation = listed[sk._sig("search shoes now")]
|
||||
composed = listed[sk._sig("search shoes and checkout now")]
|
||||
assert foundation["state"] == sk._TRUSTED and foundation["builds_on"] == []
|
||||
assert composed["builds_on"] == [sk._sig("search shoes now")]
|
||||
foundation = listed[sk.compute_sig("search shoes now")]
|
||||
composed = listed[sk.compute_sig("search shoes and checkout now")]
|
||||
assert foundation["state"] == sk.TRUSTED and foundation["builds_on"] == []
|
||||
assert composed["builds_on"] == [sk.compute_sig("search shoes now")]
|
||||
assert "rev" in composed and "steps" in composed
|
||||
|
||||
|
||||
@@ -478,24 +478,24 @@ def test_replay_safety_refuses_send_steps_and_passes_reads():
|
||||
|
||||
|
||||
def test_extract_first_json_strips_fences_and_prose():
|
||||
from backend.apps.agents.browser.browser_extract import _first_json
|
||||
assert _first_json('```json\n{"a": 1}\n```') == '{"a": 1}'
|
||||
assert _first_json('Here you go: [{"n": "x"}] hope that helps') == '[{"n": "x"}]'
|
||||
assert _first_json("no json here") == ""
|
||||
assert _first_json('{"broken": ') == ""
|
||||
from backend.apps.agents.browser.browser_extract import first_json
|
||||
assert first_json('```json\n{"a": 1}\n```') == '{"a": 1}'
|
||||
assert first_json('Here you go: [{"n": "x"}] hope that helps') == '[{"n": "x"}]'
|
||||
assert first_json("no json here") == ""
|
||||
assert first_json('{"broken": ') == ""
|
||||
|
||||
|
||||
def test_widened_redaction_catches_audit_bypasses():
|
||||
# the audit's three named bypasses: bare 2FA digits, credential-shaped
|
||||
# fields the old regex missed, and seed/recovery phrase boxes
|
||||
assert sk._looks_sensitive("481922", "")
|
||||
assert sk._looks_sensitive("hunter2", "#user")
|
||||
assert sk._looks_sensitive("me@corp.com", "#login-email")
|
||||
assert sk._looks_sensitive("correct horse battery staple", "#seed-phrase")
|
||||
assert sk._looks_sensitive("123456", "input[name='verification-code']")
|
||||
assert sk.looks_sensitive("481922", "")
|
||||
assert sk.looks_sensitive("hunter2", "#user")
|
||||
assert sk.looks_sensitive("me@corp.com", "#login-email")
|
||||
assert sk.looks_sensitive("correct horse battery staple", "#seed-phrase")
|
||||
assert sk.looks_sensitive("123456", "input[name='verification-code']")
|
||||
# the bread-and-butter skill (a search query) still persists
|
||||
assert not sk._looks_sensitive("shoes", "#search-input")
|
||||
assert not sk._looks_sensitive("Ada Lovelace", ".search-global-typeahead input")
|
||||
assert not sk.looks_sensitive("shoes", "#search-input")
|
||||
assert not sk.looks_sensitive("Ada Lovelace", ".search-global-typeahead input")
|
||||
|
||||
|
||||
def test_first_unsafe_step_splits_send_skills():
|
||||
@@ -517,13 +517,13 @@ def test_first_unsafe_step_splits_send_skills():
|
||||
|
||||
|
||||
def test_template_task_ignores_possessive_apostrophes():
|
||||
from backend.apps.agents.browser.browser_skills import template_task, _sig
|
||||
from backend.apps.agents.browser.browser_skills import template_task, compute_sig
|
||||
r14 = "go to tyler chen's linkedin hes in entrepreneurs first and text him '[test] hello world r14-os'"
|
||||
r15 = "go to tyler chen's linkedin hes in entrepreneurs first and text him '[test] hello world r15-os'"
|
||||
t14, v14 = template_task(r14)
|
||||
assert v14 == ["[test] hello world r14-os"]
|
||||
assert "chen's linkedin" in t14
|
||||
assert _sig(r14) == _sig(r15)
|
||||
assert compute_sig(r14) == compute_sig(r15)
|
||||
assert template_task("no quotes here at all") == ("no quotes here at all", [])
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Deterministic stagnation detection for the browser sub-agent."""
|
||||
|
||||
from backend.apps.agents.browser.browser_loop import (
|
||||
_STAGNATION_ESCALATION_AT,
|
||||
_STAGNATION_MAX,
|
||||
_looks_like_failure,
|
||||
STAGNATION_ESCALATION_AT,
|
||||
STAGNATION_MAX,
|
||||
looks_like_failure,
|
||||
advance_stagnation,
|
||||
card_is_unavailable,
|
||||
completion_is_honest,
|
||||
@@ -19,14 +19,14 @@ def _fail(url="https://a.com"):
|
||||
|
||||
|
||||
def test_looks_like_failure_positive():
|
||||
assert _looks_like_failure("Element not found: '.foo'")
|
||||
assert _looks_like_failure("Index 4 is no longer valid")
|
||||
assert _looks_like_failure("Error: something broke")
|
||||
assert looks_like_failure("Element not found: '.foo'")
|
||||
assert looks_like_failure("Index 4 is no longer valid")
|
||||
assert looks_like_failure("Error: something broke")
|
||||
|
||||
|
||||
def test_looks_like_failure_negative():
|
||||
assert not _looks_like_failure("Clicked element: button#submit")
|
||||
assert not _looks_like_failure("Typed into: input#email")
|
||||
assert not looks_like_failure("Clicked element: button#submit")
|
||||
assert not looks_like_failure("Typed into: input#email")
|
||||
|
||||
|
||||
def test_error_result_is_unproductive():
|
||||
@@ -63,17 +63,17 @@ def test_neutral_read_tools_never_count():
|
||||
|
||||
def test_nudge_mentions_human_intervention_only_at_max():
|
||||
assert "RequestHumanIntervention" not in stagnation_nudge(3)
|
||||
assert "RequestHumanIntervention" in stagnation_nudge(_STAGNATION_MAX)
|
||||
assert "RequestHumanIntervention" in stagnation_nudge(STAGNATION_MAX)
|
||||
assert "ladder" in stagnation_nudge(3)
|
||||
|
||||
|
||||
def test_advance_increments_on_failures_and_nudges_at_threshold():
|
||||
streak, url, text, nudge = 0, "", "", None
|
||||
nudges = []
|
||||
for _ in range(_STAGNATION_ESCALATION_AT):
|
||||
for _ in range(STAGNATION_ESCALATION_AT):
|
||||
streak, url, text, nudge = advance_stagnation(streak, url, text, "BrowserClick", _fail())
|
||||
nudges.append(nudge)
|
||||
assert streak == _STAGNATION_ESCALATION_AT
|
||||
assert streak == STAGNATION_ESCALATION_AT
|
||||
assert nudges[-1] is not None # nudge fires exactly when the threshold is hit
|
||||
assert nudges[0] is None and nudges[1] is None
|
||||
|
||||
@@ -97,9 +97,9 @@ def test_advance_neutral_tools_pass_through_unchanged():
|
||||
|
||||
|
||||
def test_advance_fires_again_at_max():
|
||||
streak, url, text = _STAGNATION_MAX - 1, "https://a.com", "prev different"
|
||||
streak, url, text = STAGNATION_MAX - 1, "https://a.com", "prev different"
|
||||
streak, url, text, nudge = advance_stagnation(streak, url, text, "BrowserClick", _fail())
|
||||
assert streak == _STAGNATION_MAX
|
||||
assert streak == STAGNATION_MAX
|
||||
assert nudge is not None and "RequestHumanIntervention" in nudge
|
||||
assert stagnation_exhausted(streak)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
|
||||
from backend.apps.agents.browser.browser_validator import adjudicate_stuck, _extract_text
|
||||
from backend.apps.agents.browser.browser_validator import adjudicate_stuck, extract_text
|
||||
|
||||
|
||||
class _Block:
|
||||
@@ -52,7 +52,7 @@ def test_swallows_provider_error_and_returns_empty():
|
||||
|
||||
def test_extract_text_joins_text_blocks_and_ignores_others():
|
||||
resp = _Resp([_Block("text", "First."), _Block("tool_use"), _Block("text", "Second.")])
|
||||
assert _extract_text(resp) == "First. Second."
|
||||
assert extract_text(resp) == "First. Second."
|
||||
|
||||
|
||||
def test_handles_empty_inputs_without_crashing():
|
||||
|
||||
@@ -168,7 +168,7 @@ async def test_hung_tab_returns_fast_not_after_the_full_command_timeout():
|
||||
assert out.get("error") == "page unresponsive"
|
||||
assert elapsed < 3.0, f"hung wait must return fast, took {elapsed:.1f}s"
|
||||
# it bailed after the timeout threshold, not after burning the whole cap
|
||||
assert ex.calls <= bw._MAX_PROBE_TIMEOUTS
|
||||
assert ex.calls <= bw.MAX_PROBE_TIMEOUTS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -68,4 +68,4 @@ def test_tier_mapping_present():
|
||||
# the analyzer's _PRODUCTIVE set must include the real mutation tools
|
||||
m = _load()
|
||||
for t in ("BrowserClick", "BrowserClickIndex", "BrowserType", "BrowserNavigate", "BrowserReplayRoute"):
|
||||
assert t in m._PRODUCTIVE
|
||||
assert t in m.PRODUCTIVE
|
||||
|
||||
@@ -57,14 +57,14 @@ def test_analyzer_measures_replay_speedup_when_the_layer_helps(_metrics_dir, cap
|
||||
sk.clear(wipe_disk=True)
|
||||
# A repeated task: 1 slow LLM run, then 2 fast replays -> measurable speedup.
|
||||
sk.record_skill("shop.com", "search now", _log())
|
||||
_task_row(sk._sig("search now"), "llm", 4.0)
|
||||
_task_row(sk.compute_sig("search now"), "llm", 4.0)
|
||||
sk.mark_replay_succeeded("shop.com", "search now")
|
||||
_task_row(sk._sig("search now"), "replay", 0.04)
|
||||
_task_row(sk._sig("search now"), "replay", 0.05)
|
||||
_task_row(sk.compute_sig("search now"), "replay", 0.04)
|
||||
_task_row(sk.compute_sig("search now"), "replay", 0.05)
|
||||
|
||||
mod = _load_analyzer()
|
||||
tasks = mod._load(os.path.join(_metrics_dir, "tasks.jsonl"))
|
||||
sevs = mod._load(os.path.join(_metrics_dir, "skill_events.jsonl"))
|
||||
tasks = mod.load(os.path.join(_metrics_dir, "tasks.jsonl"))
|
||||
sevs = mod.load(os.path.join(_metrics_dir, "skill_events.jsonl"))
|
||||
mod.skill_layer_report(tasks, sevs)
|
||||
out = capsys.readouterr().out
|
||||
assert "REPLAY SPEEDUP" in out
|
||||
@@ -81,12 +81,12 @@ def test_analyzer_flags_silent_non_help_thrash(_metrics_dir, capsys):
|
||||
"clicked_role": "button", "clicked_name": "Other"}]
|
||||
sk.record_skill("bad.com", "do thing now", edited) # edit (un-quarantine)
|
||||
sk.mark_replay_failed("bad.com", "do thing now") # quarantine again
|
||||
_task_row(sk._sig("do thing now"), "llm", 3.0)
|
||||
_task_row(sk._sig("do thing now"), "llm_fallback", 3.2)
|
||||
_task_row(sk.compute_sig("do thing now"), "llm", 3.0)
|
||||
_task_row(sk.compute_sig("do thing now"), "llm_fallback", 3.2)
|
||||
|
||||
mod = _load_analyzer()
|
||||
tasks = mod._load(os.path.join(_metrics_dir, "tasks.jsonl"))
|
||||
sevs = mod._load(os.path.join(_metrics_dir, "skill_events.jsonl"))
|
||||
tasks = mod.load(os.path.join(_metrics_dir, "tasks.jsonl"))
|
||||
sevs = mod.load(os.path.join(_metrics_dir, "skill_events.jsonl"))
|
||||
mod.skill_layer_report(tasks, sevs)
|
||||
out = capsys.readouterr().out
|
||||
assert "SILENT NON-HELP" in out # repeated but never replayed
|
||||
@@ -104,7 +104,7 @@ def test_analyzer_reports_composition(_metrics_dir, capsys):
|
||||
sk.deprecate_skill("shop.com", "search now") # must invalidate the TRUSTED dependent
|
||||
|
||||
mod = _load_analyzer()
|
||||
sevs = mod._load(os.path.join(_metrics_dir, "skill_events.jsonl"))
|
||||
sevs = mod.load(os.path.join(_metrics_dir, "skill_events.jsonl"))
|
||||
# the invalidate EVENT must actually fire (end-to-end), not just the state flip
|
||||
assert any(e["kind"] == "invalidate" for e in sevs)
|
||||
mod.skill_layer_report([], sevs)
|
||||
@@ -117,11 +117,11 @@ def test_analyzer_reports_composition(_metrics_dir, capsys):
|
||||
def test_analyzer_reports_playbook_cutting_exploration_turns(_metrics_dir, capsys):
|
||||
# tier-2 win: a cold run on a host takes many turns; once strategy is seeded,
|
||||
# the same kind of task takes fewer. The analyzer must report HELPS.
|
||||
sig = sk._sig("find people")
|
||||
sig = sk.compute_sig("find people")
|
||||
_task_row(sig, "llm", 60.0, turns=14, playbook_seeded=False) # cold
|
||||
_task_row(sig, "llm", 40.0, turns=8, playbook_seeded=True) # seeded -> fewer turns
|
||||
mod = _load_analyzer()
|
||||
tasks = mod._load(os.path.join(_metrics_dir, "tasks.jsonl"))
|
||||
tasks = mod.load(os.path.join(_metrics_dir, "tasks.jsonl"))
|
||||
mod.playbook_report(tasks)
|
||||
out = capsys.readouterr().out
|
||||
assert "STRATEGIC PLAYBOOK" in out and "HELPS" in out and "NOT HELPING" not in out
|
||||
@@ -129,11 +129,11 @@ def test_analyzer_reports_playbook_cutting_exploration_turns(_metrics_dir, capsy
|
||||
|
||||
def test_analyzer_flags_playbook_that_does_not_help(_metrics_dir, capsys):
|
||||
# anti-ghost: memory is active (seeded) but seeded runs are NOT cheaper -> flag.
|
||||
sig = sk._sig("stubborn task")
|
||||
sig = sk.compute_sig("stubborn task")
|
||||
_task_row(sig, "llm", 60.0, turns=10, playbook_seeded=False)
|
||||
_task_row(sig, "llm", 60.0, turns=12, playbook_seeded=True) # seeded but MORE turns
|
||||
mod = _load_analyzer()
|
||||
tasks = mod._load(os.path.join(_metrics_dir, "tasks.jsonl"))
|
||||
tasks = mod.load(os.path.join(_metrics_dir, "tasks.jsonl"))
|
||||
mod.playbook_report(tasks)
|
||||
out = capsys.readouterr().out
|
||||
assert "NOT HELPING" in out
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_seed_makes_catalog_non_empty_offline(monkeypatch, tmp_path):
|
||||
seeded = sr._load_seed_cache()
|
||||
assert len(seeded) >= 10
|
||||
|
||||
sr._cache = seeded
|
||||
sr.CACHE = seeded
|
||||
res = asyncio.run(sr.registry_search(q="", limit=100, offset=0, sort="name", category=""))
|
||||
assert res["total"] >= 10 and len(res["skills"]) >= 10
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from collections import Counter, defaultdict
|
||||
|
||||
# Tools that actually change page state (vs. read/meta). A "completed" task that
|
||||
# never ran one of these did nothing but look around, suspicious.
|
||||
_PRODUCTIVE = {
|
||||
PRODUCTIVE = {
|
||||
"BrowserClick", "BrowserClickIndex", "BrowserType", "BrowserNavigate",
|
||||
"BrowserPressKey", "BrowserBatch", "BrowserReplayRoute",
|
||||
}
|
||||
@@ -34,7 +34,7 @@ def _default_dir():
|
||||
return os.path.join(os.path.dirname(__file__), "..", "backend", "data", "browser_metrics")
|
||||
|
||||
|
||||
def _load(path):
|
||||
def load(path):
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
out = []
|
||||
@@ -56,7 +56,7 @@ def ghost_verdict(task, events_for_task):
|
||||
return False, []
|
||||
reasons = []
|
||||
tools = [e["tool"] for e in events_for_task]
|
||||
productive = [t for t in tools if t in _PRODUCTIVE]
|
||||
productive = [t for t in tools if t in PRODUCTIVE]
|
||||
errs = sum(1 for e in events_for_task if not e.get("ok"))
|
||||
total = len(events_for_task)
|
||||
# A read/extract task legitimately has no state-changing action; its evidence
|
||||
@@ -76,7 +76,7 @@ def ghost_verdict(task, events_for_task):
|
||||
reasons.append(f"{errs}/{total} tool calls errored but still marked completed")
|
||||
if any(e.get("is_loop") for e in events_for_task):
|
||||
reasons.append("loop detector fired during a 'completed' task")
|
||||
prod_ok = [e for e in events_for_task if e["tool"] in _PRODUCTIVE and e.get("ok")]
|
||||
prod_ok = [e for e in events_for_task if e["tool"] in PRODUCTIVE and e.get("ok")]
|
||||
if productive and not prod_ok:
|
||||
reasons.append("every state-changing action errored, yet marked completed")
|
||||
return (len(reasons) > 0), reasons
|
||||
@@ -172,9 +172,9 @@ def playbook_report(tasks):
|
||||
|
||||
def main():
|
||||
d = sys.argv[1] if len(sys.argv) > 1 else _default_dir()
|
||||
events = _load(os.path.join(d, "events.jsonl"))
|
||||
tasks = _load(os.path.join(d, "tasks.jsonl"))
|
||||
skill_events = _load(os.path.join(d, "skill_events.jsonl"))
|
||||
events = load(os.path.join(d, "events.jsonl"))
|
||||
tasks = load(os.path.join(d, "tasks.jsonl"))
|
||||
skill_events = load(os.path.join(d, "skill_events.jsonl"))
|
||||
print(f"metrics dir: {d}")
|
||||
print(f"events: {len(events)} tasks: {len(tasks)} skill_events: {len(skill_events)}\n")
|
||||
if not tasks and not events:
|
||||
|
||||
Reference in New Issue
Block a user