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.
- sera_browser/__init__.py +203 -0
- sera_browser/__main__.py +26 -0
- sera_browser/api/__init__.py +6 -0
- sera_browser/api/app.py +275 -0
- sera_browser/browser/__init__.py +19 -0
- sera_browser/browser/browser_use_fallback.py +108 -0
- sera_browser/browser/cdp_ax.py +230 -0
- sera_browser/browser/context.py +163 -0
- sera_browser/browser/driver.py +801 -0
- sera_browser/browser/inspector.py +208 -0
- sera_browser/browser/manager.py +178 -0
- sera_browser/browser/refmap.py +283 -0
- sera_browser/browser/resolver.py +231 -0
- sera_browser/browser/screenshots.py +59 -0
- sera_browser/browser/som.py +216 -0
- sera_browser/browser/stealth.py +271 -0
- sera_browser/browser/storage.py +78 -0
- sera_browser/browser/tabs.py +260 -0
- sera_browser/browser/trimmer.py +141 -0
- sera_browser/browser/viewer.py +328 -0
- sera_browser/cancel.py +107 -0
- sera_browser/challenge/__init__.py +6 -0
- sera_browser/challenge/detector.py +301 -0
- sera_browser/config.py +209 -0
- sera_browser/eval/__init__.py +20 -0
- sera_browser/eval/harness.py +405 -0
- sera_browser/logging.py +128 -0
- sera_browser/models.py +340 -0
- sera_browser/net/__init__.py +6 -0
- sera_browser/net/recorder.py +288 -0
- sera_browser/security.py +137 -0
- sera_browser/session/__init__.py +6 -0
- sera_browser/session/manager.py +214 -0
- sera_browser/state/__init__.py +6 -0
- sera_browser/state/state.py +109 -0
- sera_browser/task/__init__.py +63 -0
- sera_browser/task/action_schema.py +412 -0
- sera_browser/task/agent.py +750 -0
- sera_browser/task/executor.py +532 -0
- sera_browser/task/extraction.py +169 -0
- sera_browser/task/history.py +190 -0
- sera_browser/task/llm_client.py +743 -0
- sera_browser/task/llm_planner.py +696 -0
- sera_browser/task/loop.py +102 -0
- sera_browser/task/memory.py +168 -0
- sera_browser/task/observability.py +246 -0
- sera_browser/task/pav.py +453 -0
- sera_browser/task/planner.py +192 -0
- sera_browser/task/report.py +139 -0
- sera_browser/task/safety.py +294 -0
- sera_browser/task/soft_restrict.py +92 -0
- sera_browser-3.1.1.dist-info/METADATA +244 -0
- sera_browser-3.1.1.dist-info/RECORD +56 -0
- sera_browser-3.1.1.dist-info/WHEEL +5 -0
- sera_browser-3.1.1.dist-info/entry_points.txt +2 -0
- sera_browser-3.1.1.dist-info/top_level.txt +1 -0
sera_browser/__init__.py
ADDED
|
@@ -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
|
+
]
|
sera_browser/__main__.py
ADDED
|
@@ -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())
|
sera_browser/api/app.py
ADDED
|
@@ -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
|