frontend-perception-engine 0.1.0__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.
- frontend_perception_engine-0.1.0.dist-info/METADATA +235 -0
- frontend_perception_engine-0.1.0.dist-info/RECORD +50 -0
- frontend_perception_engine-0.1.0.dist-info/WHEEL +5 -0
- frontend_perception_engine-0.1.0.dist-info/entry_points.txt +2 -0
- frontend_perception_engine-0.1.0.dist-info/top_level.txt +1 -0
- navigation/__init__.py +1 -0
- navigation/browser_use/__init__.py +17 -0
- navigation/browser_use/agent_runner.py +165 -0
- navigation/browser_use/hints.py +155 -0
- navigation/browser_use/integration.py +46 -0
- navigation/browser_use/llm.py +70 -0
- navigation/codeGraph/__init__.py +4 -0
- navigation/codeGraph/crg_impl.py +170 -0
- navigation/codeGraph/factory.py +24 -0
- navigation/codeGraph/interface.py +78 -0
- navigation/codeGraph/null_impl.py +47 -0
- navigation/mcp/__init__.py +4 -0
- navigation/mcp/__main__.py +4 -0
- navigation/mcp/diff.py +28 -0
- navigation/mcp/envelope.py +50 -0
- navigation/mcp/handlers.py +791 -0
- navigation/mcp/instructions.py +36 -0
- navigation/mcp/resources.py +82 -0
- navigation/mcp/scan_registry.py +47 -0
- navigation/mcp/server.py +229 -0
- navigation/mcp/session_store.py +87 -0
- navigation/mcp/tools.py +332 -0
- navigation/perception/__init__.py +76 -0
- navigation/perception/artifacts.py +19 -0
- navigation/perception/auth_gate.py +57 -0
- navigation/perception/budget.py +55 -0
- navigation/perception/cdp_hub.py +122 -0
- navigation/perception/dev_insights.py +625 -0
- navigation/perception/exploration.py +99 -0
- navigation/perception/feature_flags.py +83 -0
- navigation/perception/file_upload.py +84 -0
- navigation/perception/flow_graph.py +121 -0
- navigation/perception/form_probe.py +148 -0
- navigation/perception/iframe_context.py +76 -0
- navigation/perception/observation.py +97 -0
- navigation/perception/preflight.py +91 -0
- navigation/perception/rich_editors.py +90 -0
- navigation/perception/route_guards.py +136 -0
- navigation/perception/runner.py +138 -0
- navigation/perception/scan.py +74 -0
- navigation/perception/scripted_actions.py +67 -0
- navigation/perception/state_manager.py +114 -0
- navigation/perception/verification.py +117 -0
- navigation/perception/virtual_scroll.py +84 -0
- navigation/perception/websocket_observer.py +79 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""MCP server preamble — programs host agent behavior. See AGENT_GUIDE.md for full playbooks."""
|
|
2
|
+
|
|
3
|
+
MCP_INSTRUCTIONS = """\
|
|
4
|
+
Frontend Perception MCP — deterministic browser runtime (no LLM inside this server).
|
|
5
|
+
|
|
6
|
+
YOU are the brain. This server returns facts only — never "next step" suggestions.
|
|
7
|
+
Read AGENT_GUIDE.md at session start (resource: perception://agent-guide).
|
|
8
|
+
|
|
9
|
+
Universal loop (AGENT_GUIDE §0):
|
|
10
|
+
OBSERVE → REASON → ACT → VERIFY → repeat or STOP
|
|
11
|
+
|
|
12
|
+
Bootstrap (§1):
|
|
13
|
+
perception_health → perception_session_start → save session_id
|
|
14
|
+
|
|
15
|
+
Playbooks by task (AGENT_GUIDE.md):
|
|
16
|
+
§2 Page inspection / new UI → navigate_and_observe, verify, diff
|
|
17
|
+
§3 Debugging broken UI → observe blocking first, fix code, verify + diff
|
|
18
|
+
§4 Forms & validation → probe_form, invalid submit, verify, valid fill, verify
|
|
19
|
+
§5 Navigation & route guards → probe_guards, auth_gate, state_save/restore
|
|
20
|
+
§6 Multi-step flows → flow_describe, per-checkpoint verify
|
|
21
|
+
§7 Regression verify-only → observe + verify (no act unless fail)
|
|
22
|
+
§8 Responsive / viewport → session_start with viewport, observe + screenshot
|
|
23
|
+
§9 Feature flags & edge UI → edge-lab routes, feature-specific verify
|
|
24
|
+
§10 Code ↔ live UI → code_context + observe + diff
|
|
25
|
+
|
|
26
|
+
Hard rules:
|
|
27
|
+
- Never claim UI work is done without perception_verify.
|
|
28
|
+
- Never skip verify after execute_script or execute_actions.
|
|
29
|
+
- Read agent_summary.blocking before advisory warnings.
|
|
30
|
+
- On verify failure: re-observe with screenshot, then perception_diff.
|
|
31
|
+
- perception_auth_gate requires_human → STOP, ask user (no login/MFA loops).
|
|
32
|
+
- probe_form before filling unknown forms; invalid submit before valid.
|
|
33
|
+
- flow_describe gives checkpoints — YOU execute each; MCP does not run flows.
|
|
34
|
+
|
|
35
|
+
Eval scenario (smoke test your wiring): perception://eval/validation-form
|
|
36
|
+
"""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""MCP resources — guides, evals, and scan artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .scan_registry import ScanRegistry
|
|
9
|
+
|
|
10
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def list_resources(scans: ScanRegistry | None = None) -> list[dict[str, str]]:
|
|
14
|
+
resources = [
|
|
15
|
+
{
|
|
16
|
+
"uri": "perception://agent-guide",
|
|
17
|
+
"name": "AGENT_GUIDE",
|
|
18
|
+
"description": "Primary behavior contract — playbooks for host agent (read at session start)",
|
|
19
|
+
"mimeType": "text/markdown",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"uri": "perception://eval/validation-form",
|
|
23
|
+
"name": "Validation Form Eval",
|
|
24
|
+
"description": "M3 eval scenario — complete using AGENT_GUIDE §4 form playbook",
|
|
25
|
+
"mimeType": "text/markdown",
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
if scans is not None:
|
|
29
|
+
for rec in scans.all():
|
|
30
|
+
resources.append(
|
|
31
|
+
{
|
|
32
|
+
"uri": f"perception://scan/{rec.scan_id}/report.json",
|
|
33
|
+
"name": f"scan_report_{rec.scan_id}",
|
|
34
|
+
"description": f"Observation report for {rec.scan_id}",
|
|
35
|
+
"mimeType": "application/json",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
if rec.screenshot_path:
|
|
39
|
+
resources.append(
|
|
40
|
+
{
|
|
41
|
+
"uri": f"perception://scan/{rec.scan_id}/screenshot.png",
|
|
42
|
+
"name": f"scan_screenshot_{rec.scan_id}",
|
|
43
|
+
"description": f"Screenshot image for {rec.scan_id}",
|
|
44
|
+
"mimeType": "image/png",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
return resources
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_resource(uri: str, scans: ScanRegistry | None = None) -> tuple[str, str, bool]:
|
|
51
|
+
"""Return (mime_type, payload, is_blob). Raises KeyError if unknown."""
|
|
52
|
+
if uri == "perception://agent-guide":
|
|
53
|
+
path = PROJECT_ROOT / "AGENT_GUIDE.md"
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
raise FileNotFoundError(f"AGENT_GUIDE.md not found at {path}")
|
|
56
|
+
return "text/markdown", path.read_text(encoding="utf-8"), False
|
|
57
|
+
|
|
58
|
+
if uri == "perception://eval/validation-form":
|
|
59
|
+
path = PROJECT_ROOT / "evals" / "VALIDATION_FORM_EVAL.md"
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
raise FileNotFoundError(f"eval doc not found at {path}")
|
|
62
|
+
return "text/markdown", path.read_text(encoding="utf-8"), False
|
|
63
|
+
|
|
64
|
+
if uri.startswith("perception://scan/") and scans is not None:
|
|
65
|
+
# pattern: perception://scan/{scan_id}/(report.json|screenshot.png)
|
|
66
|
+
parts = uri.split("/")
|
|
67
|
+
if len(parts) >= 5:
|
|
68
|
+
scan_id = parts[3]
|
|
69
|
+
artifact = parts[4]
|
|
70
|
+
rec = scans.get(scan_id)
|
|
71
|
+
if rec is None:
|
|
72
|
+
raise KeyError(uri)
|
|
73
|
+
if artifact == "report.json":
|
|
74
|
+
return "application/json", json.dumps(rec.observation, indent=2, default=str), False
|
|
75
|
+
if artifact == "screenshot.png":
|
|
76
|
+
screenshot_path = Path(str(rec.screenshot_path or ""))
|
|
77
|
+
if not screenshot_path.is_file():
|
|
78
|
+
return "text/plain", "", False
|
|
79
|
+
raw = screenshot_path.read_bytes()
|
|
80
|
+
return "image/png", base64.b64encode(raw).decode("ascii"), True
|
|
81
|
+
|
|
82
|
+
raise KeyError(uri)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Observation snapshots keyed by scan_id (for diff and audit)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ScanRecord:
|
|
11
|
+
scan_id: str
|
|
12
|
+
session_id: str
|
|
13
|
+
run_id: str
|
|
14
|
+
url: str
|
|
15
|
+
observation: dict[str, Any]
|
|
16
|
+
screenshot_path: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ScanRegistry:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._scans: dict[str, ScanRecord] = {}
|
|
22
|
+
|
|
23
|
+
def register(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
session_id: str,
|
|
27
|
+
run_id: str,
|
|
28
|
+
url: str,
|
|
29
|
+
observation: dict[str, Any],
|
|
30
|
+
) -> ScanRecord:
|
|
31
|
+
scan_id = f"scan_{uuid.uuid4().hex[:12]}"
|
|
32
|
+
rec = ScanRecord(
|
|
33
|
+
scan_id=scan_id,
|
|
34
|
+
session_id=session_id,
|
|
35
|
+
run_id=run_id,
|
|
36
|
+
url=url,
|
|
37
|
+
observation=observation,
|
|
38
|
+
screenshot_path=observation.get("screenshot_path"),
|
|
39
|
+
)
|
|
40
|
+
self._scans[scan_id] = rec
|
|
41
|
+
return rec
|
|
42
|
+
|
|
43
|
+
def get(self, scan_id: str) -> ScanRecord | None:
|
|
44
|
+
return self._scans.get(scan_id)
|
|
45
|
+
|
|
46
|
+
def all(self) -> list[ScanRecord]:
|
|
47
|
+
return list(self._scans.values())
|
navigation/mcp/server.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Frontend Perception MCP server — host agent is the brain; this is the runtime."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
os.environ.setdefault("BROWSER_USE_LOGGING_LEVEL", "warning")
|
|
13
|
+
os.environ.setdefault("BROWSER_USE_SETUP_LOGGING", "false")
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import mcp.server.stdio
|
|
19
|
+
import mcp.types as types
|
|
20
|
+
from mcp.server import NotificationOptions, Server
|
|
21
|
+
from mcp.server.models import InitializationOptions
|
|
22
|
+
|
|
23
|
+
MCP_AVAILABLE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
MCP_AVAILABLE = False
|
|
26
|
+
types = None # type: ignore
|
|
27
|
+
|
|
28
|
+
from .handlers import (
|
|
29
|
+
handle_auth_gate,
|
|
30
|
+
handle_code_context,
|
|
31
|
+
handle_diff,
|
|
32
|
+
handle_execute_actions,
|
|
33
|
+
handle_execute_script,
|
|
34
|
+
handle_flow_describe,
|
|
35
|
+
handle_health,
|
|
36
|
+
handle_navigate,
|
|
37
|
+
handle_navigate_and_observe,
|
|
38
|
+
handle_observe,
|
|
39
|
+
handle_probe_form,
|
|
40
|
+
handle_probe_guards,
|
|
41
|
+
handle_session_end,
|
|
42
|
+
handle_session_start,
|
|
43
|
+
handle_state_list,
|
|
44
|
+
handle_state_restore,
|
|
45
|
+
handle_state_save,
|
|
46
|
+
handle_verify,
|
|
47
|
+
)
|
|
48
|
+
from .instructions import MCP_INSTRUCTIONS
|
|
49
|
+
from .resources import list_resources, read_resource
|
|
50
|
+
from .scan_registry import ScanRegistry
|
|
51
|
+
from .session_store import SessionStore
|
|
52
|
+
from .tools import perception_tools
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
HandlerFn = Callable[..., Awaitable[dict[str, Any]]]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PerceptionMCPServer:
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
if not MCP_AVAILABLE:
|
|
61
|
+
raise RuntimeError("mcp package not installed. pip install mcp")
|
|
62
|
+
self._store = SessionStore()
|
|
63
|
+
self._scans = ScanRegistry()
|
|
64
|
+
self._server = Server("frontend-perception")
|
|
65
|
+
self._dispatch: dict[str, HandlerFn] = self._build_dispatch()
|
|
66
|
+
|
|
67
|
+
@self._server.list_tools()
|
|
68
|
+
async def list_tools() -> list[types.Tool]:
|
|
69
|
+
return perception_tools(types)
|
|
70
|
+
|
|
71
|
+
@self._server.list_resources()
|
|
72
|
+
async def list_resources_handler() -> list[types.Resource]:
|
|
73
|
+
return [
|
|
74
|
+
types.Resource(
|
|
75
|
+
uri=item["uri"],
|
|
76
|
+
name=item["name"],
|
|
77
|
+
description=item["description"],
|
|
78
|
+
mimeType=item["mimeType"],
|
|
79
|
+
)
|
|
80
|
+
for item in list_resources(self._scans)
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
@self._server.read_resource()
|
|
84
|
+
async def read_resource_handler(uri: str) -> types.TextResourceContents | types.BlobResourceContents:
|
|
85
|
+
uri_str = str(uri)
|
|
86
|
+
mime, payload, is_blob = read_resource(uri_str, self._scans)
|
|
87
|
+
if is_blob:
|
|
88
|
+
return types.BlobResourceContents(uri=uri_str, mimeType=mime, blob=payload)
|
|
89
|
+
return types.TextResourceContents(uri=uri_str, mimeType=mime, text=payload)
|
|
90
|
+
|
|
91
|
+
@self._server.call_tool()
|
|
92
|
+
async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]:
|
|
93
|
+
args = arguments or {}
|
|
94
|
+
try:
|
|
95
|
+
handler = self._dispatch.get(name)
|
|
96
|
+
if handler is None:
|
|
97
|
+
result = {
|
|
98
|
+
"contract_version": "1.0",
|
|
99
|
+
"tool": name,
|
|
100
|
+
"ok": False,
|
|
101
|
+
"error": f"unknown tool: {name}",
|
|
102
|
+
"data": {},
|
|
103
|
+
}
|
|
104
|
+
else:
|
|
105
|
+
result = await handler(args)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
logger.exception("tool %s failed", name)
|
|
108
|
+
result = {
|
|
109
|
+
"contract_version": "1.0",
|
|
110
|
+
"tool": name,
|
|
111
|
+
"ok": False,
|
|
112
|
+
"error": str(exc),
|
|
113
|
+
"data": {},
|
|
114
|
+
}
|
|
115
|
+
return [types.TextContent(type="text", text=json.dumps(result, indent=2, default=str))]
|
|
116
|
+
|
|
117
|
+
def _build_dispatch(self) -> dict[str, HandlerFn]:
|
|
118
|
+
store = self._store
|
|
119
|
+
scans = self._scans
|
|
120
|
+
|
|
121
|
+
async def health(args: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
return await handle_health(args)
|
|
123
|
+
|
|
124
|
+
async def session_start(args: dict[str, Any]) -> dict[str, Any]:
|
|
125
|
+
return await handle_session_start(store, args)
|
|
126
|
+
|
|
127
|
+
async def session_end(args: dict[str, Any]) -> dict[str, Any]:
|
|
128
|
+
return await handle_session_end(store, args)
|
|
129
|
+
|
|
130
|
+
async def navigate(args: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
return await handle_navigate(store, args)
|
|
132
|
+
|
|
133
|
+
async def navigate_and_observe(args: dict[str, Any]) -> dict[str, Any]:
|
|
134
|
+
return await handle_navigate_and_observe(store, scans, args)
|
|
135
|
+
|
|
136
|
+
async def observe(args: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
return await handle_observe(store, scans, args)
|
|
138
|
+
|
|
139
|
+
async def execute_script(args: dict[str, Any]) -> dict[str, Any]:
|
|
140
|
+
return await handle_execute_script(store, scans, args)
|
|
141
|
+
|
|
142
|
+
async def execute_actions(args: dict[str, Any]) -> dict[str, Any]:
|
|
143
|
+
return await handle_execute_actions(store, scans, args)
|
|
144
|
+
|
|
145
|
+
async def verify(args: dict[str, Any]) -> dict[str, Any]:
|
|
146
|
+
return await handle_verify(store, args)
|
|
147
|
+
|
|
148
|
+
async def diff(args: dict[str, Any]) -> dict[str, Any]:
|
|
149
|
+
return await handle_diff(scans, args)
|
|
150
|
+
|
|
151
|
+
async def auth_gate(args: dict[str, Any]) -> dict[str, Any]:
|
|
152
|
+
return await handle_auth_gate(store, args)
|
|
153
|
+
|
|
154
|
+
async def probe_form(args: dict[str, Any]) -> dict[str, Any]:
|
|
155
|
+
return await handle_probe_form(store, args)
|
|
156
|
+
|
|
157
|
+
async def probe_guards(args: dict[str, Any]) -> dict[str, Any]:
|
|
158
|
+
return await handle_probe_guards(store, args)
|
|
159
|
+
|
|
160
|
+
async def state_save(args: dict[str, Any]) -> dict[str, Any]:
|
|
161
|
+
return await handle_state_save(store, args)
|
|
162
|
+
|
|
163
|
+
async def state_restore(args: dict[str, Any]) -> dict[str, Any]:
|
|
164
|
+
return await handle_state_restore(store, args)
|
|
165
|
+
|
|
166
|
+
async def state_list(args: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
return await handle_state_list(store, args)
|
|
168
|
+
|
|
169
|
+
async def flow_describe(args: dict[str, Any]) -> dict[str, Any]:
|
|
170
|
+
return await handle_flow_describe(args)
|
|
171
|
+
|
|
172
|
+
async def code_context(args: dict[str, Any]) -> dict[str, Any]:
|
|
173
|
+
return await handle_code_context(args)
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"perception_health": health,
|
|
177
|
+
"perception_session_start": session_start,
|
|
178
|
+
"perception_session_end": session_end,
|
|
179
|
+
"perception_navigate": navigate,
|
|
180
|
+
"perception_navigate_and_observe": navigate_and_observe,
|
|
181
|
+
"perception_observe": observe,
|
|
182
|
+
"perception_execute_script": execute_script,
|
|
183
|
+
"perception_execute_actions": execute_actions,
|
|
184
|
+
"perception_verify": verify,
|
|
185
|
+
"perception_diff": diff,
|
|
186
|
+
"perception_auth_gate": auth_gate,
|
|
187
|
+
"perception_probe_form": probe_form,
|
|
188
|
+
"perception_probe_guards": probe_guards,
|
|
189
|
+
"perception_state_save": state_save,
|
|
190
|
+
"perception_state_restore": state_restore,
|
|
191
|
+
"perception_state_list": state_list,
|
|
192
|
+
"perception_flow_describe": flow_describe,
|
|
193
|
+
"perception_code_context": code_context,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async def run(self) -> None:
|
|
197
|
+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
|
198
|
+
await self._server.run(
|
|
199
|
+
read_stream,
|
|
200
|
+
write_stream,
|
|
201
|
+
InitializationOptions(
|
|
202
|
+
server_name="frontend-perception",
|
|
203
|
+
server_version="0.4.0",
|
|
204
|
+
capabilities=self._server.get_capabilities(
|
|
205
|
+
notification_options=NotificationOptions(),
|
|
206
|
+
experimental_capabilities={},
|
|
207
|
+
),
|
|
208
|
+
instructions=MCP_INSTRUCTIONS,
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def async_main() -> None:
|
|
214
|
+
if not MCP_AVAILABLE:
|
|
215
|
+
print("Install MCP: pip install mcp", file=sys.stderr)
|
|
216
|
+
raise SystemExit(1)
|
|
217
|
+
server = PerceptionMCPServer()
|
|
218
|
+
try:
|
|
219
|
+
await server.run()
|
|
220
|
+
finally:
|
|
221
|
+
await server._store.end_all()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main() -> None:
|
|
225
|
+
asyncio.run(async_main())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Browser session lifecycle for MCP tools."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from navigation.perception.state_manager import StateManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SessionRecord:
|
|
14
|
+
session_id: str
|
|
15
|
+
base_url: str
|
|
16
|
+
browser: Any
|
|
17
|
+
artifacts_dir: Path
|
|
18
|
+
state_manager: StateManager = field(default_factory=StateManager)
|
|
19
|
+
run_counter: int = 0
|
|
20
|
+
current_run_id: str = ""
|
|
21
|
+
|
|
22
|
+
def next_run_id(self) -> str:
|
|
23
|
+
self.run_counter += 1
|
|
24
|
+
self.current_run_id = f"run_{self.run_counter:04d}"
|
|
25
|
+
return self.current_run_id
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionStore:
|
|
29
|
+
def __init__(self, artifacts_root: Path | None = None) -> None:
|
|
30
|
+
self._sessions: dict[str, SessionRecord] = {}
|
|
31
|
+
self._artifacts_root = artifacts_root or Path.cwd() / "artifacts" / "mcp"
|
|
32
|
+
|
|
33
|
+
def get(self, session_id: str) -> SessionRecord | None:
|
|
34
|
+
return self._sessions.get(session_id)
|
|
35
|
+
|
|
36
|
+
def require(self, session_id: str) -> SessionRecord:
|
|
37
|
+
rec = self.get(session_id)
|
|
38
|
+
if rec is None:
|
|
39
|
+
raise KeyError(f"unknown session_id: {session_id}")
|
|
40
|
+
return rec
|
|
41
|
+
|
|
42
|
+
async def start(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
base_url: str,
|
|
46
|
+
headless: bool = True,
|
|
47
|
+
viewport_width: int = 1920,
|
|
48
|
+
viewport_height: int = 1080,
|
|
49
|
+
) -> SessionRecord:
|
|
50
|
+
from browser_use import BrowserProfile, BrowserSession
|
|
51
|
+
|
|
52
|
+
session_id = f"sess_{uuid.uuid4().hex[:12]}"
|
|
53
|
+
artifacts_dir = self._artifacts_root / session_id
|
|
54
|
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
browser = BrowserSession(
|
|
57
|
+
browser_profile=BrowserProfile(
|
|
58
|
+
headless=headless,
|
|
59
|
+
viewport={"width": viewport_width, "height": viewport_height},
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
await browser.start()
|
|
63
|
+
await browser.navigate_to(base_url.rstrip("/"))
|
|
64
|
+
|
|
65
|
+
rec = SessionRecord(
|
|
66
|
+
session_id=session_id,
|
|
67
|
+
base_url=base_url.rstrip("/"),
|
|
68
|
+
browser=browser,
|
|
69
|
+
artifacts_dir=artifacts_dir,
|
|
70
|
+
)
|
|
71
|
+
rec.next_run_id()
|
|
72
|
+
self._sessions[session_id] = rec
|
|
73
|
+
return rec
|
|
74
|
+
|
|
75
|
+
async def end(self, session_id: str) -> bool:
|
|
76
|
+
rec = self._sessions.pop(session_id, None)
|
|
77
|
+
if rec is None:
|
|
78
|
+
return False
|
|
79
|
+
try:
|
|
80
|
+
await rec.browser.kill()
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
async def end_all(self) -> None:
|
|
86
|
+
for sid in list(self._sessions):
|
|
87
|
+
await self.end(sid)
|