HARvestGym / server /tools /browser_agent.py
kdcyberdude's picture
Upload folder using huggingface_hub
e6ce96e verified
Raw
History Blame
15.5 kB
"""
browser_agent tool — HAR-based API surface discovery.
At step 1, loads a pre-recorded HAR file for the target application,
extracts an OpenAPI-like spec, builds GEMMA embeddings for search_endpoints().
Falls back to all-MiniLM-L6-v2 if google/embeddinggemma-300m is unavailable.
"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import numpy as np
# ---------------------------------------------------------------------------
# HAR path resolution
# ---------------------------------------------------------------------------
HARS_DIR = Path(__file__).parent.parent.parent / "hars"
CATALOGS_DIR = Path(__file__).parent.parent.parent / "catalogs"
HAR_MAP: dict[str, str] = {
":7770": "shopping.har",
":7780": "shopping_admin.har",
":9999": "forum.har",
":3000": "osm.har",
":8888": "wikipedia.har",
}
APP_NAME_MAP: dict[str, str] = {
":7770": "shopping",
":7780": "shopping_admin",
":9999": "forum",
":3000": "osm",
":8888": "wikipedia",
}
# Static asset patterns to skip
_STATIC_RE = re.compile(
r"\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|map|webp|avif|otf)(\?|$)",
re.IGNORECASE,
)
_ANALYTICS_HOSTS = {"google-analytics.com", "doubleclick.net", "googletagmanager.com",
"cdn.jsdelivr.net", "cdnjs.cloudflare.com"}
# ID normalisation patterns
_ID_PATTERNS = [
(re.compile(r"/[0-9a-f]{32,}(?=/|$)"), "/{id}"), # Magento cart IDs
(re.compile(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=/|$)"), "/{id}"), # UUIDs
(re.compile(r"/\d+(?=/|$)"), "/{id}"), # numeric IDs
]
def _is_static_asset(url: str) -> bool:
parsed = urlparse(url)
if _STATIC_RE.search(parsed.path):
return True
if parsed.netloc in _ANALYTICS_HOSTS:
return True
return False
def _normalise_path(path: str) -> str:
for pattern, replacement in _ID_PATTERNS:
path = pattern.sub(replacement, path)
return path
def _get_content_type(entry: dict, which: str) -> str:
"""Extract Content-Type from request or response headers."""
headers_key = "request" if which == "request" else "response"
obj = entry.get(headers_key, {})
for h in obj.get("headers", []):
if h.get("name", "").lower() == "content-type":
return h.get("value", "").lower()
if which == "response":
ct = obj.get("content", {}).get("mimeType", "")
return ct.lower()
return ""
def _extract_body(req: dict) -> Any:
post_data = req.get("postData", {})
if not post_data:
return None
text = post_data.get("text", "")
if not text:
return None
try:
return json.loads(text)
except Exception:
return text[:200] if text else None
def _truncate_response_sample(resp: dict) -> Any:
content = resp.get("content", {})
text = content.get("text", "")
if not text:
return None
try:
parsed = json.loads(text)
if isinstance(parsed, list) and len(parsed) > 2:
return parsed[:2]
if isinstance(parsed, dict):
# truncate large arrays in response
truncated = {}
for k, v in parsed.items():
if isinstance(v, list) and len(v) > 2:
truncated[k] = v[:2]
else:
truncated[k] = v
return truncated
return parsed
except Exception:
return text[:300] if text else None
def extract_openapi_spec(har_data: dict, app_base_url: str) -> list[dict]:
"""Extract OpenAPI-like spec from HAR data."""
entries = har_data.get("log", {}).get("entries", [])
seen: set[str] = set()
spec_entries = []
for entry in entries:
req = entry.get("request", {})
resp = entry.get("response", {})
raw_url = req.get("url", "")
method = req.get("method", "GET").upper()
if not raw_url:
continue
if _is_static_asset(raw_url):
continue
resp_ct = _get_content_type(entry, "response")
req_ct = _get_content_type(entry, "request")
parsed_url = urlparse(raw_url)
path = parsed_url.path
# Skip pure static HTML page loads (GET returning text/html for main page/nav)
# BUT keep: POST forms, API paths, admin paths, JSON responses
is_html_get = "text/html" in resp_ct and method == "GET"
has_api_path = any(x in path for x in ["/rest/", "/api/", "/ajax/", "/mui/", ".json"])
is_admin_path = "/admin/" in path or "/rest/V1/" in path
is_post = method in ("POST", "PUT", "PATCH", "DELETE")
has_json_response = "json" in resp_ct
if is_html_get and not has_api_path and not is_admin_path and not has_json_response:
# Skip pure page navigations but only for very common extensions
if not is_post:
continue
path_norm = _normalise_path(path)
key = f"{method} {path_norm}"
if key in seen:
continue
seen.add(key)
has_auth = any(
h.get("name", "").lower() in ("authorization", "x-api-key", "cookie")
for h in req.get("headers", [])
)
spec_entries.append({
"method": method,
"path": path_norm,
"query_params": parsed_url.query or None,
"request_body": _extract_body(req),
"status_code": resp.get("status", 0),
"response_content_type": resp_ct,
"response_body_sample": _truncate_response_sample(resp),
"auth_observed": has_auth,
})
return spec_entries
def catalog_to_spec_entries(app_name: str) -> list[dict]:
"""Load ground truth catalog as spec entries when HAR doesn't yield results."""
catalog_path = CATALOGS_DIR / f"{app_name}.json"
if not catalog_path.exists():
return []
try:
with open(catalog_path) as f:
data = json.load(f)
endpoints = data if isinstance(data, list) else data.get("endpoints", [])
spec_entries = []
for ep in endpoints:
# Handle "endpoint": "POST /rest/V1/..." format
endpoint_str = ep.get("endpoint", "")
if endpoint_str and " " in endpoint_str:
parts = endpoint_str.split(" ", 1)
method = parts[0].upper()
path = parts[1]
else:
path = ep.get("path", endpoint_str)
method = ep.get("method", "GET").upper()
if not path:
continue
auth = ep.get("auth", ep.get("authentication", "none"))
spec_entries.append({
"method": method,
"path": path,
"query_params": None,
"request_body": ep.get("body_params") or ep.get("body"),
"status_code": 200,
"response_content_type": "application/json",
"response_body_sample": ep.get("response_fields") or ep.get("response_sample"),
"auth_observed": auth not in ("none", "None", None, ""),
})
return spec_entries
except Exception as e:
print(f"[browser_agent] Failed to load catalog {app_name}: {e}", flush=True)
return []
def spec_entry_to_text(entry: dict, app_name: str) -> str:
"""Convert a spec entry to searchable text for embedding."""
parts = [
f"app: {app_name}",
f"endpoint: {entry['method']} {entry['path']}",
f"status: {entry['status_code']}",
f"auth: {'required' if entry['auth_observed'] else 'none'}",
]
if entry.get("query_params"):
parts.append(f"query: {entry['query_params']}")
if entry.get("request_body"):
body_str = json.dumps(entry["request_body"])[:300] if not isinstance(entry["request_body"], str) else entry["request_body"][:300]
parts.append(f"body: {body_str}")
if entry.get("response_body_sample") is not None:
resp_str = json.dumps(entry["response_body_sample"])[:300] if not isinstance(entry["response_body_sample"], str) else str(entry["response_body_sample"])[:300]
parts.append(f"response_sample: {resp_str}")
return " | ".join(parts)
# ---------------------------------------------------------------------------
# Embedding model (lazy load)
# ---------------------------------------------------------------------------
_embedding_model = None
_embedding_model_name = None
def _get_embedding_model():
global _embedding_model, _embedding_model_name
if _embedding_model is not None:
return _embedding_model, _embedding_model_name
hf_token = os.environ.get("HF_TOKEN")
# Set a writable cache dir to avoid read-only filesystem errors
import tempfile
cache_dir = os.environ.get("HF_HOME", os.environ.get("TRANSFORMERS_CACHE",
os.path.join(tempfile.gettempdir(), "hf_cache")))
os.makedirs(cache_dir, exist_ok=True)
os.environ.setdefault("HF_HOME", cache_dir)
os.environ.setdefault("TRANSFORMERS_CACHE", cache_dir)
os.environ.setdefault("SENTENCE_TRANSFORMERS_HOME", cache_dir)
# Skip embedding if HARVGYM_NO_EMBED is set (for testing/offline use)
if os.environ.get("HARVGYM_NO_EMBED"):
raise RuntimeError("Embeddings disabled via HARVGYM_NO_EMBED")
# Try GEMMA first, fall back to MiniLM
candidates = [
("google/embeddinggemma-300m", hf_token),
("all-MiniLM-L6-v2", None),
("sentence-transformers/all-MiniLM-L6-v2", None),
]
for model_name, token in candidates:
try:
from sentence_transformers import SentenceTransformer
kwargs: dict = {"cache_folder": cache_dir}
if token:
kwargs["token"] = token
model = SentenceTransformer(model_name, **kwargs)
_embedding_model = model
_embedding_model_name = model_name
print(f"[browser_agent] Loaded embedding model: {model_name}", flush=True)
return _embedding_model, _embedding_model_name
except Exception as e:
print(f"[browser_agent] Could not load {model_name}: {type(e).__name__}: {str(e)[:100]}", flush=True)
raise RuntimeError("No embedding model available. Install sentence-transformers.")
def build_endpoint_embeddings(spec_entries: list[dict], app_name: str):
"""Build embeddings over spec entries. Returns (embeddings_array, text_chunks)."""
model, model_name = _get_embedding_model()
chunks = [spec_entry_to_text(e, app_name) for e in spec_entries]
if not chunks:
return np.array([]), []
# Use encode_document if available (GEMMA), else plain encode
if hasattr(model, "encode_document"):
embeddings = model.encode_document(chunks, batch_size=32, show_progress_bar=False)
else:
embeddings = model.encode(chunks, batch_size=32, show_progress_bar=False)
if not isinstance(embeddings, np.ndarray):
embeddings = np.array(embeddings)
# Normalize for cosine similarity
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms = np.where(norms == 0, 1, norms)
embeddings = embeddings / norms
return embeddings, chunks
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def run_browser_agent(task: str, url: str, episode_store=None) -> dict:
"""
Load HAR for the app inferred from URL, extract spec, build embeddings.
Returns summary endpoint list.
episode_store: mutable dict where we store embeddings/spec for search_endpoints().
"""
# Detect app from URL
app_name = "unknown"
har_filename = None
for port_suffix, fname in HAR_MAP.items():
if port_suffix in url:
har_filename = fname
app_name = APP_NAME_MAP[port_suffix]
break
if har_filename is None:
# Try to guess from URL path
if "shopping" in url.lower() or "7770" in url or "7780" in url:
har_filename = "shopping.har"
app_name = "shopping"
elif "forum" in url.lower() or "9999" in url:
har_filename = "forum.har"
app_name = "forum"
elif "wiki" in url.lower() or "8888" in url:
har_filename = "wikipedia.har"
app_name = "wikipedia"
else:
har_filename = "shopping.har"
app_name = "shopping"
har_path = HARS_DIR / har_filename
if not har_path.exists():
return {
"app": app_name,
"endpoints": [],
"total_endpoints": 0,
"note": f"HAR file not found: {har_path}. No endpoints available.",
"error": f"Missing HAR: {har_filename}",
}
with open(har_path) as f:
har_data = json.load(f)
spec_entries = extract_openapi_spec(har_data, url)
# Augment with ground truth catalog if HAR extraction is sparse
catalog_entries = catalog_to_spec_entries(app_name)
if len(spec_entries) < 5 and catalog_entries:
print(f"[browser_agent] HAR yielded {len(spec_entries)} endpoints, augmenting from catalog ({len(catalog_entries)} entries)", flush=True)
# Merge: catalog takes priority for proper paths
har_paths = {e["path"] for e in spec_entries}
for ce in catalog_entries:
if ce["path"] not in har_paths:
spec_entries.append(ce)
elif catalog_entries:
# Augment any catalog endpoints not found in HAR
har_paths = {e["path"] for e in spec_entries}
for ce in catalog_entries:
if ce["path"] not in har_paths:
spec_entries.append(ce)
# Build embeddings and store in episode_store for search_endpoints
if spec_entries and episode_store is not None:
try:
embeddings, chunks = build_endpoint_embeddings(spec_entries, app_name)
episode_store["endpoint_embeddings"] = embeddings
episode_store["endpoint_chunks"] = chunks
episode_store["spec_entries"] = spec_entries
episode_store["app_name"] = app_name
except Exception as e:
print(f"[browser_agent] Embedding build failed: {e}. Storing spec without embeddings.", flush=True)
# Store chunks as plain text even without embeddings for keyword fallback
chunks = [spec_entry_to_text(e, app_name) for e in spec_entries]
episode_store["endpoint_chunks"] = chunks
episode_store["endpoint_embeddings"] = None
episode_store["spec_entries"] = spec_entries
episode_store["app_name"] = app_name
elif episode_store is not None:
episode_store["spec_entries"] = []
episode_store["app_name"] = app_name
# Return summary only (no schemas)
summary_endpoints = [{"method": e["method"], "path": e["path"]} for e in spec_entries]
return {
"app": app_name,
"endpoints": summary_endpoints,
"total_endpoints": len(summary_endpoints),
"note": (
"These endpoints were observed for this application. "
"Use search_endpoints() with a natural language query to get the full schema, "
"parameters, and auth details for any endpoint."
),
}