sera-browser 3.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. sera_browser/__init__.py +203 -0
  2. sera_browser/__main__.py +26 -0
  3. sera_browser/api/__init__.py +6 -0
  4. sera_browser/api/app.py +275 -0
  5. sera_browser/browser/__init__.py +19 -0
  6. sera_browser/browser/browser_use_fallback.py +108 -0
  7. sera_browser/browser/cdp_ax.py +230 -0
  8. sera_browser/browser/context.py +163 -0
  9. sera_browser/browser/driver.py +801 -0
  10. sera_browser/browser/inspector.py +208 -0
  11. sera_browser/browser/manager.py +178 -0
  12. sera_browser/browser/refmap.py +283 -0
  13. sera_browser/browser/resolver.py +231 -0
  14. sera_browser/browser/screenshots.py +59 -0
  15. sera_browser/browser/som.py +216 -0
  16. sera_browser/browser/stealth.py +271 -0
  17. sera_browser/browser/storage.py +78 -0
  18. sera_browser/browser/tabs.py +260 -0
  19. sera_browser/browser/trimmer.py +141 -0
  20. sera_browser/browser/viewer.py +328 -0
  21. sera_browser/cancel.py +107 -0
  22. sera_browser/challenge/__init__.py +6 -0
  23. sera_browser/challenge/detector.py +301 -0
  24. sera_browser/config.py +209 -0
  25. sera_browser/eval/__init__.py +20 -0
  26. sera_browser/eval/harness.py +405 -0
  27. sera_browser/logging.py +128 -0
  28. sera_browser/models.py +340 -0
  29. sera_browser/net/__init__.py +6 -0
  30. sera_browser/net/recorder.py +288 -0
  31. sera_browser/security.py +137 -0
  32. sera_browser/session/__init__.py +6 -0
  33. sera_browser/session/manager.py +214 -0
  34. sera_browser/state/__init__.py +6 -0
  35. sera_browser/state/state.py +109 -0
  36. sera_browser/task/__init__.py +63 -0
  37. sera_browser/task/action_schema.py +412 -0
  38. sera_browser/task/agent.py +750 -0
  39. sera_browser/task/executor.py +532 -0
  40. sera_browser/task/extraction.py +169 -0
  41. sera_browser/task/history.py +190 -0
  42. sera_browser/task/llm_client.py +743 -0
  43. sera_browser/task/llm_planner.py +696 -0
  44. sera_browser/task/loop.py +102 -0
  45. sera_browser/task/memory.py +168 -0
  46. sera_browser/task/observability.py +246 -0
  47. sera_browser/task/pav.py +453 -0
  48. sera_browser/task/planner.py +192 -0
  49. sera_browser/task/report.py +139 -0
  50. sera_browser/task/safety.py +294 -0
  51. sera_browser/task/soft_restrict.py +92 -0
  52. sera_browser-3.1.1.dist-info/METADATA +244 -0
  53. sera_browser-3.1.1.dist-info/RECORD +56 -0
  54. sera_browser-3.1.1.dist-info/WHEEL +5 -0
  55. sera_browser-3.1.1.dist-info/entry_points.txt +2 -0
  56. sera_browser-3.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,203 @@
1
+ """SERA Browser Agent — Playwright-first autonomous browser agent.
2
+
3
+ A standalone, production-quality autonomous browser agent whose core engine
4
+ is Playwright. Receives a high-level objective, drives a real browser, reasons
5
+ from a single structured browser state, escalates to vision only when needed,
6
+ and returns a deterministic structured report.
7
+
8
+ Public API:
9
+ from sera_browser import BrowserAgent, SessionManager, Settings
10
+ from sera_browser import create_app # FastAPI app factory
11
+
12
+ Quick start:
13
+ settings = Settings(local_lab=True, allowed_private_targets="127.0.0.1")
14
+ bm = BrowserManager(settings)
15
+ sm = SessionManager(settings, bm)
16
+ rec = await sm.create(TaskCreate(target="http://localhost:3000", task="analyze"))
17
+ """
18
+ from __future__ import annotations
19
+
20
+ __version__ = "3.1.1"
21
+
22
+ # Public API — these are the ONLY imports SERA should use.
23
+ # Internal module paths may change; the public API is stable.
24
+
25
+ # Configuration
26
+ from .config import Settings, get_settings
27
+
28
+ # Browser management
29
+ from .browser.manager import BrowserManager
30
+
31
+ # Session management
32
+ from .session.manager import SessionManager, SessionStatus, SessionRecord
33
+
34
+ # Task + agent
35
+ from .task.agent import BrowserAgent, TaskConfig, TaskResult
36
+ from .task.planner import TaskPlanner, Step, StepKind
37
+ from .task.executor import ActionExecutor, ActionResult
38
+ from .task.loop import LoopRecoveryManager
39
+
40
+ # Models (structured types)
41
+ from .models import (
42
+ AgentReport,
43
+ BrowserState,
44
+ InteractiveElement,
45
+ FormDescriptor,
46
+ LinkDescriptor,
47
+ NetworkRequest,
48
+ WebSocketEvent,
49
+ Challenge,
50
+ TaskCreate,
51
+ TaskCreatedResponse,
52
+ AuthenticationSummary,
53
+ NetworkSummary,
54
+ )
55
+
56
+ # LLM layer (provider-agnostic)
57
+ from .task.llm_client import (
58
+ LLMClient,
59
+ LLMConfig,
60
+ LLMResponse,
61
+ LLMError,
62
+ LLMUnavailableError,
63
+ LLMTimeoutError,
64
+ LLMAuthError,
65
+ LLMRateLimitError,
66
+ LLMResponseError,
67
+ ZaiLLMClient,
68
+ OpenAICompatLLMClient,
69
+ MockLLMClient,
70
+ make_llm_client,
71
+ )
72
+
73
+ # LLM planner (optional, with deterministic fallback)
74
+ from .task.llm_planner import LLMTaskPlanner, LLMPlannerStats, SYSTEM_PROMPT
75
+
76
+ # Advanced features
77
+ from .task.memory import MemoryManager
78
+ from .task.safety import SafetyGuard, SafetyConfig
79
+ from .task.history import HistoryManager, HistoryEntry
80
+ from .task.soft_restrict import SoftRestrict
81
+ from .task.observability import Observability, ActionSpan
82
+ from .task.extraction import ExtractionResult, validate_against_schema
83
+ from .task.pav import PlanActValidateOrchestrator, PAVStats
84
+
85
+ # Browser subsystems
86
+ from .browser.driver import PlaywrightDriver
87
+ from .browser.inspector import Inspector
88
+ from .browser.context import ContextManager
89
+ from .browser.screenshots import ScreenshotLayer
90
+ from .browser.storage import StorageManager
91
+ from .browser.refmap import RefMap, RefEntry, build_refmap
92
+ from .browser.resolver import ElementResolver, ResolutionResult
93
+ from .browser.tabs import TabManager, TabRecord
94
+ from .browser.trimmer import trim_snapshot, TrimResult
95
+ from .browser.stealth import StealthLayer, StealthConfig, StealthFingerprint
96
+ from .browser.som import SetOfMarkAnnotator, SoMResult, MarkedElement
97
+ from .browser.cdp_ax import CDPAccessibilityExtractor
98
+ from .browser.viewer import BrowserViewer, StreamEvent, ViewerStats
99
+
100
+ # Network recorder (reusable security sensor)
101
+ from .net.recorder import NetworkRecorder
102
+
103
+ # Challenge detection
104
+ from .challenge.detector import ChallengeDetector
105
+
106
+ # API (FastAPI app factory)
107
+ from .api.app import create_app
108
+
109
+ __all__ = [
110
+ "__version__",
111
+ # Config
112
+ "Settings",
113
+ "get_settings",
114
+ # Browser
115
+ "BrowserManager",
116
+ "PlaywrightDriver",
117
+ "Inspector",
118
+ "ContextManager",
119
+ "ScreenshotLayer",
120
+ "StorageManager",
121
+ "RefMap",
122
+ "RefEntry",
123
+ "build_refmap",
124
+ "ElementResolver",
125
+ "ResolutionResult",
126
+ "TabManager",
127
+ "TabRecord",
128
+ "trim_snapshot",
129
+ "TrimResult",
130
+ "StealthLayer",
131
+ "StealthConfig",
132
+ "StealthFingerprint",
133
+ "SetOfMarkAnnotator",
134
+ "SoMResult",
135
+ "MarkedElement",
136
+ "CDPAccessibilityExtractor",
137
+ "BrowserViewer",
138
+ "StreamEvent",
139
+ "ViewerStats",
140
+ # Session
141
+ "SessionManager",
142
+ "SessionStatus",
143
+ "SessionRecord",
144
+ # Task + agent
145
+ "BrowserAgent",
146
+ "TaskConfig",
147
+ "TaskResult",
148
+ "TaskPlanner",
149
+ "Step",
150
+ "StepKind",
151
+ "ActionExecutor",
152
+ "ActionResult",
153
+ "LoopRecoveryManager",
154
+ # Models
155
+ "AgentReport",
156
+ "BrowserState",
157
+ "InteractiveElement",
158
+ "FormDescriptor",
159
+ "LinkDescriptor",
160
+ "NetworkRequest",
161
+ "WebSocketEvent",
162
+ "Challenge",
163
+ "TaskCreate",
164
+ "TaskCreatedResponse",
165
+ "AuthenticationSummary",
166
+ "NetworkSummary",
167
+ # LLM
168
+ "LLMClient",
169
+ "LLMConfig",
170
+ "LLMResponse",
171
+ "LLMError",
172
+ "LLMUnavailableError",
173
+ "LLMTimeoutError",
174
+ "LLMAuthError",
175
+ "LLMRateLimitError",
176
+ "LLMResponseError",
177
+ "ZaiLLMClient",
178
+ "OpenAICompatLLMClient",
179
+ "MockLLMClient",
180
+ "make_llm_client",
181
+ "LLMTaskPlanner",
182
+ "LLMPlannerStats",
183
+ "SYSTEM_PROMPT",
184
+ # Advanced
185
+ "MemoryManager",
186
+ "SafetyGuard",
187
+ "SafetyConfig",
188
+ "HistoryManager",
189
+ "HistoryEntry",
190
+ "SoftRestrict",
191
+ "Observability",
192
+ "ActionSpan",
193
+ "ExtractionResult",
194
+ "validate_against_schema",
195
+ "PlanActValidateOrchestrator",
196
+ "PAVStats",
197
+ # Network
198
+ "NetworkRecorder",
199
+ # Challenge
200
+ "ChallengeDetector",
201
+ # API
202
+ "create_app",
203
+ ]
@@ -0,0 +1,26 @@
1
+ """Entry point: `python -m sera_browser`."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ from .api.app import create_app
7
+
8
+
9
+ def main() -> int:
10
+ import uvicorn
11
+
12
+ from .config import Settings
13
+
14
+ settings = Settings()
15
+ app = create_app(settings)
16
+ uvicorn.run(
17
+ app,
18
+ host=settings.api_host,
19
+ port=settings.api_port,
20
+ log_level=settings.log_level.lower(),
21
+ )
22
+ return 0
23
+
24
+
25
+ if __name__ == "__main__":
26
+ sys.exit(main())
@@ -0,0 +1,6 @@
1
+ """API subpackage."""
2
+ from __future__ import annotations
3
+
4
+ from .app import create_app
5
+
6
+ __all__ = ["create_app"]
@@ -0,0 +1,275 @@
1
+ """FastAPI HTTP API.
2
+
3
+ Endpoints:
4
+ POST /tasks create + start a browser agent task
5
+ GET /tasks/{id} task status + metadata
6
+ POST /tasks/{id}/cancel cancel a running task
7
+ GET /tasks/{id}/state current BrowserState snapshot
8
+ GET /tasks/{id}/result final structured report
9
+ GET /health liveness + effective browser mode
10
+
11
+ Real-view browser viewer (human-takeoverable, for SERA chat UI):
12
+ GET /tasks/{id}/viewer viewer status (CDP URL, takeover state)
13
+ POST /tasks/{id}/human_takeover pause agent for human interaction
14
+ POST /tasks/{id}/resume resume agent after human takeover
15
+ GET /tasks/{id}/screenshot current page screenshot (base64 data URL)
16
+ WS /tasks/{id}/stream real-time browser state stream (screenshots + steps)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ from importlib import metadata as _md
22
+ import time
23
+ from contextlib import asynccontextmanager
24
+ from typing import Any
25
+
26
+ from fastapi import FastAPI, HTTPException
27
+ from fastapi.responses import JSONResponse
28
+
29
+ from ..browser.manager import BrowserManager
30
+
31
+ try: # avoid a circular import with the package __init__
32
+ __version__ = _md.version("sera-browser")
33
+ except Exception: # noqa: BLE001
34
+ __version__ = "3.1.1"
35
+ from ..config import Settings, get_settings
36
+ from ..logging import configure_logging, get_logger
37
+ from ..models import TaskCreate, TaskCreatedResponse
38
+ from ..session.manager import SessionManager, SessionStatus
39
+
40
+ log = get_logger("sera.api")
41
+
42
+
43
+ def create_app(settings: Settings | None = None) -> FastAPI:
44
+ settings = settings or get_settings()
45
+ configure_logging(level=settings.log_level, fmt=settings.log_format)
46
+
47
+ browser_manager = BrowserManager(settings=settings)
48
+ sessions = SessionManager(settings=settings, browser_manager=browser_manager)
49
+
50
+ @asynccontextmanager
51
+ async def lifespan(app: FastAPI): # noqa: ARG001
52
+ try:
53
+ yield
54
+ finally:
55
+ log.info("shutting down: cancelling all sessions + force-cleanup")
56
+ try:
57
+ await asyncio.wait_for(sessions.shutdown_all(), timeout=20.0)
58
+ except (asyncio.TimeoutError, Exception): # noqa: BLE001
59
+ await browser_manager.force_kill()
60
+
61
+ app = FastAPI(
62
+ title="SERA Browser Agent",
63
+ version=__version__,
64
+ description="Playwright-first autonomous browser agent.",
65
+ lifespan=lifespan,
66
+ )
67
+
68
+ @app.get("/health")
69
+ async def health() -> dict[str, Any]:
70
+ return {
71
+ "status": "ok",
72
+ "browser_mode": settings.effective_browser_mode(),
73
+ "browser_launched": browser_manager._handle is not None,
74
+ "browser_use_available": _browser_use_available(),
75
+ "viewer_enabled": settings.enable_viewer,
76
+ "version": __version__,
77
+ }
78
+
79
+ @app.post("/tasks", response_model=TaskCreatedResponse, status_code=202)
80
+ async def create_task(req: TaskCreate) -> TaskCreatedResponse:
81
+ rec = await sessions.create(req)
82
+ if rec.status == SessionStatus.failed:
83
+ raise HTTPException(status_code=400, detail=rec.error or "target rejected")
84
+ return TaskCreatedResponse(
85
+ task_id=rec.task_id,
86
+ status=rec.status.value,
87
+ target=rec.config.target,
88
+ )
89
+
90
+ @app.get("/tasks/{task_id}")
91
+ async def get_task(task_id: str) -> dict[str, Any]:
92
+ rec = sessions.get(task_id)
93
+ if rec is None:
94
+ raise HTTPException(status_code=404, detail="task not found")
95
+ return {
96
+ "task_id": rec.task_id,
97
+ "target": rec.config.target,
98
+ "task": rec.config.objective,
99
+ "browser_mode": rec.config.browser_mode,
100
+ "status": rec.status.value,
101
+ "error": rec.error,
102
+ "started_at": rec.started_at,
103
+ "finished_at": rec.finished_at,
104
+ "has_state": rec.state is not None,
105
+ "has_report": rec.report is not None,
106
+ }
107
+
108
+ @app.post("/tasks/{task_id}/cancel")
109
+ async def cancel_task(task_id: str) -> dict[str, Any]:
110
+ rec = sessions.get(task_id)
111
+ if rec is None:
112
+ raise HTTPException(status_code=404, detail="task not found")
113
+ cancelled = await sessions.cancel(task_id)
114
+ return {
115
+ "task_id": task_id,
116
+ "cancelled": cancelled,
117
+ "status": rec.status.value,
118
+ }
119
+
120
+ @app.get("/tasks/{task_id}/state")
121
+ async def get_state(task_id: str, with_snapshot: bool = False) -> dict[str, Any]:
122
+ rec = sessions.get(task_id)
123
+ if rec is None:
124
+ raise HTTPException(status_code=404, detail="task not found")
125
+ if rec.state is None:
126
+ return {"task_id": task_id, "state": None}
127
+ return {
128
+ "task_id": task_id,
129
+ "state": rec.state.to_api_dict(with_snapshot=with_snapshot),
130
+ }
131
+
132
+ @app.get("/tasks/{task_id}/result")
133
+ async def get_result(task_id: str) -> dict[str, Any]:
134
+ rec = sessions.get(task_id)
135
+ if rec is None:
136
+ raise HTTPException(status_code=404, detail="task not found")
137
+ if rec.report is None:
138
+ raise HTTPException(status_code=409, detail="report not ready")
139
+ return rec.report.model_dump(mode="json")
140
+
141
+ # ------------------------------------------------------------------
142
+ # Real-view browser viewer (human-takeoverable browser for SERA chat UI)
143
+ # ------------------------------------------------------------------
144
+
145
+ @app.get("/tasks/{task_id}/viewer")
146
+ async def get_viewer_status(task_id: str) -> dict[str, Any]:
147
+ """Get the viewer status for a task (CDP URL, takeover state, stats)."""
148
+ rec = sessions.get(task_id)
149
+ if rec is None:
150
+ raise HTTPException(status_code=404, detail="task not found")
151
+ if rec.viewer is None:
152
+ return {"task_id": task_id, "viewer_enabled": False}
153
+ return {
154
+ "task_id": task_id,
155
+ "viewer_enabled": True,
156
+ "cdp_url": rec.viewer.cdp_url,
157
+ "cdp_port": rec.viewer._cdp_port,
158
+ "takeover_active": rec.viewer.is_takeover_active,
159
+ "takeover_reason": rec.viewer._takeover_reason if rec.viewer.is_takeover_active else None,
160
+ "stats": rec.viewer.stats.as_dict(),
161
+ }
162
+
163
+ @app.post("/tasks/{task_id}/human_takeover")
164
+ async def human_takeover(task_id: str, reason: str = "human requested") -> dict[str, Any]:
165
+ """Request human takeover — pauses the agent so the human can interact
166
+ with the visible browser window. The agent resumes when /resume is called.
167
+ """
168
+ rec = sessions.get(task_id)
169
+ if rec is None:
170
+ raise HTTPException(status_code=404, detail="task not found")
171
+ if rec.viewer is None:
172
+ raise HTTPException(status_code=400, detail="viewer not enabled for this task")
173
+ ok = await sessions.request_takeover(task_id, reason)
174
+ return {
175
+ "task_id": task_id,
176
+ "takeover_active": ok,
177
+ "message": "Agent paused. Interact with the browser window, then call /resume.",
178
+ }
179
+
180
+ @app.post("/tasks/{task_id}/resume")
181
+ async def resume_task(task_id: str) -> dict[str, Any]:
182
+ """Resume the agent after human takeover."""
183
+ rec = sessions.get(task_id)
184
+ if rec is None:
185
+ raise HTTPException(status_code=404, detail="task not found")
186
+ if rec.viewer is None:
187
+ raise HTTPException(status_code=400, detail="viewer not enabled for this task")
188
+ ok = sessions.resume_task(task_id)
189
+ return {
190
+ "task_id": task_id,
191
+ "resumed": ok,
192
+ "message": "Agent resuming." if ok else "No active takeover to resume.",
193
+ }
194
+
195
+ @app.get("/tasks/{task_id}/screenshot")
196
+ async def get_screenshot(task_id: str) -> dict[str, Any]:
197
+ """Capture and return the current page screenshot as a base64 data URL."""
198
+ rec = sessions.get(task_id)
199
+ if rec is None:
200
+ raise HTTPException(status_code=404, detail="task not found")
201
+ if rec.viewer is None or rec.viewer._page is None:
202
+ raise HTTPException(status_code=400, detail="viewer/page not available")
203
+ png = await rec.viewer.capture_screenshot(rec.viewer._page)
204
+ if png is None:
205
+ raise HTTPException(status_code=500, detail="screenshot capture failed")
206
+ import base64
207
+
208
+ data_url = f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}"
209
+ return {"task_id": task_id, "screenshot": data_url}
210
+
211
+ @app.websocket("/tasks/{task_id}/stream")
212
+ async def stream_task(websocket: Any) -> None:
213
+ """WebSocket endpoint for real-time browser state streaming.
214
+
215
+ Sends StreamEvent JSON objects (step, takeover, resume, close) to
216
+ connected clients. The SERA chat UI connects here to receive
217
+ real-time screenshots + state after each agent step.
218
+ """
219
+ from fastapi import WebSocket as FastAPIWebSocket
220
+
221
+ task_id = websocket.path_params.get("task_id", "")
222
+ rec = sessions.get(task_id)
223
+ if rec is None:
224
+ await websocket.close(code=4004, reason="task not found")
225
+ return
226
+ if rec.viewer is None:
227
+ await websocket.close(code=4003, reason="viewer not enabled")
228
+ return
229
+ await websocket.accept()
230
+ q = rec.viewer.add_client()
231
+ try:
232
+ while True:
233
+ try:
234
+ event = await asyncio.wait_for(q.get(), timeout=30.0)
235
+ await websocket.send_text(event.to_json())
236
+ except asyncio.TimeoutError:
237
+ # Send a keepalive ping.
238
+ await websocket.send_json({"type": "ping", "timestamp": time.time()})
239
+ except Exception: # noqa: BLE001
240
+ pass
241
+ finally:
242
+ rec.viewer.remove_client(q)
243
+
244
+ @app.get("/tasks")
245
+ async def list_tasks() -> list[dict[str, Any]]:
246
+ return [
247
+ {
248
+ "task_id": r.task_id,
249
+ "target": r.config.target,
250
+ "status": r.status.value,
251
+ "started_at": r.started_at,
252
+ "finished_at": r.finished_at,
253
+ "has_report": r.report is not None,
254
+ }
255
+ for r in sessions.list()
256
+ ]
257
+
258
+ @app.get("/")
259
+ async def root() -> dict[str, str]:
260
+ return {"name": "SERA Browser Agent", "version": __version__}
261
+
262
+ return app
263
+
264
+
265
+ def _browser_use_available() -> bool:
266
+ try:
267
+ from ..browser.browser_use_fallback import browser_use_available as _impl # type: ignore
268
+
269
+ return _impl()
270
+ except Exception: # noqa: BLE001
271
+ return False
272
+
273
+
274
+ # ASGI entry point
275
+ app = create_app()
@@ -0,0 +1,19 @@
1
+ """Browser subsystem: PlaywrightDriver, BrowserManager, ContextManager,
2
+ Inspector, ScreenshotLayer, StorageManager, optional BrowserUseFallback."""
3
+ from __future__ import annotations
4
+
5
+ from .driver import PlaywrightDriver
6
+ from .manager import BrowserManager
7
+ from .context import ContextManager
8
+ from .inspector import Inspector
9
+ from .screenshots import ScreenshotLayer
10
+ from .storage import StorageManager
11
+
12
+ __all__ = [
13
+ "PlaywrightDriver",
14
+ "BrowserManager",
15
+ "ContextManager",
16
+ "Inspector",
17
+ "ScreenshotLayer",
18
+ "StorageManager",
19
+ ]
@@ -0,0 +1,108 @@
1
+ """Optional browser-use fallback driver.
2
+
3
+ - Lazy-imported. If `browser-use` is not installed, instantiation fails with
4
+ a clear message and the agent falls back to declaring the task blocked.
5
+ - Shares the same BrowserState, SessionManager, CancellationManager,
6
+ NetworkRecorder, and result format. It does NOT spawn a parallel browser
7
+ lifecycle.
8
+ - Never the normal execution path. Invoked only when:
9
+ 1. Playwright interaction cannot complete the task (after retries),
10
+ 2. `Settings.enable_browser_use_fallback` is True, AND
11
+ 3. `browser-use` is importable.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Any
17
+
18
+ from ..cancel import CancellationManager
19
+ from ..config import Settings
20
+ from ..logging import get_logger
21
+ from ..models import BrowserState
22
+
23
+ log = get_logger("sera.browser.browser_use_fallback")
24
+
25
+
26
+ def browser_use_available() -> bool:
27
+ try:
28
+ import browser_use # noqa: F401
29
+ return True
30
+ except Exception: # noqa: BLE001
31
+ return False
32
+
33
+
34
+ class BrowserUseFallback:
35
+ """Lazy wrapper around browser-use.
36
+
37
+ The wrapper deliberately exposes a tiny surface so the agent never
38
+ depends on browser-use's internals. It receives the same BrowserState
39
+ and CancellationManager as the Playwright path, mutates state in place,
40
+ and returns nothing — the agent reads state afterwards.
41
+ """
42
+
43
+ def __init__(self, settings: Settings, cancellation: CancellationManager) -> None:
44
+ if not settings.enable_browser_use_fallback:
45
+ raise PermissionError("browser-use fallback disabled by settings")
46
+ if not browser_use_available():
47
+ raise ImportError(
48
+ "browser-use is not installed. Install with "
49
+ "`pip install \".[fallback]\"` to enable the fallback driver."
50
+ )
51
+ self.settings = settings
52
+ self.cancellation = cancellation
53
+
54
+ async def attempt(
55
+ self,
56
+ state: BrowserState,
57
+ objective: str,
58
+ *,
59
+ max_steps: int = 10,
60
+ ) -> bool:
61
+ """Attempt the objective via browser-use. Returns True on success.
62
+
63
+ This is a thin integration: we hand the objective to browser-use
64
+ and let it drive. Network events from the page are already being
65
+ captured by NetworkRecorder wired in the Playwright path.
66
+
67
+ NOTE: browser-use may launch its own browser context. For maximum
68
+ isolation we accept this and ensure the CancellationManager cancels
69
+ its tasks too.
70
+ """
71
+ # Lazy import inside the method so the module imports cleanly when
72
+ # browser-use is absent.
73
+ try:
74
+ from browser_use import Agent as BUA # type: ignore
75
+ from browser_use import Browser as BUB # type: ignore
76
+ except Exception as e: # noqa: BLE001
77
+ log.error(
78
+ "browser-use import failed",
79
+ extra={"event": "browser_use.import.fail", "err": str(e)},
80
+ )
81
+ return False
82
+
83
+ # We don't want to spawn a parallel browser lifecycle indefinitely.
84
+ # Bound it strictly to `max_steps`.
85
+ self.cancellation.check()
86
+ try:
87
+ browser = BUB()
88
+ agent = BUA(
89
+ task=objective,
90
+ browser=browser,
91
+ max_steps=max_steps,
92
+ )
93
+ # Run it under a cancellation-aware wait so cancel propagates.
94
+ await self.cancellation.wait_or_cancel(agent.run()) # type: ignore[arg-type]
95
+ return True
96
+ except Exception as e: # noqa: BLE001
97
+ log.warning(
98
+ "browser-use attempt failed",
99
+ extra={"event": "browser_use.fail", "err": str(e)},
100
+ )
101
+ return False
102
+ finally:
103
+ # Best-effort cleanup of browser-use's own context.
104
+ try:
105
+ if 'browser' in locals():
106
+ await asyncio.wait_for(browser.close(), timeout=5.0) # type: ignore[union-attr]
107
+ except (asyncio.TimeoutError, Exception): # noqa: BLE001
108
+ pass