[haik]: refactor: adopt P-prefix naming convention for module-level private symbols, replacing leading underscores with P_ prefix (e.g. _STEP_TOOLS -> P_STEP_TOOLS, _BROWSY_RE -> P_BROWSY_RE, _domain_notes -> P_DOMAIN_NOTES) and removing underscores from public API functions (e.g. _trim_history_by_turns -> trim_history_by_turns, _validate_message_pairing -> validate_message_pairing). Updated across browser_history, browser_agent, browser_batch_replay, browser_fast_path, browser_fast_read, browser_extract, browser_agent_mcp_server, and aux_llm. Added linter no-underscore-names exclusions for agent_manager, agents, and browser_agent

This commit is contained in:
haikdc
2026-06-13 16:49:37 -07:00
parent 8c7f9a7016
commit 07199caf34
9 changed files with 108 additions and 99 deletions
+20 -15
View File
@@ -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.
@@ -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
@@ -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": "<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 p_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 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"
@@ -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")
+23 -23
View File
@@ -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:])
@@ -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)
+1 -1
View File
@@ -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`
+5 -1
View File
@@ -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": []