screenforge 0.4.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.
- cli/__init__.py +0 -0
- cli/_version.py +1 -0
- cli/dispatch.py +266 -0
- cli/doctor.py +487 -0
- cli/modes/__init__.py +0 -0
- cli/modes/action.py +262 -0
- cli/modes/default.py +248 -0
- cli/modes/demo.py +162 -0
- cli/modes/dry_run.py +237 -0
- cli/modes/init.py +133 -0
- cli/modes/plan.py +148 -0
- cli/modes/workflow.py +354 -0
- cli/parser.py +305 -0
- cli/reporter.py +207 -0
- cli/session.py +146 -0
- cli/shared.py +427 -0
- cli/shorthand.py +90 -0
- cli/tool_protocol_handlers.py +446 -0
- common/__init__.py +0 -0
- common/adapters/__init__.py +21 -0
- common/adapters/android_adapter.py +273 -0
- common/adapters/base_adapter.py +24 -0
- common/adapters/ios_adapter.py +278 -0
- common/adapters/web_adapter.py +271 -0
- common/ai.py +277 -0
- common/ai_autonomous.py +273 -0
- common/ai_heal.py +222 -0
- common/cache/__init__.py +15 -0
- common/cache/cache_hash.py +57 -0
- common/cache/cache_manager.py +300 -0
- common/cache/cache_stats.py +133 -0
- common/cache/cache_storage.py +79 -0
- common/cache/embedding_loader.py +150 -0
- common/capabilities.py +121 -0
- common/case_memory.py +327 -0
- common/error_codes.py +61 -0
- common/exceptions.py +18 -0
- common/executor.py +1504 -0
- common/failure_diagnosis.py +138 -0
- common/history_manager.py +75 -0
- common/logs.py +168 -0
- common/mcp_server.py +467 -0
- common/preflight.py +496 -0
- common/progress.py +37 -0
- common/run_reporter.py +415 -0
- common/run_resume.py +149 -0
- common/runtime_modes.py +35 -0
- common/tool_protocol.py +196 -0
- common/visual_fallback.py +71 -0
- common/workflow_schema.py +150 -0
- config/__init__.py +0 -0
- config/config.py +167 -0
- config/env_loader.py +76 -0
- screenforge-0.4.0.dist-info/METADATA +43 -0
- screenforge-0.4.0.dist-info/RECORD +64 -0
- screenforge-0.4.0.dist-info/WHEEL +5 -0
- screenforge-0.4.0.dist-info/entry_points.txt +2 -0
- screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
- screenforge-0.4.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +0 -0
- utils/screenshot_annotator.py +60 -0
- utils/utils_ios.py +195 -0
- utils/utils_web.py +304 -0
- utils/utils_xml.py +218 -0
common/capabilities.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from common.runtime_modes import MODE_DOCTOR, MODE_DRY_RUN, MODE_PLAN_ONLY, MODE_RUN
|
|
2
|
+
|
|
3
|
+
SUPPORTED_PLATFORMS = ("android", "ios", "web")
|
|
4
|
+
SUPPORTED_ACTIONS = (
|
|
5
|
+
"goto",
|
|
6
|
+
"click",
|
|
7
|
+
"long_click",
|
|
8
|
+
"hover",
|
|
9
|
+
"input",
|
|
10
|
+
"swipe",
|
|
11
|
+
"press",
|
|
12
|
+
"scroll_into_view",
|
|
13
|
+
"select",
|
|
14
|
+
"upload",
|
|
15
|
+
"double_click",
|
|
16
|
+
"right_click",
|
|
17
|
+
"drag",
|
|
18
|
+
"wait_for",
|
|
19
|
+
"assert_exist",
|
|
20
|
+
"assert_not_exist",
|
|
21
|
+
"assert_text_equals",
|
|
22
|
+
"assert_text_contains",
|
|
23
|
+
"assert_value",
|
|
24
|
+
"assert_url",
|
|
25
|
+
)
|
|
26
|
+
# Assertions produce a verification VERDICT (the system-under-test did/did not
|
|
27
|
+
# meet the condition) rather than an engine error — execute_and_record tags
|
|
28
|
+
# their failures with assertion_failed so callers/--json can disambiguate.
|
|
29
|
+
ASSERTION_ACTIONS = {
|
|
30
|
+
"assert_exist",
|
|
31
|
+
"assert_not_exist",
|
|
32
|
+
"assert_text_equals",
|
|
33
|
+
"assert_text_contains",
|
|
34
|
+
"assert_value",
|
|
35
|
+
"assert_url",
|
|
36
|
+
}
|
|
37
|
+
# assert_url reads page.url, not an element — it needs no locator (web-global).
|
|
38
|
+
GLOBAL_ACTIONS = {"goto", "swipe", "press", "assert_url"}
|
|
39
|
+
# Actions with a clean Playwright API but no robust coordinate-free mobile
|
|
40
|
+
# equivalent. Engaged only on web; on android/ios the handler fails honestly
|
|
41
|
+
# rather than emitting a brittle coordinate-based step (see P2 coordinate
|
|
42
|
+
# honesty). assert_url is web-only for a different reason (reads page.url).
|
|
43
|
+
WEB_ONLY_ACTIONS = {
|
|
44
|
+
"goto",
|
|
45
|
+
"scroll_into_view",
|
|
46
|
+
"select",
|
|
47
|
+
"upload",
|
|
48
|
+
"double_click",
|
|
49
|
+
"right_click",
|
|
50
|
+
"drag",
|
|
51
|
+
"assert_url",
|
|
52
|
+
}
|
|
53
|
+
ACTIONS_REQUIRING_EXTRA_VALUE = {
|
|
54
|
+
"goto",
|
|
55
|
+
"input",
|
|
56
|
+
"select",
|
|
57
|
+
"upload",
|
|
58
|
+
"drag",
|
|
59
|
+
"assert_text_equals",
|
|
60
|
+
"assert_text_contains",
|
|
61
|
+
"assert_value",
|
|
62
|
+
"assert_url",
|
|
63
|
+
}
|
|
64
|
+
CONTROL_PLANES = ("goal", "workflow", "action", "doctor")
|
|
65
|
+
EXECUTION_MODES = (MODE_RUN, MODE_DOCTOR, MODE_PLAN_ONLY, MODE_DRY_RUN)
|
|
66
|
+
|
|
67
|
+
# Which locator_type values actually resolve on each platform. This mirrors the
|
|
68
|
+
# real executor / UI-compressor behavior, NOT aspiration:
|
|
69
|
+
# - web: compress_web_dom emits ref/bbox; LocatorBuilder maps css/text/desc.
|
|
70
|
+
# - android: utils_xml emits resource-id/text/content-desc (no ref/bbox).
|
|
71
|
+
# - ios: utils_ios maps text/desc -> label/name (no ref/bbox); resourceId->name.
|
|
72
|
+
# An agent should read this instead of assuming `ref` works everywhere.
|
|
73
|
+
LOCATORS_BY_PLATFORM = {
|
|
74
|
+
"web": ["css", "ref", "text", "description"],
|
|
75
|
+
"android": ["resourceId", "text", "description"],
|
|
76
|
+
"ios": ["text", "description"],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Platform-gated location features. ref/bbox and the VLM visual fallback are
|
|
80
|
+
# web-only (mobile UI-tree compressors don't emit ref/bbox, and the visual
|
|
81
|
+
# fallback in executor.py is gated on platform == "web").
|
|
82
|
+
FEATURES_BY_PLATFORM = {
|
|
83
|
+
"ref_bbox": ["web"],
|
|
84
|
+
"screenshot_annotation": ["web"],
|
|
85
|
+
"visual_fallback": ["web"],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_capabilities_payload() -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"platforms": list(SUPPORTED_PLATFORMS),
|
|
92
|
+
"execution_modes": list(EXECUTION_MODES),
|
|
93
|
+
"control_planes": list(CONTROL_PLANES),
|
|
94
|
+
"supported_actions": list(SUPPORTED_ACTIONS),
|
|
95
|
+
"global_actions": sorted(GLOBAL_ACTIONS),
|
|
96
|
+
"web_only_actions": sorted(WEB_ONLY_ACTIONS),
|
|
97
|
+
"actions_requiring_extra_value": sorted(ACTIONS_REQUIRING_EXTRA_VALUE),
|
|
98
|
+
"locators": {p: list(v) for p, v in LOCATORS_BY_PLATFORM.items()},
|
|
99
|
+
"locator_priority": ["css", "resourceId", "text", "description"],
|
|
100
|
+
"features": {f: list(v) for f, v in FEATURES_BY_PLATFORM.items()},
|
|
101
|
+
"supports": {
|
|
102
|
+
"doctor": True,
|
|
103
|
+
"resume": True,
|
|
104
|
+
"workflow": True,
|
|
105
|
+
"workflow_vars": True,
|
|
106
|
+
"action": True,
|
|
107
|
+
"inspect_ui": True,
|
|
108
|
+
"case_memory": True,
|
|
109
|
+
"run_assets": True,
|
|
110
|
+
"load_run": True,
|
|
111
|
+
"tool_request": True,
|
|
112
|
+
"tool_stdin": True,
|
|
113
|
+
"mcp_server": True,
|
|
114
|
+
"json_events": True,
|
|
115
|
+
"goal_cli_human_mode_only": True,
|
|
116
|
+
},
|
|
117
|
+
"docs": {
|
|
118
|
+
"capability_matrix": "docs/capability-matrix.md",
|
|
119
|
+
"agent_guide": "docs/agent_guide.md",
|
|
120
|
+
},
|
|
121
|
+
}
|
common/case_memory.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
import config.config as config
|
|
11
|
+
from common.logs import log
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _now_iso() -> str:
|
|
15
|
+
return datetime.now().isoformat(timespec="seconds")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _normalize_text(value: str) -> str:
|
|
19
|
+
return str(value or "").strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _slugify(value: str) -> str:
|
|
23
|
+
text = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "-", _normalize_text(value)).strip("-")
|
|
24
|
+
return text.lower() or "memory"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_memory_id(
|
|
28
|
+
platform: str,
|
|
29
|
+
control_kind: str,
|
|
30
|
+
control_label: str,
|
|
31
|
+
source_ref: str,
|
|
32
|
+
) -> str:
|
|
33
|
+
identity_seed = f"{platform}|{control_kind}|{control_label}|{source_ref}"
|
|
34
|
+
digest = hashlib.sha1(identity_seed.encode("utf-8")).hexdigest()[:10]
|
|
35
|
+
human_label = _slugify(source_ref or control_label)[:48]
|
|
36
|
+
return f"{platform}:{control_kind}:{human_label}:{digest}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _merge_unique_strings(existing: list[str], incoming: list[str]) -> list[str]:
|
|
40
|
+
merged = []
|
|
41
|
+
seen = set()
|
|
42
|
+
for item in [*existing, *incoming]:
|
|
43
|
+
normalized = _normalize_text(item)
|
|
44
|
+
if not normalized or normalized in seen:
|
|
45
|
+
continue
|
|
46
|
+
merged.append(normalized)
|
|
47
|
+
seen.add(normalized)
|
|
48
|
+
return merged
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _merge_locator_hints(
|
|
52
|
+
existing: list["LocatorHint"],
|
|
53
|
+
incoming: list["LocatorHint"],
|
|
54
|
+
) -> list["LocatorHint"]:
|
|
55
|
+
merged: list["LocatorHint"] = []
|
|
56
|
+
seen = set()
|
|
57
|
+
for item in [*existing, *incoming]:
|
|
58
|
+
key = (
|
|
59
|
+
_normalize_text(item.action),
|
|
60
|
+
_normalize_text(item.locator_type),
|
|
61
|
+
_normalize_text(item.locator_value),
|
|
62
|
+
)
|
|
63
|
+
if not all(key) or key in seen:
|
|
64
|
+
continue
|
|
65
|
+
merged.append(
|
|
66
|
+
LocatorHint(
|
|
67
|
+
action=key[0],
|
|
68
|
+
locator_type=key[1],
|
|
69
|
+
locator_value=key[2],
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
seen.add(key)
|
|
73
|
+
return merged
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LocatorHint(BaseModel):
|
|
77
|
+
action: str = ""
|
|
78
|
+
locator_type: str = ""
|
|
79
|
+
locator_value: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CaseMemoryEntry(BaseModel):
|
|
83
|
+
memory_id: str
|
|
84
|
+
platform: str
|
|
85
|
+
control_kind: str
|
|
86
|
+
control_label: str
|
|
87
|
+
source_ref: str = ""
|
|
88
|
+
success_count: int = 0
|
|
89
|
+
failure_count: int = 0
|
|
90
|
+
last_status: str = ""
|
|
91
|
+
last_run_id: str = ""
|
|
92
|
+
last_used_at: str = ""
|
|
93
|
+
successful_actions: list[str] = Field(default_factory=list)
|
|
94
|
+
locator_hints: list[LocatorHint] = Field(default_factory=list)
|
|
95
|
+
pytest_asset: dict[str, Any] = Field(default_factory=dict)
|
|
96
|
+
recommended_next_step: dict[str, Any] | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CaseMemoryDocument(BaseModel):
|
|
100
|
+
version: int = 1
|
|
101
|
+
updated_at: str = ""
|
|
102
|
+
entries: list[CaseMemoryEntry] = Field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _collect_successful_actions(step_records: list[dict[str, Any]]) -> list[str]:
|
|
106
|
+
return [
|
|
107
|
+
_normalize_text(item.get("action_description", ""))
|
|
108
|
+
for item in step_records
|
|
109
|
+
if item.get("event") == "action_executed"
|
|
110
|
+
and item.get("success") is True
|
|
111
|
+
and _normalize_text(item.get("action_description", ""))
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _collect_locator_hints(
|
|
116
|
+
summary: dict[str, Any],
|
|
117
|
+
step_records: list[dict[str, Any]],
|
|
118
|
+
) -> list[LocatorHint]:
|
|
119
|
+
hints: list[LocatorHint] = []
|
|
120
|
+
control_summary = summary.get("control_summary", {}) or {}
|
|
121
|
+
tuples_seen = set()
|
|
122
|
+
|
|
123
|
+
def _append_hint(action: str, locator_type: str, locator_value: str) -> None:
|
|
124
|
+
normalized_action = _normalize_text(action)
|
|
125
|
+
normalized_type = _normalize_text(locator_type)
|
|
126
|
+
normalized_value = _normalize_text(locator_value)
|
|
127
|
+
if (
|
|
128
|
+
not normalized_action
|
|
129
|
+
or not normalized_type
|
|
130
|
+
or normalized_type.lower() == "global"
|
|
131
|
+
or not normalized_value
|
|
132
|
+
or normalized_value.lower() == "global"
|
|
133
|
+
):
|
|
134
|
+
return
|
|
135
|
+
key = (normalized_action, normalized_type, normalized_value)
|
|
136
|
+
if key in tuples_seen:
|
|
137
|
+
return
|
|
138
|
+
tuples_seen.add(key)
|
|
139
|
+
hints.append(
|
|
140
|
+
LocatorHint(
|
|
141
|
+
action=normalized_action,
|
|
142
|
+
locator_type=normalized_type,
|
|
143
|
+
locator_value=normalized_value,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
_append_hint(
|
|
148
|
+
control_summary.get("action", ""),
|
|
149
|
+
control_summary.get("locator_type", ""),
|
|
150
|
+
control_summary.get("locator_value", ""),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
for item in step_records:
|
|
154
|
+
_append_hint(
|
|
155
|
+
item.get("action", ""),
|
|
156
|
+
item.get("locator_type", ""),
|
|
157
|
+
item.get("locator_value", ""),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return hints
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class CaseMemoryStore:
|
|
164
|
+
def __init__(self, file_path: str | Path | None = None):
|
|
165
|
+
self._file_path = Path(file_path or config.CASE_MEMORY_PATH).expanduser()
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def file_path(self) -> Path:
|
|
169
|
+
return self._file_path
|
|
170
|
+
|
|
171
|
+
def load_document(self) -> CaseMemoryDocument:
|
|
172
|
+
if not self._file_path.exists():
|
|
173
|
+
return CaseMemoryDocument(updated_at=_now_iso())
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
payload = json.loads(self._file_path.read_text(encoding="utf-8"))
|
|
177
|
+
return CaseMemoryDocument.model_validate(payload)
|
|
178
|
+
except Exception as e:
|
|
179
|
+
log.warning(f"[Warning] Failed to read case memory, falling back to empty store: {e}")
|
|
180
|
+
return CaseMemoryDocument(updated_at=_now_iso())
|
|
181
|
+
|
|
182
|
+
def save_document(self, document: CaseMemoryDocument) -> None:
|
|
183
|
+
self._file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
tmp_path = self._file_path.with_suffix(self._file_path.suffix + ".tmp")
|
|
185
|
+
tmp_path.write_text(
|
|
186
|
+
json.dumps(document.model_dump(), ensure_ascii=False, indent=2),
|
|
187
|
+
encoding="utf-8",
|
|
188
|
+
)
|
|
189
|
+
tmp_path.replace(self._file_path)
|
|
190
|
+
|
|
191
|
+
def query_entries(
|
|
192
|
+
self,
|
|
193
|
+
platform: str = "",
|
|
194
|
+
control_kind: str = "",
|
|
195
|
+
query: str = "",
|
|
196
|
+
source_ref: str = "",
|
|
197
|
+
limit: int = 20,
|
|
198
|
+
) -> list[dict[str, Any]]:
|
|
199
|
+
document = self.load_document()
|
|
200
|
+
normalized_platform = _normalize_text(platform).lower()
|
|
201
|
+
normalized_kind = _normalize_text(control_kind).lower()
|
|
202
|
+
normalized_query = _normalize_text(query).lower()
|
|
203
|
+
normalized_source_ref = _normalize_text(source_ref)
|
|
204
|
+
limit = max(1, int(limit or 20))
|
|
205
|
+
|
|
206
|
+
matched_entries = []
|
|
207
|
+
for entry in document.entries:
|
|
208
|
+
if normalized_platform and entry.platform.lower() != normalized_platform:
|
|
209
|
+
continue
|
|
210
|
+
if normalized_kind and entry.control_kind.lower() != normalized_kind:
|
|
211
|
+
continue
|
|
212
|
+
if normalized_source_ref and entry.source_ref != normalized_source_ref:
|
|
213
|
+
continue
|
|
214
|
+
if normalized_query:
|
|
215
|
+
haystacks = [
|
|
216
|
+
entry.control_label.lower(),
|
|
217
|
+
entry.source_ref.lower(),
|
|
218
|
+
" ".join(entry.successful_actions).lower(),
|
|
219
|
+
]
|
|
220
|
+
if not any(normalized_query in haystack for haystack in haystacks):
|
|
221
|
+
continue
|
|
222
|
+
matched_entries.append(entry.model_dump())
|
|
223
|
+
|
|
224
|
+
matched_entries.sort(
|
|
225
|
+
key=lambda item: (
|
|
226
|
+
item.get("last_used_at", ""),
|
|
227
|
+
item.get("success_count", 0),
|
|
228
|
+
),
|
|
229
|
+
reverse=True,
|
|
230
|
+
)
|
|
231
|
+
return matched_entries[:limit]
|
|
232
|
+
|
|
233
|
+
def find_entry(
|
|
234
|
+
self,
|
|
235
|
+
platform: str,
|
|
236
|
+
control_kind: str,
|
|
237
|
+
control_label: str,
|
|
238
|
+
source_ref: str = "",
|
|
239
|
+
) -> dict[str, Any] | None:
|
|
240
|
+
normalized_platform = _normalize_text(platform).lower()
|
|
241
|
+
normalized_kind = _normalize_text(control_kind).lower()
|
|
242
|
+
normalized_label = _normalize_text(control_label)
|
|
243
|
+
normalized_source_ref = _normalize_text(source_ref)
|
|
244
|
+
document = self.load_document()
|
|
245
|
+
|
|
246
|
+
for entry in document.entries:
|
|
247
|
+
if entry.platform.lower() != normalized_platform:
|
|
248
|
+
continue
|
|
249
|
+
if entry.control_kind.lower() != normalized_kind:
|
|
250
|
+
continue
|
|
251
|
+
if normalized_source_ref and entry.source_ref == normalized_source_ref:
|
|
252
|
+
return entry.model_dump()
|
|
253
|
+
if normalized_label and entry.control_label == normalized_label:
|
|
254
|
+
return entry.model_dump()
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def upsert_from_run(
|
|
258
|
+
self,
|
|
259
|
+
summary: dict[str, Any],
|
|
260
|
+
step_records: list[dict[str, Any]],
|
|
261
|
+
) -> dict[str, Any] | None:
|
|
262
|
+
if _normalize_text(summary.get("execution_mode", "")) != "run":
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
control_summary = summary.get("control_summary", {}) or {}
|
|
266
|
+
control_kind = _normalize_text(control_summary.get("control_kind", ""))
|
|
267
|
+
if not control_kind or control_kind == "doctor":
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
platform = _normalize_text(summary.get("platform", ""))
|
|
271
|
+
control_label = _normalize_text(control_summary.get("control_label", "")) or _normalize_text(
|
|
272
|
+
summary.get("goal", "")
|
|
273
|
+
)
|
|
274
|
+
source_ref = _normalize_text(control_summary.get("source_ref", ""))
|
|
275
|
+
if not platform or not control_label:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
document = self.load_document()
|
|
279
|
+
existing_entry = None
|
|
280
|
+
for entry in document.entries:
|
|
281
|
+
if entry.platform != platform or entry.control_kind != control_kind:
|
|
282
|
+
continue
|
|
283
|
+
if source_ref and entry.source_ref == source_ref:
|
|
284
|
+
existing_entry = entry
|
|
285
|
+
break
|
|
286
|
+
if entry.control_label == control_label:
|
|
287
|
+
existing_entry = entry
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
if existing_entry is None:
|
|
291
|
+
existing_entry = CaseMemoryEntry(
|
|
292
|
+
memory_id=_build_memory_id(
|
|
293
|
+
platform=platform,
|
|
294
|
+
control_kind=control_kind,
|
|
295
|
+
control_label=control_label,
|
|
296
|
+
source_ref=source_ref,
|
|
297
|
+
),
|
|
298
|
+
platform=platform,
|
|
299
|
+
control_kind=control_kind,
|
|
300
|
+
control_label=control_label,
|
|
301
|
+
source_ref=source_ref,
|
|
302
|
+
)
|
|
303
|
+
document.entries.append(existing_entry)
|
|
304
|
+
|
|
305
|
+
status = _normalize_text(summary.get("status", ""))
|
|
306
|
+
if status == "success":
|
|
307
|
+
existing_entry.success_count += 1
|
|
308
|
+
else:
|
|
309
|
+
existing_entry.failure_count += 1
|
|
310
|
+
|
|
311
|
+
existing_entry.last_status = status
|
|
312
|
+
existing_entry.last_run_id = _normalize_text(summary.get("run_id", ""))
|
|
313
|
+
existing_entry.last_used_at = _normalize_text(summary.get("finished_at", "")) or _now_iso()
|
|
314
|
+
existing_entry.successful_actions = _merge_unique_strings(
|
|
315
|
+
existing_entry.successful_actions,
|
|
316
|
+
_collect_successful_actions(step_records),
|
|
317
|
+
)
|
|
318
|
+
existing_entry.locator_hints = _merge_locator_hints(
|
|
319
|
+
existing_entry.locator_hints,
|
|
320
|
+
_collect_locator_hints(summary, step_records),
|
|
321
|
+
)
|
|
322
|
+
existing_entry.pytest_asset = dict(summary.get("pytest_asset", {}) or {})
|
|
323
|
+
existing_entry.recommended_next_step = dict(summary.get("failure_analysis", {}) or {}) or None
|
|
324
|
+
|
|
325
|
+
document.updated_at = existing_entry.last_used_at
|
|
326
|
+
self.save_document(document)
|
|
327
|
+
return existing_entry.model_dump()
|
common/error_codes.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Single source of truth for agent-facing error codes.
|
|
2
|
+
|
|
3
|
+
Both the stderr log (`log.error(format_log("E037"))`) and the `--action --json`
|
|
4
|
+
failure payload read message + fix from this one table, so the two channels can
|
|
5
|
+
never drift. Scope is deliberately narrow: only the locate/action codes an agent
|
|
6
|
+
hits on the `--action` path. Connection codes (E04x/E05x) and `--goal`-only codes
|
|
7
|
+
(E02x stagnation / circuit-breaker / max-steps) are intentionally NOT here — this
|
|
8
|
+
iteration does not touch those paths.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# code -> (message, fix)
|
|
12
|
+
ERROR_CODES: dict[str, tuple[str, str]] = {
|
|
13
|
+
"E030": (
|
|
14
|
+
"Ref not found in cache.",
|
|
15
|
+
"Run inspect_ui first to refresh the element cache.",
|
|
16
|
+
),
|
|
17
|
+
"E031": (
|
|
18
|
+
"Unsupported action type.",
|
|
19
|
+
"See `screenforge --capabilities` for the supported action list.",
|
|
20
|
+
),
|
|
21
|
+
"E032": (
|
|
22
|
+
"Element action missing locator_type.",
|
|
23
|
+
"Provide locator_type (css/text/resourceId/description).",
|
|
24
|
+
),
|
|
25
|
+
"E033": (
|
|
26
|
+
"Element locator is empty after resolution.",
|
|
27
|
+
"Verify the target exists on the current page via inspect_ui.",
|
|
28
|
+
),
|
|
29
|
+
"E035": (
|
|
30
|
+
"AI returned empty action type.",
|
|
31
|
+
"Check that MODEL_NAME supports structured JSON output.",
|
|
32
|
+
),
|
|
33
|
+
"E036": (
|
|
34
|
+
"Ref has no stable locator (only coordinates).",
|
|
35
|
+
"Re-inspect; use a text/css locator instead of a coordinate-only ref.",
|
|
36
|
+
),
|
|
37
|
+
"E037": (
|
|
38
|
+
"Element could not be located for the action.",
|
|
39
|
+
"Re-inspect, scroll the target into view, or add --vision.",
|
|
40
|
+
),
|
|
41
|
+
"E038": (
|
|
42
|
+
"Element located but the action failed or was blocked.",
|
|
43
|
+
"Check for overlays; ensure the element is enabled and in the viewport.",
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_GENERIC = (
|
|
48
|
+
"Action failed.",
|
|
49
|
+
"Re-inspect via inspect_ui and adjust strategy.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def lookup(code: str) -> tuple[str, str]:
|
|
54
|
+
"""Return (message, fix) for a code; unknown codes get a generic, non-raising fallback."""
|
|
55
|
+
return ERROR_CODES.get(code, _GENERIC)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_log(code: str) -> str:
|
|
59
|
+
"""Format a code for stderr: '[E037] <message> Fix: <fix>'."""
|
|
60
|
+
msg, fix = lookup(code)
|
|
61
|
+
return f"[{code}] {msg} Fix: {fix}"
|
common/exceptions.py
ADDED