agent-portal-2 0.1.0
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.
- package/.continue/agents/new-config.yaml +22 -0
- package/AGENT_STEERING.md +36 -0
- package/ARCHITECTURE.md +13 -0
- package/CHANGELOG.md +97 -0
- package/CLI.md +38 -0
- package/CONTRIBUTING.md +55 -0
- package/INSTALLATION.md +58 -0
- package/LICENSE +60 -0
- package/PLUGIN_SYSTEM.md +33 -0
- package/PYTHON_SDK.md +22 -0
- package/QUICKSTART.md +19 -0
- package/README.md +385 -0
- package/RELEASE_NOTES_v0.1.0.md +281 -0
- package/ROADMAP.md +3 -0
- package/RUNTIME.md +44 -0
- package/SAFETY_MODEL.md +24 -0
- package/TESTING.md +35 -0
- package/TROUBLESHOOTING.md +30 -0
- package/UPGRADE_GUIDE.md +288 -0
- package/VS_CODE_EXTENSION.md +47 -0
- package/agent-portal.config.json +20 -0
- package/apps/desktop/agent-portal-desktop.zip +0 -0
- package/apps/desktop/fixtures/local-workflow.html +151 -0
- package/apps/desktop/package.json +18 -0
- package/apps/desktop/src/main.ts +117 -0
- package/apps/desktop/tsconfig.json +8 -0
- package/apps/vscode-extension/LICENSE +60 -0
- package/apps/vscode-extension/README.md +20 -0
- package/apps/vscode-extension/media/agent-portal-logo.png +0 -0
- package/apps/vscode-extension/package.json +149 -0
- package/apps/vscode-extension/src/extension.ts +614 -0
- package/apps/vscode-extension/tsconfig.json +12 -0
- package/assets/branding/agent-portal-logo.png +0 -0
- package/connectors/chatgpt-tools/README.md +9 -0
- package/connectors/claude-mcp-server/README.md +9 -0
- package/connectors/gemini-connector/README.md +9 -0
- package/connectors/rest-websocket-api/README.md +9 -0
- package/docs/MCP_SERVER.md +68 -0
- package/docs/architecture.md +214 -0
- package/docs/roadmap.md +125 -0
- package/package.json +21 -0
- package/packages/agent-portal-mcp/README.md +12 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/__init__.py +3 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/bridge/__init__.py +1 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/bridge/runtime_client.py +180 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/cli.py +32 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/doctor.py +71 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/schemas/__init__.py +1 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/schemas/actions.py +17 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/schemas/results.py +24 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/schemas/risk.py +20 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/security/__init__.py +1 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/security/policy.py +27 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/server.py +148 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tool_registry.py +58 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/__init__.py +1 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/browser.py +89 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/common.py +98 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/inspection.py +93 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/navigation.py +93 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/reports.py +34 -0
- package/packages/agent-portal-mcp/agent_portal_mcp/tools/steering.py +93 -0
- package/packages/agent-portal-mcp/pyproject.toml +20 -0
- package/packages/agent-portal-mcp/tests/test_doctor.py +20 -0
- package/packages/agent-portal-mcp/tests/test_mcp_server.py +161 -0
- package/packages/core/package.json +15 -0
- package/packages/core/src/index.ts +1842 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/mcp-server/package.json +15 -0
- package/packages/mcp-server/src/index.ts +73 -0
- package/packages/mcp-server/tsconfig.json +8 -0
- package/packages/sdk/package.json +15 -0
- package/packages/sdk/src/index.ts +544 -0
- package/packages/sdk/tsconfig.json +8 -0
- package/plugins/README.md +16 -0
- package/plugins/agent-portal-browser/plugin.json +19 -0
- package/plugins/agent-portal-python/plugin.json +16 -0
- package/plugins/agent-portal-skills/plugin.json +19 -0
- package/plugins/agent-portal-vscode/plugin.json +27 -0
- package/plugins/example-runtime-plugin/README.md +3 -0
- package/plugins/example-runtime-plugin/plugin.json +20 -0
- package/plugins/plugin.schema.json +53 -0
- package/python/README.md +18 -0
- package/python/agent_portal/__init__.py +5 -0
- package/python/agent_portal/__main__.py +5 -0
- package/python/agent_portal/browser.py +393 -0
- package/python/agent_portal/cli.py +164 -0
- package/python/agent_portal/config.py +31 -0
- package/python/agent_portal/doctor.py +165 -0
- package/python/agent_portal/exceptions.py +39 -0
- package/python/agent_portal/logging_utils.py +33 -0
- package/python/agent_portal/metrics.py +309 -0
- package/python/agent_portal/models.py +160 -0
- package/python/agent_portal/plugin_system.py +42 -0
- package/python/agent_portal/rate_limit.py +253 -0
- package/python/agent_portal/runtime.py +739 -0
- package/python/agent_portal/server.py +351 -0
- package/python/agent_portal/validation.py +299 -0
- package/python/pyproject.toml +29 -0
- package/python/tests/test_config.py +24 -0
- package/python/tests/test_doctor.py +19 -0
- package/python/tests/test_metrics.py +180 -0
- package/python/tests/test_rate_limit.py +237 -0
- package/python/tests/test_runtime.py +122 -0
- package/python/tests/test_server.py +53 -0
- package/python/tests/test_validation.py +170 -0
- package/releases/desktop/agent-portal-desktop/README.md +378 -0
- package/releases/desktop/agent-portal-desktop/RELEASE_NOTES.md +14 -0
- package/releases/desktop/agent-portal-desktop/assets/branding/agent-portal-logo.png +0 -0
- package/releases/desktop/agent-portal-desktop/fixtures/local-workflow.html +151 -0
- package/releases/desktop/agent-portal-desktop/launch-agent-portal.bat +4 -0
- package/releases/desktop/agent-portal-desktop.zip +0 -0
- package/releases/python/agent_portal-0.0.2-py3-none-any.whl +0 -0
- package/releases/python/agent_portal-0.0.2.tar.gz +0 -0
- package/scripts/package_desktop.mjs +117 -0
- package/scripts/release_python.py +46 -0
- package/tests/plugin-manifest.test.mjs +26 -0
- package/tests/runtime.test.mjs +41 -0
- package/tests/vscode-extension.test.mjs +22 -0
- package/tsconfig.base.json +16 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from .browser import BrowserController
|
|
11
|
+
from .config import load_config
|
|
12
|
+
from .exceptions import AgentPortalError, PolicyBlockedError, RuntimeStartupError
|
|
13
|
+
from .logging_utils import build_logger
|
|
14
|
+
from .models import (
|
|
15
|
+
ActionRequest,
|
|
16
|
+
ActionResult,
|
|
17
|
+
BrowserState,
|
|
18
|
+
RiskPolicy,
|
|
19
|
+
RuntimeConfigModel,
|
|
20
|
+
SessionReport,
|
|
21
|
+
SessionState,
|
|
22
|
+
utc_now,
|
|
23
|
+
)
|
|
24
|
+
from .plugin_system import discover_plugins, validate_plugin_manifest
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
RISK_ORDER = ["safe", "low", "medium", "high", "blocked"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PortalRuntime:
|
|
31
|
+
def __init__(self, base_path: Path | None = None, config: RuntimeConfigModel | None = None) -> None:
|
|
32
|
+
self.base_path = base_path or Path.cwd()
|
|
33
|
+
self.config = config or load_config(self.base_path)
|
|
34
|
+
self.logger = build_logger("agent_portal.runtime", self.config.log_level)
|
|
35
|
+
self.session = SessionState(session_id=f"session-{utc_now().replace(':', '-')}")
|
|
36
|
+
self.browser_state = BrowserState(browser_name=self.config.default_browser)
|
|
37
|
+
self.risk_policy = RiskPolicy(
|
|
38
|
+
mode=self.config.action_mode,
|
|
39
|
+
approval_threshold=self.config.approval_policy,
|
|
40
|
+
read_only=self.config.action_mode == "read-only",
|
|
41
|
+
)
|
|
42
|
+
self.browser = BrowserController(
|
|
43
|
+
screenshot_directory=self.base_path / self.config.screenshot_directory
|
|
44
|
+
)
|
|
45
|
+
self._lock_path = self.base_path / ".agent-portal-runtime.lock"
|
|
46
|
+
self._started = False
|
|
47
|
+
|
|
48
|
+
def startup_validation(self) -> None:
|
|
49
|
+
if self.config.runtime_host == "0.0.0.0":
|
|
50
|
+
self.logger.warning(
|
|
51
|
+
"Runtime is configured to bind publicly.",
|
|
52
|
+
extra={"context": {"host": self.config.runtime_host}},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
self._ensure_single_instance()
|
|
57
|
+
self._ensure_port_available()
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
raise RuntimeStartupError(
|
|
60
|
+
"Runtime could not bind to the requested port.",
|
|
61
|
+
module="agent_portal.runtime",
|
|
62
|
+
likely_cause="Another runtime instance is already running.",
|
|
63
|
+
suggested_fix="Run `agent-portal stop` or change the configured port.",
|
|
64
|
+
can_continue=False,
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
def start(self) -> None:
|
|
68
|
+
if self._started:
|
|
69
|
+
return
|
|
70
|
+
self.startup_validation()
|
|
71
|
+
self.session.started_at = utc_now()
|
|
72
|
+
self.session.ended_at = None
|
|
73
|
+
self.session.runtime_status = "idle"
|
|
74
|
+
self._started = True
|
|
75
|
+
self.logger.info("Runtime started")
|
|
76
|
+
|
|
77
|
+
def stop(self) -> None:
|
|
78
|
+
self.session.runtime_status = "stopped"
|
|
79
|
+
self.session.ended_at = utc_now()
|
|
80
|
+
self.browser_state.connected = False
|
|
81
|
+
try:
|
|
82
|
+
self.browser.stop()
|
|
83
|
+
finally:
|
|
84
|
+
self._remove_lock()
|
|
85
|
+
self._started = False
|
|
86
|
+
self.logger.info("Runtime stopped")
|
|
87
|
+
|
|
88
|
+
def pause(self) -> None:
|
|
89
|
+
self.session.runtime_status = "paused"
|
|
90
|
+
self.session.logs.insert(0, "Runtime paused")
|
|
91
|
+
|
|
92
|
+
def resume(self) -> None:
|
|
93
|
+
self.session.runtime_status = "acting"
|
|
94
|
+
self.session.logs.insert(0, "Runtime resumed")
|
|
95
|
+
|
|
96
|
+
def restart(self) -> None:
|
|
97
|
+
self.stop()
|
|
98
|
+
self.start()
|
|
99
|
+
|
|
100
|
+
def set_goal(self, goal: str) -> None:
|
|
101
|
+
self.session.current_goal = goal
|
|
102
|
+
self.session.logs.insert(0, f"Goal set to {goal}")
|
|
103
|
+
|
|
104
|
+
def get_current_goal(self) -> str | None:
|
|
105
|
+
return self.session.current_goal
|
|
106
|
+
|
|
107
|
+
def set_action_mode(self, mode: str) -> None:
|
|
108
|
+
if mode not in {"read-only", "assisted", "autonomous", "manual-override"}:
|
|
109
|
+
raise AgentPortalError(
|
|
110
|
+
f"Unsupported action mode: {mode}",
|
|
111
|
+
module="agent_portal.runtime",
|
|
112
|
+
likely_cause="The requested mode is not part of the runtime steering model.",
|
|
113
|
+
suggested_fix="Use one of: read-only, assisted, autonomous, manual-override.",
|
|
114
|
+
can_continue=True,
|
|
115
|
+
)
|
|
116
|
+
self.config.action_mode = mode # type: ignore[assignment]
|
|
117
|
+
self.risk_policy.mode = mode # type: ignore[assignment]
|
|
118
|
+
self.risk_policy.read_only = mode == "read-only"
|
|
119
|
+
self.session.logs.insert(0, f"Action mode set to {mode}")
|
|
120
|
+
|
|
121
|
+
def get_action_queue(self) -> list[dict[str, Any]]:
|
|
122
|
+
return [asdict(action) for action in self._all_actions()]
|
|
123
|
+
|
|
124
|
+
def status(self) -> dict[str, Any]:
|
|
125
|
+
return {
|
|
126
|
+
"session": asdict(self.session),
|
|
127
|
+
"browser": asdict(self.browser_state),
|
|
128
|
+
"config": asdict(self.config),
|
|
129
|
+
"policy": asdict(self.risk_policy),
|
|
130
|
+
"plugins": self.list_plugins(),
|
|
131
|
+
"runtimeVersion": "0.0.3",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
def health(self) -> dict[str, Any]:
|
|
135
|
+
return {
|
|
136
|
+
"ok": True,
|
|
137
|
+
"runtimeVersion": "0.0.3",
|
|
138
|
+
"runtimeStatus": self.session.runtime_status,
|
|
139
|
+
"browserConnected": self.browser_state.connected,
|
|
140
|
+
"currentUrl": self.browser_state.current_url,
|
|
141
|
+
"pageTitle": self.browser_state.page_title,
|
|
142
|
+
"pluginCount": len(discover_plugins(self.base_path)),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def ensure_browser(self) -> None:
|
|
146
|
+
if not self.browser.available():
|
|
147
|
+
raise RuntimeStartupError(
|
|
148
|
+
"Python Playwright dependency is missing.",
|
|
149
|
+
module="agent_portal.runtime",
|
|
150
|
+
likely_cause="The Python runtime dependencies were not installed.",
|
|
151
|
+
suggested_fix="Run `pip install -e ./python`.",
|
|
152
|
+
can_continue=False,
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
self.browser.start()
|
|
156
|
+
self.browser_state.connected = True
|
|
157
|
+
self.session.runtime_status = "acting"
|
|
158
|
+
except AgentPortalError as exc:
|
|
159
|
+
self.browser_state.last_error = exc.message
|
|
160
|
+
self.browser_state.connected = False
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
def open_url(self, url: str) -> ActionResult:
|
|
164
|
+
return self._execute_action(
|
|
165
|
+
ActionRequest("open_url", "Open a URL", target=url),
|
|
166
|
+
lambda action: self._open_url_impl(url, action),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def screenshot(self, label: str = "manual") -> ActionResult:
|
|
170
|
+
return self._execute_action(
|
|
171
|
+
ActionRequest("screenshot", "Capture a screenshot", risk_level="safe"),
|
|
172
|
+
lambda action: self.complete_action(
|
|
173
|
+
action.action_id,
|
|
174
|
+
"Captured screenshot",
|
|
175
|
+
after_screenshot=self.browser.screenshot(label),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def click(self, selector: str, reason: str) -> ActionResult:
|
|
180
|
+
return self._execute_action(
|
|
181
|
+
ActionRequest("click", reason, target=selector),
|
|
182
|
+
lambda action: self._click_impl(selector, action),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def type_text(self, selector: str, value: str, reason: str) -> ActionResult:
|
|
186
|
+
risk = "blocked" if "password" in selector.lower() else "low"
|
|
187
|
+
action = self.propose_action(
|
|
188
|
+
ActionRequest("type", reason, target=selector, payload=value, risk_level=risk)
|
|
189
|
+
)
|
|
190
|
+
if action.status == "blocked":
|
|
191
|
+
raise PolicyBlockedError(
|
|
192
|
+
"Action was blocked because it attempted to type into a protected field.",
|
|
193
|
+
module="agent_portal.runtime",
|
|
194
|
+
likely_cause="The target appears to be a password field or protected input.",
|
|
195
|
+
suggested_fix="Use manual override only if you intend to enter a sensitive value.",
|
|
196
|
+
can_continue=True,
|
|
197
|
+
)
|
|
198
|
+
return self._execute_approved_action(
|
|
199
|
+
action.action_id,
|
|
200
|
+
lambda approved: self._type_impl(selector, value, approved),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def scroll(self, selector: str | None = None, reason: str = "Scroll the page") -> ActionResult:
|
|
204
|
+
return self._execute_action(
|
|
205
|
+
ActionRequest("scroll", reason, target=selector, risk_level="safe"),
|
|
206
|
+
lambda action: self._scroll_impl(selector, action),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def hover(self, selector: str, reason: str = "Hover over element") -> ActionResult:
|
|
210
|
+
return self._execute_action(
|
|
211
|
+
ActionRequest("hover", reason, target=selector, risk_level="safe"),
|
|
212
|
+
lambda action: self._hover_impl(selector, action),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def wait(self, selector: str, reason: str = "Wait for element") -> ActionResult:
|
|
216
|
+
return self._execute_action(
|
|
217
|
+
ActionRequest("wait", reason, target=selector, risk_level="safe"),
|
|
218
|
+
lambda action: self._wait_impl(selector, action),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def inspect(self) -> dict[str, Any]:
|
|
222
|
+
snapshot = self.browser.inspect()
|
|
223
|
+
self.session.console_errors = self.browser.read_console()
|
|
224
|
+
self.session.network_errors = self.browser.read_network()
|
|
225
|
+
self.browser_state.current_url = snapshot.get("url")
|
|
226
|
+
self.browser_state.page_title = snapshot.get("title")
|
|
227
|
+
return snapshot
|
|
228
|
+
|
|
229
|
+
def read_text(self, selector: str, reason: str = "Read text from element") -> dict[str, Any]:
|
|
230
|
+
holder: dict[str, Any] = {}
|
|
231
|
+
action = self._execute_action(
|
|
232
|
+
ActionRequest("inspect", reason, target=selector, risk_level="safe"),
|
|
233
|
+
lambda approved: self._read_text_impl(selector, approved, holder),
|
|
234
|
+
)
|
|
235
|
+
return {
|
|
236
|
+
"action": asdict(action),
|
|
237
|
+
"selector": selector,
|
|
238
|
+
"text": holder.get("text"),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
def execute(self, script: str, reason: str = "Execute script in page context") -> dict[str, Any]:
|
|
242
|
+
holder: dict[str, Any] = {}
|
|
243
|
+
action = self._execute_action(
|
|
244
|
+
ActionRequest("execute", reason, payload=script, risk_level="medium"),
|
|
245
|
+
lambda approved: self._execute_script_impl(script, approved, holder),
|
|
246
|
+
)
|
|
247
|
+
return {
|
|
248
|
+
"action": asdict(action),
|
|
249
|
+
"result": holder.get("result"),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
def capture_snapshot(self, label: str = "capture") -> dict[str, Any]:
|
|
253
|
+
screenshot = self.screenshot(label)
|
|
254
|
+
inspection = self.inspect()
|
|
255
|
+
return {
|
|
256
|
+
"action": asdict(screenshot),
|
|
257
|
+
"inspection": inspection,
|
|
258
|
+
"screenshotPath": screenshot.after_screenshot,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
def propose_action(self, request: ActionRequest) -> ActionResult:
|
|
262
|
+
self._enforce_policy(request)
|
|
263
|
+
next_index = (
|
|
264
|
+
len(self.session.pending_actions)
|
|
265
|
+
+ len(self.session.approved_actions)
|
|
266
|
+
+ len(self.session.completed_actions)
|
|
267
|
+
+ len(self.session.rejected_actions)
|
|
268
|
+
+ len(self.session.blocked_actions)
|
|
269
|
+
+ len(self.session.failed_actions)
|
|
270
|
+
+ 1
|
|
271
|
+
)
|
|
272
|
+
action = ActionResult(
|
|
273
|
+
action_id=f"{request.action_type}-{next_index}",
|
|
274
|
+
action_type=request.action_type,
|
|
275
|
+
status="pending",
|
|
276
|
+
reason=request.reason,
|
|
277
|
+
target=request.target,
|
|
278
|
+
payload=request.payload,
|
|
279
|
+
risk_level=request.risk_level,
|
|
280
|
+
)
|
|
281
|
+
if request.risk_level == "blocked":
|
|
282
|
+
action.status = "blocked"
|
|
283
|
+
self.session.blocked_actions.append(action)
|
|
284
|
+
self.session.runtime_status = "blocked"
|
|
285
|
+
else:
|
|
286
|
+
self.session.pending_actions.append(action)
|
|
287
|
+
self.session.runtime_status = "waiting-approval"
|
|
288
|
+
return action
|
|
289
|
+
|
|
290
|
+
def approve_action(self, action_id: str) -> ActionResult:
|
|
291
|
+
action = self._remove_pending(action_id)
|
|
292
|
+
action.status = "approved"
|
|
293
|
+
self.session.approved_actions.append(action)
|
|
294
|
+
self.session.runtime_status = "acting"
|
|
295
|
+
return action
|
|
296
|
+
|
|
297
|
+
def reject_action(self, action_id: str, reason: str) -> ActionResult:
|
|
298
|
+
action = self._remove_pending(action_id)
|
|
299
|
+
action.status = "rejected"
|
|
300
|
+
action.result = reason
|
|
301
|
+
self.session.rejected_actions.append(action)
|
|
302
|
+
self.session.runtime_status = "blocked"
|
|
303
|
+
return action
|
|
304
|
+
|
|
305
|
+
def execute_action(self, action_id: str) -> ActionResult:
|
|
306
|
+
action = self._find_action(action_id)
|
|
307
|
+
if action.status == "pending":
|
|
308
|
+
action = self.approve_action(action_id)
|
|
309
|
+
if action.status != "approved":
|
|
310
|
+
raise AgentPortalError(
|
|
311
|
+
f"Action {action_id} is not approved for execution.",
|
|
312
|
+
module="agent_portal.runtime",
|
|
313
|
+
likely_cause="The action is blocked, rejected, completed, or failed.",
|
|
314
|
+
suggested_fix="Approve a pending action before trying to execute it.",
|
|
315
|
+
can_continue=True,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return self._execute_approved_action(
|
|
319
|
+
action.action_id,
|
|
320
|
+
lambda approved: self._dispatch_approved_action(approved),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def complete_action(
|
|
324
|
+
self,
|
|
325
|
+
action_id: str,
|
|
326
|
+
result: str,
|
|
327
|
+
before_screenshot: str | None = None,
|
|
328
|
+
after_screenshot: str | None = None,
|
|
329
|
+
) -> ActionResult:
|
|
330
|
+
action = next(entry for entry in self.session.approved_actions if entry.action_id == action_id)
|
|
331
|
+
action.status = "completed"
|
|
332
|
+
action.result = result
|
|
333
|
+
action.before_screenshot = before_screenshot
|
|
334
|
+
action.after_screenshot = after_screenshot
|
|
335
|
+
self.session.approved_actions = [
|
|
336
|
+
entry for entry in self.session.approved_actions if entry.action_id != action_id
|
|
337
|
+
]
|
|
338
|
+
self.session.completed_actions.append(action)
|
|
339
|
+
self.session.console_errors = self.browser.read_console()
|
|
340
|
+
self.session.network_errors = self.browser.read_network()
|
|
341
|
+
self.session.runtime_status = "idle"
|
|
342
|
+
return action
|
|
343
|
+
|
|
344
|
+
def fail_action(self, action_id: str, error: AgentPortalError) -> ActionResult:
|
|
345
|
+
action = next(entry for entry in self.session.approved_actions if entry.action_id == action_id)
|
|
346
|
+
action.status = "failed"
|
|
347
|
+
action.error = error.to_dict()
|
|
348
|
+
action.result = str(error)
|
|
349
|
+
self.session.approved_actions = [
|
|
350
|
+
entry for entry in self.session.approved_actions if entry.action_id != action_id
|
|
351
|
+
]
|
|
352
|
+
self.session.failed_actions.append(action)
|
|
353
|
+
self.browser_state.last_error = str(error)
|
|
354
|
+
self.session.runtime_status = "failed"
|
|
355
|
+
return action
|
|
356
|
+
|
|
357
|
+
def generate_report(self) -> Path:
|
|
358
|
+
report_dir = self.base_path / self.config.report_directory
|
|
359
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
report = SessionReport(
|
|
361
|
+
project_name=self.base_path.name,
|
|
362
|
+
runtime_version="0.0.2",
|
|
363
|
+
session_id=self.session.session_id,
|
|
364
|
+
start_time=self.session.started_at,
|
|
365
|
+
end_time=utc_now(),
|
|
366
|
+
browser_used=self.browser_state.browser_name,
|
|
367
|
+
current_url=self.browser_state.current_url,
|
|
368
|
+
goals=[self.session.current_goal] if self.session.current_goal else [],
|
|
369
|
+
actions_proposed=self.session.pending_actions + self.session.completed_actions,
|
|
370
|
+
actions_approved=self.session.approved_actions + self.session.completed_actions,
|
|
371
|
+
actions_rejected=self.session.rejected_actions,
|
|
372
|
+
actions_blocked=self.session.blocked_actions,
|
|
373
|
+
actions_completed=[action for action in self.session.completed_actions if action.status == "completed"],
|
|
374
|
+
console_errors=self.session.console_errors,
|
|
375
|
+
network_errors=self.session.network_errors,
|
|
376
|
+
screenshots=[
|
|
377
|
+
action.after_screenshot
|
|
378
|
+
for action in self.session.completed_actions + self.session.failed_actions
|
|
379
|
+
if action.after_screenshot
|
|
380
|
+
],
|
|
381
|
+
risk_events=[
|
|
382
|
+
f"{action.action_type}:{action.risk_level}"
|
|
383
|
+
for action in self.session.completed_actions + self.session.blocked_actions + self.session.failed_actions
|
|
384
|
+
],
|
|
385
|
+
failed_steps=[action.reason for action in self.session.failed_actions],
|
|
386
|
+
suggested_fixes=[
|
|
387
|
+
"Review blocked or rejected actions before retrying.",
|
|
388
|
+
"Check local development server health if navigation or network actions fail.",
|
|
389
|
+
],
|
|
390
|
+
reproduction_steps=[
|
|
391
|
+
action.action_type for action in self.session.completed_actions
|
|
392
|
+
],
|
|
393
|
+
environment_details={
|
|
394
|
+
"host": self.config.runtime_host,
|
|
395
|
+
"port": str(self.config.runtime_port),
|
|
396
|
+
"browser": self.config.default_browser,
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
report_path = report_dir / f"{self.session.session_id}.json"
|
|
400
|
+
report_path.write_text(json.dumps(report.to_dict(), indent=2), encoding="utf8")
|
|
401
|
+
return report_path
|
|
402
|
+
|
|
403
|
+
def list_plugins(self) -> list[dict[str, Any]]:
|
|
404
|
+
manifests: list[dict[str, Any]] = []
|
|
405
|
+
for plugin_path in discover_plugins(self.base_path):
|
|
406
|
+
manifests.append(
|
|
407
|
+
{
|
|
408
|
+
"path": str(plugin_path),
|
|
409
|
+
"errors": validate_plugin_manifest(plugin_path),
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
return manifests
|
|
413
|
+
|
|
414
|
+
def close_browser(self) -> ActionResult:
|
|
415
|
+
return self._execute_action(
|
|
416
|
+
ActionRequest("browser_close", "Close the browser session", risk_level="medium"),
|
|
417
|
+
lambda action: self._close_browser_impl(action),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def refresh(self) -> ActionResult:
|
|
421
|
+
return self._execute_action(
|
|
422
|
+
ActionRequest("browser_refresh", "Refresh the current page", risk_level="low"),
|
|
423
|
+
lambda action: self._refresh_impl(action),
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def back(self) -> ActionResult:
|
|
427
|
+
return self._execute_action(
|
|
428
|
+
ActionRequest("browser_back", "Navigate backward in browser history", risk_level="low"),
|
|
429
|
+
lambda action: self._back_impl(action),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def forward(self) -> ActionResult:
|
|
433
|
+
return self._execute_action(
|
|
434
|
+
ActionRequest("browser_forward", "Navigate forward in browser history", risk_level="low"),
|
|
435
|
+
lambda action: self._forward_impl(action),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def browser_status(self) -> dict[str, Any]:
|
|
439
|
+
return asdict(self.browser_state)
|
|
440
|
+
|
|
441
|
+
def read_dom(self) -> dict[str, Any]:
|
|
442
|
+
return {
|
|
443
|
+
"url": self.browser.current_url(),
|
|
444
|
+
"dom": self.browser.read_dom(),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def read_accessibility_tree(self) -> dict[str, Any]:
|
|
448
|
+
return {
|
|
449
|
+
"url": self.browser.current_url(),
|
|
450
|
+
"accessibilityTree": self.browser.read_accessibility_tree(),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
def read_console_errors(self) -> dict[str, Any]:
|
|
454
|
+
return {
|
|
455
|
+
"consoleErrors": self.browser.read_console(),
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
def read_network_failures(self) -> dict[str, Any]:
|
|
459
|
+
return {
|
|
460
|
+
"networkFailures": self.browser.read_network(),
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
def inspect_element(self, selector: str) -> dict[str, Any]:
|
|
464
|
+
return self.browser.inspect_element(selector)
|
|
465
|
+
|
|
466
|
+
def list_reports(self) -> list[dict[str, str]]:
|
|
467
|
+
report_dir = self.base_path / self.config.report_directory
|
|
468
|
+
if not report_dir.exists():
|
|
469
|
+
return []
|
|
470
|
+
return [
|
|
471
|
+
{"name": report.name, "path": str(report)}
|
|
472
|
+
for report in sorted(report_dir.glob("*.json"), reverse=True)
|
|
473
|
+
]
|
|
474
|
+
|
|
475
|
+
def read_report(self, report_name: str) -> dict[str, Any]:
|
|
476
|
+
report_path = self._resolve_report_path(report_name)
|
|
477
|
+
return json.loads(report_path.read_text(encoding="utf8"))
|
|
478
|
+
|
|
479
|
+
def export_report(self, report_name: str, destination: str | None = None) -> dict[str, str]:
|
|
480
|
+
report_path = self._resolve_report_path(report_name)
|
|
481
|
+
export_dir = self.base_path / self.config.report_directory / "exports"
|
|
482
|
+
export_dir.mkdir(parents=True, exist_ok=True)
|
|
483
|
+
destination_path = Path(destination) if destination else export_dir / report_path.name
|
|
484
|
+
destination_path.write_text(report_path.read_text(encoding="utf8"), encoding="utf8")
|
|
485
|
+
return {"exportPath": str(destination_path)}
|
|
486
|
+
|
|
487
|
+
def _enforce_policy(self, request: ActionRequest) -> None:
|
|
488
|
+
haystack = f"{request.reason} {request.target or ''} {request.payload or ''}".lower()
|
|
489
|
+
if self.risk_policy.read_only and request.action_type not in {"inspect", "screenshot", "wait"}:
|
|
490
|
+
request.risk_level = "blocked"
|
|
491
|
+
elif any(token in haystack for token in ["payment", "billing", "checkout", "card"]):
|
|
492
|
+
request.risk_level = "blocked"
|
|
493
|
+
elif any(token in haystack for token in ["delete", "drop database", "destroy"]):
|
|
494
|
+
request.risk_level = "high"
|
|
495
|
+
elif any(token in haystack for token in ["submit", "send", "publish", "login"]):
|
|
496
|
+
request.risk_level = max_risk(request.risk_level, "medium")
|
|
497
|
+
|
|
498
|
+
if request.target and "password" in request.target.lower():
|
|
499
|
+
request.risk_level = "blocked"
|
|
500
|
+
|
|
501
|
+
if self.risk_policy.domain_lock and request.action_type == "open_url" and request.target:
|
|
502
|
+
if self.risk_policy.domain_lock not in request.target:
|
|
503
|
+
request.risk_level = "blocked"
|
|
504
|
+
|
|
505
|
+
def _remove_pending(self, action_id: str) -> ActionResult:
|
|
506
|
+
for index, action in enumerate(self.session.pending_actions):
|
|
507
|
+
if action.action_id == action_id:
|
|
508
|
+
return self.session.pending_actions.pop(index)
|
|
509
|
+
raise AgentPortalError(
|
|
510
|
+
f"Pending action {action_id} was not found.",
|
|
511
|
+
module="agent_portal.runtime",
|
|
512
|
+
likely_cause="The action was already handled or the ID is incorrect.",
|
|
513
|
+
suggested_fix="Refresh the action queue and retry the operation.",
|
|
514
|
+
can_continue=True,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def _capture_if_allowed(self, label: str) -> str | None:
|
|
518
|
+
if not self.config.sensitive_screenshots_enabled and self.config.safe_mode:
|
|
519
|
+
return None
|
|
520
|
+
return self.browser.screenshot(label)
|
|
521
|
+
|
|
522
|
+
def _find_action(self, action_id: str) -> ActionResult:
|
|
523
|
+
for action in self._all_actions():
|
|
524
|
+
if action.action_id == action_id:
|
|
525
|
+
return action
|
|
526
|
+
raise AgentPortalError(
|
|
527
|
+
f"Action {action_id} was not found.",
|
|
528
|
+
module="agent_portal.runtime",
|
|
529
|
+
likely_cause="The action id is incorrect or the queue has already been rotated.",
|
|
530
|
+
suggested_fix="Refresh the action queue and use a current action id.",
|
|
531
|
+
can_continue=True,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def _all_actions(self) -> list[ActionResult]:
|
|
535
|
+
return (
|
|
536
|
+
self.session.pending_actions
|
|
537
|
+
+ self.session.approved_actions
|
|
538
|
+
+ self.session.completed_actions
|
|
539
|
+
+ self.session.rejected_actions
|
|
540
|
+
+ self.session.blocked_actions
|
|
541
|
+
+ self.session.failed_actions
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def _execute_action(
|
|
545
|
+
self,
|
|
546
|
+
request: ActionRequest,
|
|
547
|
+
callback: Callable[[ActionResult], ActionResult],
|
|
548
|
+
) -> ActionResult:
|
|
549
|
+
action = self.propose_action(request)
|
|
550
|
+
if action.status == "blocked":
|
|
551
|
+
raise PolicyBlockedError(
|
|
552
|
+
"Action was blocked by the runtime policy engine.",
|
|
553
|
+
module="agent_portal.runtime",
|
|
554
|
+
likely_cause="The action exceeded the current safety policy or approval boundary.",
|
|
555
|
+
suggested_fix="Review the queue, adjust steering mode, or manually override the action if appropriate.",
|
|
556
|
+
can_continue=True,
|
|
557
|
+
)
|
|
558
|
+
return self._execute_approved_action(action.action_id, callback)
|
|
559
|
+
|
|
560
|
+
def _execute_approved_action(
|
|
561
|
+
self,
|
|
562
|
+
action_id: str,
|
|
563
|
+
callback: Callable[[ActionResult], ActionResult],
|
|
564
|
+
) -> ActionResult:
|
|
565
|
+
approved = self.approve_action(action_id)
|
|
566
|
+
try:
|
|
567
|
+
result = callback(approved)
|
|
568
|
+
self.browser_state.current_url = self.browser.current_url()
|
|
569
|
+
self.browser_state.page_title = self.browser.current_title()
|
|
570
|
+
return result
|
|
571
|
+
except AgentPortalError as exc:
|
|
572
|
+
self.fail_action(action_id, exc)
|
|
573
|
+
raise
|
|
574
|
+
except Exception as exc:
|
|
575
|
+
wrapped = AgentPortalError(
|
|
576
|
+
"Browser action failed unexpectedly.",
|
|
577
|
+
module="agent_portal.runtime",
|
|
578
|
+
likely_cause=str(exc),
|
|
579
|
+
suggested_fix="Inspect the runtime logs and browser state, then retry the action.",
|
|
580
|
+
can_continue=True,
|
|
581
|
+
)
|
|
582
|
+
self.fail_action(action_id, wrapped)
|
|
583
|
+
raise wrapped from exc
|
|
584
|
+
|
|
585
|
+
def _open_url_impl(self, url: str, action: ActionResult) -> ActionResult:
|
|
586
|
+
before = self._capture_if_allowed(f"{action.action_id}-before")
|
|
587
|
+
self.browser.open_url(url)
|
|
588
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
589
|
+
return self.complete_action(action.action_id, "Opened URL", before, after)
|
|
590
|
+
|
|
591
|
+
def _click_impl(self, selector: str, action: ActionResult) -> ActionResult:
|
|
592
|
+
before = self._capture_if_allowed(f"{action.action_id}-before")
|
|
593
|
+
self.browser.click(selector)
|
|
594
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
595
|
+
return self.complete_action(action.action_id, "Clicked element", before, after)
|
|
596
|
+
|
|
597
|
+
def _type_impl(self, selector: str, value: str, action: ActionResult) -> ActionResult:
|
|
598
|
+
before = self._capture_if_allowed(f"{action.action_id}-before")
|
|
599
|
+
self.browser.type_text(selector, value)
|
|
600
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
601
|
+
return self.complete_action(action.action_id, "Typed into element", before, after)
|
|
602
|
+
|
|
603
|
+
def _scroll_impl(self, selector: str | None, action: ActionResult) -> ActionResult:
|
|
604
|
+
before = self._capture_if_allowed(f"{action.action_id}-before")
|
|
605
|
+
self.browser.scroll(selector)
|
|
606
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
607
|
+
return self.complete_action(action.action_id, "Scrolled", before, after)
|
|
608
|
+
|
|
609
|
+
def _hover_impl(self, selector: str, action: ActionResult) -> ActionResult:
|
|
610
|
+
before = self._capture_if_allowed(f"{action.action_id}-before")
|
|
611
|
+
self.browser.hover(selector)
|
|
612
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
613
|
+
return self.complete_action(action.action_id, "Hovered element", before, after)
|
|
614
|
+
|
|
615
|
+
def _wait_impl(self, selector: str, action: ActionResult) -> ActionResult:
|
|
616
|
+
self.browser.wait(selector)
|
|
617
|
+
return self.complete_action(action.action_id, "Waited for element")
|
|
618
|
+
|
|
619
|
+
def _read_text_impl(
|
|
620
|
+
self,
|
|
621
|
+
selector: str,
|
|
622
|
+
action: ActionResult,
|
|
623
|
+
holder: dict[str, Any],
|
|
624
|
+
) -> ActionResult:
|
|
625
|
+
holder["text"] = self.browser.read_text(selector)
|
|
626
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
627
|
+
return self.complete_action(action.action_id, "Read text from element", after_screenshot=after)
|
|
628
|
+
|
|
629
|
+
def _execute_script_impl(
|
|
630
|
+
self,
|
|
631
|
+
script: str,
|
|
632
|
+
action: ActionResult,
|
|
633
|
+
holder: dict[str, Any],
|
|
634
|
+
) -> ActionResult:
|
|
635
|
+
holder["result"] = self.browser.execute(script)
|
|
636
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
637
|
+
return self.complete_action(action.action_id, "Executed browser script", after_screenshot=after)
|
|
638
|
+
|
|
639
|
+
def _close_browser_impl(self, action: ActionResult) -> ActionResult:
|
|
640
|
+
self.browser.close_page()
|
|
641
|
+
self.browser_state.connected = False
|
|
642
|
+
self.browser_state.current_url = None
|
|
643
|
+
self.browser_state.page_title = None
|
|
644
|
+
return self.complete_action(action.action_id, "Closed browser session")
|
|
645
|
+
|
|
646
|
+
def _refresh_impl(self, action: ActionResult) -> ActionResult:
|
|
647
|
+
before = self._capture_if_allowed(f"{action.action_id}-before")
|
|
648
|
+
self.browser.refresh()
|
|
649
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
650
|
+
return self.complete_action(action.action_id, "Refreshed page", before, after)
|
|
651
|
+
|
|
652
|
+
def _back_impl(self, action: ActionResult) -> ActionResult:
|
|
653
|
+
before = self._capture_if_allowed(f"{action.action_id}-before")
|
|
654
|
+
self.browser.back()
|
|
655
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
656
|
+
return self.complete_action(action.action_id, "Navigated backward", before, after)
|
|
657
|
+
|
|
658
|
+
def _forward_impl(self, action: ActionResult) -> ActionResult:
|
|
659
|
+
before = self._capture_if_allowed(f"{action.action_id}-before")
|
|
660
|
+
self.browser.forward()
|
|
661
|
+
after = self._capture_if_allowed(f"{action.action_id}-after")
|
|
662
|
+
return self.complete_action(action.action_id, "Navigated forward", before, after)
|
|
663
|
+
|
|
664
|
+
def _dispatch_approved_action(self, action: ActionResult) -> ActionResult:
|
|
665
|
+
if action.action_type == "open_url":
|
|
666
|
+
return self._open_url_impl(action.target or "", action)
|
|
667
|
+
if action.action_type == "click":
|
|
668
|
+
return self._click_impl(action.target or "", action)
|
|
669
|
+
if action.action_type == "type":
|
|
670
|
+
return self._type_impl(action.target or "", action.payload or "", action)
|
|
671
|
+
if action.action_type == "scroll":
|
|
672
|
+
return self._scroll_impl(action.target, action)
|
|
673
|
+
if action.action_type == "hover":
|
|
674
|
+
return self._hover_impl(action.target or "", action)
|
|
675
|
+
if action.action_type == "wait":
|
|
676
|
+
return self._wait_impl(action.target or "", action)
|
|
677
|
+
if action.action_type == "screenshot":
|
|
678
|
+
return self.complete_action(
|
|
679
|
+
action.action_id,
|
|
680
|
+
"Captured screenshot",
|
|
681
|
+
after_screenshot=self.browser.screenshot(action.payload or "manual"),
|
|
682
|
+
)
|
|
683
|
+
if action.action_type == "browser_refresh":
|
|
684
|
+
return self._refresh_impl(action)
|
|
685
|
+
if action.action_type == "browser_back":
|
|
686
|
+
return self._back_impl(action)
|
|
687
|
+
if action.action_type == "browser_forward":
|
|
688
|
+
return self._forward_impl(action)
|
|
689
|
+
if action.action_type == "browser_close":
|
|
690
|
+
return self._close_browser_impl(action)
|
|
691
|
+
raise AgentPortalError(
|
|
692
|
+
f"Unsupported queued action type: {action.action_type}",
|
|
693
|
+
module="agent_portal.runtime",
|
|
694
|
+
likely_cause="The queued action type is not yet wired to a runtime implementation.",
|
|
695
|
+
suggested_fix="Use a supported browser action type or extend the runtime dispatcher.",
|
|
696
|
+
can_continue=True,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
def _resolve_report_path(self, report_name: str) -> Path:
|
|
700
|
+
candidate = Path(report_name)
|
|
701
|
+
if candidate.is_absolute() and candidate.exists():
|
|
702
|
+
return candidate
|
|
703
|
+
report_path = self.base_path / self.config.report_directory / report_name
|
|
704
|
+
if report_path.exists():
|
|
705
|
+
return report_path
|
|
706
|
+
raise AgentPortalError(
|
|
707
|
+
f"Report {report_name} was not found.",
|
|
708
|
+
module="agent_portal.runtime",
|
|
709
|
+
likely_cause="The report name is stale or the file has been removed.",
|
|
710
|
+
suggested_fix="List available reports and retry with a current report name.",
|
|
711
|
+
can_continue=True,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def _ensure_single_instance(self) -> None:
|
|
715
|
+
if self._lock_path.exists():
|
|
716
|
+
try:
|
|
717
|
+
stale_pid = int(self._lock_path.read_text(encoding="utf8").strip())
|
|
718
|
+
os.kill(stale_pid, 0)
|
|
719
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
720
|
+
self._remove_lock()
|
|
721
|
+
else:
|
|
722
|
+
raise OSError("Runtime lock file already exists")
|
|
723
|
+
self._lock_path.write_text(str(os.getpid()), encoding="utf8")
|
|
724
|
+
|
|
725
|
+
def _ensure_port_available(self) -> None:
|
|
726
|
+
sock = socket.socket()
|
|
727
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
728
|
+
try:
|
|
729
|
+
sock.bind((self.config.runtime_host, self.config.runtime_port))
|
|
730
|
+
finally:
|
|
731
|
+
sock.close()
|
|
732
|
+
|
|
733
|
+
def _remove_lock(self) -> None:
|
|
734
|
+
if self._lock_path.exists():
|
|
735
|
+
self._lock_path.unlink()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def max_risk(left: str, right: str) -> str:
|
|
739
|
+
return left if RISK_ORDER.index(left) >= RISK_ORDER.index(right) else right
|