diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 5c8d461c..79edaf56 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -14,13 +14,18 @@ import time from datetime import datetime from uuid import uuid4 -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, + BROWSER_HISTORY, + MAX_HISTORY_MESSAGES, + trim_history_by_turns, + validate_message_pairing, clear_browser_history, + prune_old_screenshots, + prune_stale_page_state, + place_cache_marker, PAGE_STATE_MARKER, + get_domain_note, + set_domain_note ) from backend.apps.agents.browser.browser_loop import ( _LOOP_DETECTION_EXCLUDED_TOOLS, @@ -426,7 +431,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.get(browser_id)) if initial_url: nav_result = await execute_browser_tool( "BrowserNavigate", {"url": initial_url}, browser_id, tab_id, @@ -499,8 +504,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.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" @@ -593,7 +598,7 @@ async def run_browser_agent( start_domain = _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) + prior_note = get_domain_note(start_domain) if prior_note: run_system_prompt = ( SYSTEM_PROMPT @@ -977,9 +982,9 @@ async def run_browser_agent( # current, stub the rest. Images are ~1.3-2k tokens each and get # re-read every turn, so this is the biggest per-turn context win on # any visual task (measured ~2.9x fewer image tokens, ~5x less upload). - browser_history.prune_old_screenshots(messages) - browser_history.prune_stale_page_state(messages) - browser_history.place_cache_marker(messages) + prune_old_screenshots(messages) + prune_stale_page_state(messages) + place_cache_marker(messages) _llm_t0 = time.monotonic() response = await _cancellable(client.messages.create( model=api_model, @@ -1214,7 +1219,7 @@ async def run_browser_agent( ) single_domain = len(set(session.browser_domains)) <= 1 if note_domain and working_mem and single_domain: - browser_history.set_domain_note(note_domain, working_mem) + set_domain_note(note_domain, working_mem) brain_text = ( f"📋 **Plan**\n" + (f"_Previous_: {eval_prev}\n" if eval_prev else "") @@ -1994,11 +1999,11 @@ 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_id] = trim_history_by_turns( + messages, MAX_HISTORY_MESSAGES, ) # Honesty gate: the model declaring done is not proof the goal happened. diff --git a/backend/apps/agents/browser/browser_batch_replay.py b/backend/apps/agents/browser/browser_batch_replay.py index 7ec54662..0c9f532e 100644 --- a/backend/apps/agents/browser/browser_batch_replay.py +++ b/backend/apps/agents/browser/browser_batch_replay.py @@ -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,9 +123,9 @@ 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 @@ -152,7 +152,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": @@ -164,7 +164,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 "" @@ -189,12 +189,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 []): @@ -204,7 +204,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: @@ -217,7 +217,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 ) @@ -227,14 +227,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 @@ -245,19 +245,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 diff --git a/backend/apps/agents/browser/browser_extract.py b/backend/apps/agents/browser/browser_extract.py index 5c044a9a..382559a8 100644 --- a/backend/apps/agents/browser/browser_extract.py +++ b/backend/apps/agents/browser/browser_extract.py @@ -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 p_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": ""}.\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 p_first_json(text) except Exception as e: logger.debug(f"[browser-extract] aux extraction failed: {e}") return "" diff --git a/backend/apps/agents/browser/browser_fast_path.py b/backend/apps/agents/browser/browser_fast_path.py index 5f359315..5d329399 100644 --- a/backend/apps/agents/browser/browser_fast_path.py +++ b/backend/apps/agents/browser/browser_fast_path.py @@ -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 p_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 p_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": p_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)) + from backend.apps.agents.core.aux_llm import safe_resp_text + verdict, brief = p_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" diff --git a/backend/apps/agents/browser/browser_fast_read.py b/backend/apps/agents/browser/browser_fast_read.py index 5a7bf724..6968c872 100644 --- a/backend/apps/agents/browser/browser_fast_read.py +++ b/backend/apps/agents/browser/browser_fast_read.py @@ -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: @@ -63,7 +63,7 @@ async def try_fast_read(prompt: str, brief: str, settings, primary_api: str | No from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.agents.providers.registry import resolve_aux_model - from backend.apps.agents.core.aux_llm import _safe_resp_text + from backend.apps.agents.core.aux_llm import safe_resp_text aux_model, _ = await resolve_aux_model( settings, preferred_tier="haiku", primary_api=primary_api, @@ -75,15 +75,15 @@ 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, ) - answer = _safe_resp_text(resp).strip() + answer = safe_resp_text(resp).strip() answer_ms = int((time.monotonic() - t1) * 1000) if not answer or answer.upper().startswith("INSUFFICIENT"): logger.info(f"[browser-fast-read] aux found page insufficient ({answer_ms}ms); browser fallback") diff --git a/backend/apps/agents/browser/browser_history.py b/backend/apps/agents/browser/browser_history.py index f9a9284e..1c54eaea 100644 --- a/backend/apps/agents/browser/browser_history.py +++ b/backend/apps/agents/browser/browser_history.py @@ -11,39 +11,39 @@ 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 +P_DOMAIN_NOTES: dict[str, str] = {} +P_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 P_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] + P_DOMAIN_NOTES[domain] = note.strip()[:P_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]" +P_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": P_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:]) diff --git a/backend/apps/agents/browser_agent_mcp_server.py b/backend/apps/agents/browser_agent_mcp_server.py index a978fbf4..50c95b74 100644 --- a/backend/apps/agents/browser_agent_mcp_server.py +++ b/backend/apps/agents/browser_agent_mcp_server.py @@ -156,7 +156,7 @@ def call_backend(tasks: list[dict]) -> dict: MAX_IMAGE_B64_BYTES = 400_000 -def _sniff_image_mime(b64: str) -> str: +def p_sniff_image_mime(b64: str) -> str: """PNG vs JPEG from the base64 magic bytes. Capture now sends JPEG, but older callers / cached shots may be PNG, so we label by content, not assumption.""" if b64.startswith("/9j/"): @@ -214,7 +214,7 @@ def format_result(result: dict) -> dict: screenshot = result.get("final_screenshot") if screenshot: image_data = screenshot - mime_type = _sniff_image_mime(screenshot) + mime_type = p_sniff_image_mime(screenshot) if len(image_data) > MAX_IMAGE_B64_BYTES: compressed = compress_screenshot(image_data) diff --git a/backend/apps/agents/core/aux_llm.py b/backend/apps/agents/core/aux_llm.py index 7c8b2435..9d8071f7 100644 --- a/backend/apps/agents/core/aux_llm.py +++ b/backend/apps/agents/core/aux_llm.py @@ -19,7 +19,7 @@ def clean_short_label(raw: str, max_words: int = 4, max_chars: int = 36) -> str: return label -def _safe_resp_text(resp) -> str: +def safe_resp_text(resp) -> str: """Extract text from an Anthropic-shape response, tolerating Gemini/OpenAI edge cases. Gemini through 9Router occasionally returns `content=[]` (e.g. safety stop, function-call-only turn) which makes `resp.content[0].text` diff --git a/linter/config/config.json b/linter/config/config.json index 41058e72..2ffec071 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -67,7 +67,11 @@ "import-cycles": [], "vulture": [], "ruff": [], - "no-underscore-names": [], + "no-underscore-names": [ + "backend/apps/agents/agent_manager.py", + "backend/apps/agents/agents.py", + "backend/apps/agents/browser/browser_agent.py" + ], "p-private": [], "endpoints": [], "classes": []