mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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": []
|
||||
|
||||
Reference in New Issue
Block a user