diff --git a/agent.py b/agent.py index 7d194b2..73291f5 100644 --- a/agent.py +++ b/agent.py @@ -11,6 +11,7 @@ import asyncio import hashlib import html import json +import os import re import time from dataclasses import asdict, dataclass, field @@ -22,7 +23,7 @@ from a2a_pack import A2AAgent, LLMProvisioning, NoAuth, Pricing, RunContext, ski from pydantic import BaseModel, Field -DEFAULT_OUTPUT_DIR = Path("outputs/browser-to-api") +DEFAULT_OUTPUT_DIR = Path(os.environ.get("BROWSER_TO_API_OUTPUT_DIR", "/tmp/browser-to-api")) JSONISH_TYPES = ( "application/json", "application/problem+json", @@ -166,44 +167,32 @@ class BrowserToApiAgent(A2AAgent[BrowserToApiConfig, NoAuth]): if not url or not str(url).strip().startswith(("http://", "https://")): return {"error": "url must start with http:// or https://"} - await ctx.emit_progress("launching browser and capturing network traffic") - samples, capture_log = await asyncio.to_thread( - capture_browser_traffic, - url=str(url).strip(), - wait_seconds=max(1, min(int(wait_seconds), 60)), - max_clicks=max(0, min(int(max_clicks), 20)), - search_term=search_term, - ) + try: + await ctx.emit_progress("launching browser and capturing network traffic") + samples, capture_log = await asyncio.to_thread( + capture_browser_traffic, + url=str(url).strip(), + wait_seconds=max(1, min(int(wait_seconds), 60)), + max_clicks=max(0, min(int(max_clicks), 20)), + search_term=search_term, + ) - await ctx.emit_progress(f"captured {len(samples)} network samples; inferring API surface") - bundle = build_openapi_bundle( - samples, - title=title or _title_from_url(url), - origins=origins, - include=include, - exclude=exclude, - min_samples=max(1, int(min_samples)), - redact=redact, - ) - output_dir = write_bundle(bundle, DEFAULT_OUTPUT_DIR / safe_run_id(url)) + await ctx.emit_progress(f"captured {len(samples)} network samples; inferring API surface") + bundle = build_openapi_bundle( + samples, + title=title or _title_from_url(url), + origins=origins, + include=include, + exclude=exclude, + min_samples=max(1, int(min_samples)), + redact=redact, + ) + output_dir = write_bundle(bundle, DEFAULT_OUTPUT_DIR / safe_run_id(url)) - await ctx.emit_progress(f"wrote OpenAPI bundle to {output_dir}") - return { - "url": url, - "captured_samples": len(samples), - "included_samples": bundle["summary"]["included_samples"], - "endpoints": bundle["summary"]["endpoints"], - "origins": bundle["summary"]["origins"], - "output_dir": str(output_dir), - "openapi_json": str(output_dir / "openapi.json"), - "openapi_yaml": str(output_dir / "openapi.yaml"), - "html_report": str(output_dir / "index.html"), - "markdown_report": str(output_dir / "report.md"), - "client": str(output_dir / "client.mjs"), - "confidence": str(output_dir / "confidence.json"), - "capture_log": capture_log, - "top_endpoints": bundle["summary"]["top_endpoints"], - } + await ctx.emit_progress(f"wrote OpenAPI bundle to {output_dir}") + return _result_payload(url=url, samples=samples, bundle=bundle, output_dir=output_dir, capture_log=capture_log) + except Exception as exc: # noqa: BLE001 + return {"error": "browser_to_api_failed", "detail": f"{type(exc).__name__}: {exc}"} @skill( name="discover_api_from_trace", @@ -226,33 +215,23 @@ class BrowserToApiAgent(A2AAgent[BrowserToApiConfig, NoAuth]): min_samples: int = 1, redact: list[str] | None = None, ) -> dict[str, Any]: - await ctx.emit_progress("pairing replayed request/response trace") - samples = parse_trace_jsonl(requests_jsonl=requests_jsonl, responses_jsonl=responses_jsonl) - bundle = build_openapi_bundle( - samples, - title=title, - origins=origins, - include=include, - exclude=exclude, - min_samples=max(1, int(min_samples)), - redact=redact, - ) - output_dir = write_bundle(bundle, DEFAULT_OUTPUT_DIR / safe_run_id(title)) - await ctx.emit_progress(f"wrote replayed OpenAPI bundle to {output_dir}") - return { - "captured_samples": len(samples), - "included_samples": bundle["summary"]["included_samples"], - "endpoints": bundle["summary"]["endpoints"], - "origins": bundle["summary"]["origins"], - "output_dir": str(output_dir), - "openapi_json": str(output_dir / "openapi.json"), - "openapi_yaml": str(output_dir / "openapi.yaml"), - "html_report": str(output_dir / "index.html"), - "markdown_report": str(output_dir / "report.md"), - "client": str(output_dir / "client.mjs"), - "confidence": str(output_dir / "confidence.json"), - "top_endpoints": bundle["summary"]["top_endpoints"], - } + try: + await ctx.emit_progress("pairing replayed request/response trace") + samples = parse_trace_jsonl(requests_jsonl=requests_jsonl, responses_jsonl=responses_jsonl) + bundle = build_openapi_bundle( + samples, + title=title, + origins=origins, + include=include, + exclude=exclude, + min_samples=max(1, int(min_samples)), + redact=redact, + ) + output_dir = write_bundle(bundle, DEFAULT_OUTPUT_DIR / safe_run_id(title)) + await ctx.emit_progress(f"wrote replayed OpenAPI bundle to {output_dir}") + return _result_payload(samples=samples, bundle=bundle, output_dir=output_dir) + except Exception as exc: # noqa: BLE001 + return {"error": "browser_to_api_failed", "detail": f"{type(exc).__name__}: {exc}"} def capture_browser_traffic( @@ -842,6 +821,35 @@ def write_bundle(bundle: dict[str, Any], output_dir: Path) -> Path: return output_dir +def _result_payload( + *, + samples: list[TrafficSample], + bundle: dict[str, Any], + output_dir: Path, + url: str | None = None, + capture_log: list[str] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "captured_samples": len(samples), + "included_samples": bundle["summary"]["included_samples"], + "endpoints": bundle["summary"]["endpoints"], + "origins": bundle["summary"]["origins"], + "output_dir": str(output_dir), + "openapi_json": str(output_dir / "openapi.json"), + "openapi_yaml": str(output_dir / "openapi.yaml"), + "html_report": str(output_dir / "index.html"), + "markdown_report": str(output_dir / "report.md"), + "client": str(output_dir / "client.mjs"), + "confidence": str(output_dir / "confidence.json"), + "top_endpoints": bundle["summary"]["top_endpoints"], + } + if url is not None: + payload["url"] = url + if capture_log is not None: + payload["capture_log"] = capture_log + return payload + + def infer_schema(values: list[Any]) -> dict[str, Any]: values = [value for value in values if value is not None] if not values: