screenforge 0.7.0__tar.gz → 0.11.0__tar.gz
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.
- {screenforge-0.7.0 → screenforge-0.11.0}/PKG-INFO +3 -1
- {screenforge-0.7.0 → screenforge-0.11.0}/README.md +2 -0
- screenforge-0.11.0/cli/_version.py +1 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/modes/action.py +11 -6
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/modes/default.py +17 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/session.py +25 -1
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/tool_protocol_handlers.py +44 -17
- {screenforge-0.7.0 → screenforge-0.11.0}/common/ai.py +71 -8
- {screenforge-0.7.0 → screenforge-0.11.0}/common/ai_autonomous.py +5 -3
- screenforge-0.11.0/common/cache/cache_hash.py +88 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/cache/cache_manager.py +39 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/case_memory.py +14 -2
- {screenforge-0.7.0 → screenforge-0.11.0}/common/failure_diagnosis.py +40 -4
- {screenforge-0.7.0 → screenforge-0.11.0}/common/mcp_server.py +25 -7
- screenforge-0.11.0/common/observation.py +48 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/tool_protocol.py +6 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/config/config.py +10 -5
- {screenforge-0.7.0 → screenforge-0.11.0}/pyproject.toml +1 -1
- {screenforge-0.7.0 → screenforge-0.11.0}/screenforge.egg-info/PKG-INFO +3 -1
- {screenforge-0.7.0 → screenforge-0.11.0}/screenforge.egg-info/SOURCES.txt +13 -0
- screenforge-0.11.0/tests/test_ai_brain.py +210 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_cache_hash.py +28 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_cache_manager.py +41 -0
- screenforge-0.11.0/tests/test_case_memory_identity.py +93 -0
- screenforge-0.11.0/tests/test_config_app_target.py +23 -0
- screenforge-0.11.0/tests/test_failure_diagnosis_ios.py +62 -0
- screenforge-0.11.0/tests/test_failure_diagnosis_mobile.py +62 -0
- screenforge-0.11.0/tests/test_forward_candidates.py +61 -0
- screenforge-0.11.0/tests/test_inspect_ui_candidates.py +67 -0
- screenforge-0.11.0/tests/test_inspect_ui_envelope.py +92 -0
- screenforge-0.11.0/tests/test_inspect_ui_memory.py +116 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_mcp_ref_cache.py +1 -1
- screenforge-0.11.0/tests/test_mcp_tool_result.py +68 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_mode_error_code_threading.py +28 -0
- screenforge-0.11.0/tests/test_observation.py +76 -0
- screenforge-0.11.0/tests/test_observation_action_parity.py +59 -0
- screenforge-0.11.0/tests/test_session_reap.py +43 -0
- screenforge-0.7.0/cli/_version.py +0 -1
- screenforge-0.7.0/common/cache/cache_hash.py +0 -57
- screenforge-0.7.0/tests/test_ai_brain.py +0 -106
- {screenforge-0.7.0 → screenforge-0.11.0}/LICENSE +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/dispatch.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/doctor.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/modes/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/modes/demo.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/modes/dry_run.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/modes/init.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/modes/plan.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/modes/workflow.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/parser.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/playground_sink.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/reporter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/shared.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/cli/shorthand.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/adapters/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/adapters/android_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/adapters/base_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/adapters/ios_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/adapters/web_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/ai_heal.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/cache/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/cache/cache_stats.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/cache/cache_storage.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/cache/embedding_loader.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/capabilities.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/error_codes.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/exceptions.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/executor.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/history_manager.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/logs.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/preflight.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/progress.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/run_reporter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/run_resume.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/runtime_modes.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/visual_fallback.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/common/workflow_schema.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/config/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/config/env_loader.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/screenforge.egg-info/dependency_links.txt +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/screenforge.egg-info/entry_points.txt +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/screenforge.egg-info/requires.txt +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/screenforge.egg-info/top_level.txt +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/setup.cfg +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_action_mode_observation_stash.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_ai_autonomous.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_ai_heal.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_android_smoke_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_capabilities.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_cli_action_json.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_codegen_quality.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_dispatch.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_doctor_orphan_browser.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_dom_capture.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_error_codes.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_executor.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_failure_diagnosis.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_interaction_actions.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_ios_smoke_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_mcp_execute_observation.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_mcp_execute_parity_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_ml_optional.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_parser.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_playground_app.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_playground_sink.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_playground_sink_integration.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_cases.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_conftest_gating.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_filmstrip.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_html.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_json.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_locate_frame.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_patching.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_recorder.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_screenshot.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_review_smoke_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_run_reporter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_run_resume.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_runtime_modes.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_screenshot_annotator.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_shared_observation_stash.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_shorthand.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_tool_protocol_diagnosis.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_utils_ios.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_utils_web.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_utils_xml.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_visual_fallback.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_web_adapter.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_web_dom_complex_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_web_smoke_live.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/tests/test_workflow_mode_observation_stash.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/utils/__init__.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/utils/screenshot_annotator.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/utils/utils_ios.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/utils/utils_web.py +0 -0
- {screenforge-0.7.0 → screenforge-0.11.0}/utils/utils_xml.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: screenforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: AI-driven cross-platform UI automation engine with test script generation
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
|
|
@@ -120,6 +120,8 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
|
|
|
120
120
|
- **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
|
|
121
121
|
- **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
|
|
122
122
|
- **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
|
|
123
|
+
- **Case memory hints on `inspect_ui`**: pass an optional `task` label and the payload's `memory` field carries what the last run of that task did — locator, counts, and outcome — a hint the Agent weighs against the live tree, seen before it acts, never auto-replayed. It records failures too, so check `last_status`/`success_count` before trusting a hint. The label must match the one the earlier run recorded (`--action-name`, else `"{action}:{locator_value}"`); `load_case_memory` lists them. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
124
|
+
- **Plain-language element lookup on `inspect_ui`**: pass an optional `intent` phrase (e.g. `"登录按钮"`) and the payload's `candidates` field returns up to 3 ranked elements with ready-to-use locators — additive to the full `ui_tree`, never a replacement. Matching is literal string similarity, not semantic, so phrase `intent` in the same language as the UI. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
123
125
|
|
|
124
126
|
## Agent Integration (Claude Code / Cursor / Codex)
|
|
125
127
|
|
|
@@ -75,6 +75,8 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
|
|
|
75
75
|
- **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
|
|
76
76
|
- **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
|
|
77
77
|
- **Execution-replay review report**: Run any test with `REVIEW_RECORD=1` and get a self-contained, shareable offline `report.html` — a drag-timeline of each step's screenshot + the test source line that triggered it + the DOM tree, plus a `video.gif` filmstrip. Default off, zero overhead. (Web verified; mobile registry/recording seam in place but unverified.)
|
|
78
|
+
- **Case memory hints on `inspect_ui`**: pass an optional `task` label and the payload's `memory` field carries what the last run of that task did — locator, counts, and outcome — a hint the Agent weighs against the live tree, seen before it acts, never auto-replayed. It records failures too, so check `last_status`/`success_count` before trusting a hint. The label must match the one the earlier run recorded (`--action-name`, else `"{action}:{locator_value}"`); `load_case_memory` lists them. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
79
|
+
- **Plain-language element lookup on `inspect_ui`**: pass an optional `intent` phrase (e.g. `"登录按钮"`) and the payload's `candidates` field returns up to 3 ranked elements with ready-to-use locators — additive to the full `ui_tree`, never a replacement. Matching is literal string similarity, not semantic, so phrase `intent` in the same language as the UI. See the [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md).
|
|
78
80
|
|
|
79
81
|
## Agent Integration (Claude Code / Cursor / Codex)
|
|
80
82
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.11.0"
|
|
@@ -26,6 +26,7 @@ from cli.shared import (
|
|
|
26
26
|
save_to_disk,
|
|
27
27
|
)
|
|
28
28
|
from common.failure_diagnosis import diagnose
|
|
29
|
+
from common.observation import build_observation
|
|
29
30
|
from common.runtime_modes import MODE_RUN
|
|
30
31
|
|
|
31
32
|
|
|
@@ -69,9 +70,11 @@ def build_failure_payload(
|
|
|
69
70
|
ui_elements=ui_tree.get("ui_elements", []) or [],
|
|
70
71
|
)
|
|
71
72
|
payload.update(diag.to_dict())
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
observation = build_observation(ui_tree=ui_tree, current_url=current_url)
|
|
74
|
+
observation.pop("screenshot_base64") # engine_error has never carried one
|
|
75
|
+
observation.pop("memory") # decision-time hint; action is post-decision
|
|
76
|
+
observation.pop("candidates") # engine_error already has its own diagnosis candidates
|
|
77
|
+
payload.update(observation)
|
|
75
78
|
return payload
|
|
76
79
|
|
|
77
80
|
|
|
@@ -88,14 +91,16 @@ def build_success_payload(
|
|
|
88
91
|
Single source of truth shared by the shell --json stdout write and the MCP
|
|
89
92
|
observation stash, so the two can never drift. Pure: no I/O, no device.
|
|
90
93
|
"""
|
|
94
|
+
observation = build_observation(ui_tree=ui_tree, current_url=current_url)
|
|
95
|
+
observation.pop("screenshot_base64") # action payloads have never carried one
|
|
96
|
+
observation.pop("memory") # decision-time hint; action is post-decision
|
|
97
|
+
observation.pop("candidates") # engine_error already has its own diagnosis candidates
|
|
91
98
|
return {
|
|
92
99
|
"ok": True,
|
|
93
100
|
"action": action_name,
|
|
94
101
|
"platform": platform,
|
|
95
|
-
|
|
96
|
-
"element_count": len(ui_tree.get("ui_elements", []) or []),
|
|
102
|
+
**observation,
|
|
97
103
|
"output_script": output_script,
|
|
98
|
-
"current_url": current_url,
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
|
|
@@ -58,6 +58,7 @@ def run_default_mode(
|
|
|
58
58
|
step_count = 0
|
|
59
59
|
last_error = ""
|
|
60
60
|
consecutive_failures = 0
|
|
61
|
+
consecutive_not_found = 0
|
|
61
62
|
last_ui_json = ""
|
|
62
63
|
|
|
63
64
|
while step_count < args.max_steps:
|
|
@@ -177,11 +178,27 @@ def run_default_mode(
|
|
|
177
178
|
success=False,
|
|
178
179
|
action_description="not_found",
|
|
179
180
|
)
|
|
181
|
+
# not_found is exempt from the failure circuit breaker so vision
|
|
182
|
+
# gets a chance, but a model stuck returning it makes no progress
|
|
183
|
+
# and burns one LLM call per step up to max_steps (token bleed).
|
|
184
|
+
# Cap consecutive not_found at the same threshold and stop.
|
|
185
|
+
consecutive_not_found += 1
|
|
186
|
+
if consecutive_not_found >= args.max_retries:
|
|
187
|
+
final_error = (
|
|
188
|
+
f"Stopped after {consecutive_not_found} consecutive 'not_found' — "
|
|
189
|
+
f"target never appeared in the UI tree."
|
|
190
|
+
)
|
|
191
|
+
log.error(
|
|
192
|
+
f"[E025] {consecutive_not_found} consecutive 'not_found'. "
|
|
193
|
+
f"Fix: re-run with --vision, or refine --goal / scroll the target into view."
|
|
194
|
+
)
|
|
195
|
+
return 1
|
|
180
196
|
continue
|
|
181
197
|
else:
|
|
182
198
|
result = executor.execute_and_record(action_data)
|
|
183
199
|
if result.get("success"):
|
|
184
200
|
consecutive_failures = 0
|
|
201
|
+
consecutive_not_found = 0
|
|
185
202
|
history_manager.add_step(
|
|
186
203
|
result["code_lines"], result["action_description"]
|
|
187
204
|
)
|
|
@@ -108,6 +108,30 @@ def start_session_recording(session_id: str, platform: str, udid: str = "") -> s
|
|
|
108
108
|
return video_path
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
def _reap_with_timeout(pid: int, timeout: float) -> None:
|
|
112
|
+
"""Wait for a signalled recording process to exit, then SIGKILL if it won't.
|
|
113
|
+
|
|
114
|
+
Bare os.waitpid(pid, 0) blocks forever — if `xcrun simctl recordVideo` ever
|
|
115
|
+
ignores SIGINT, --session-end would hang the whole CLI. Poll with WNOHANG up
|
|
116
|
+
to `timeout`, then SIGKILL and reap so we never block indefinitely.
|
|
117
|
+
"""
|
|
118
|
+
deadline = time.monotonic() + timeout
|
|
119
|
+
while time.monotonic() < deadline:
|
|
120
|
+
try:
|
|
121
|
+
reaped, _ = os.waitpid(pid, os.WNOHANG)
|
|
122
|
+
except ChildProcessError:
|
|
123
|
+
return # already reaped elsewhere
|
|
124
|
+
if reaped:
|
|
125
|
+
return
|
|
126
|
+
time.sleep(0.1)
|
|
127
|
+
# Still alive after the grace period — kill hard and reap the zombie.
|
|
128
|
+
try:
|
|
129
|
+
os.kill(pid, signal.SIGKILL)
|
|
130
|
+
os.waitpid(pid, 0)
|
|
131
|
+
except OSError:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
111
135
|
def stop_session_recording(session_id: str) -> str:
|
|
112
136
|
session = load_session(session_id)
|
|
113
137
|
if not session:
|
|
@@ -120,7 +144,7 @@ def stop_session_recording(session_id: str) -> str:
|
|
|
120
144
|
try:
|
|
121
145
|
pgid = os.getpgid(pid)
|
|
122
146
|
os.killpg(pgid, signal.SIGINT)
|
|
123
|
-
|
|
147
|
+
_reap_with_timeout(pid, timeout=10.0)
|
|
124
148
|
except OSError:
|
|
125
149
|
try:
|
|
126
150
|
os.kill(pid, signal.SIGINT)
|
|
@@ -20,6 +20,8 @@ from cli.shared import (
|
|
|
20
20
|
current_url,
|
|
21
21
|
log,
|
|
22
22
|
)
|
|
23
|
+
from common.failure_diagnosis import rank_candidates
|
|
24
|
+
from common.observation import build_observation
|
|
23
25
|
from common.run_resume import RunContextLoadError, load_run_bundle
|
|
24
26
|
from common.runtime_modes import MODE_DOCTOR, resolve_execution_mode
|
|
25
27
|
from common.tool_protocol import (
|
|
@@ -137,6 +139,30 @@ def _find_case_memory_hit(args, execution_mode: str) -> dict | None:
|
|
|
137
139
|
)
|
|
138
140
|
|
|
139
141
|
|
|
142
|
+
def _lookup_task_memory(platform: str, task: str) -> dict:
|
|
143
|
+
"""Case-memory hint for an inspect_ui request, or {} when there's nothing.
|
|
144
|
+
|
|
145
|
+
A HINT, not an instruction — see common/observation.py. Matching is fuzzy by
|
|
146
|
+
design (find_entry ORs control_label with source_ref), so a wrong hit costs
|
|
147
|
+
one useless hint, never a wrong click. control_kind is "action" because an
|
|
148
|
+
inspect precedes a single action, and that's the kind those runs record under.
|
|
149
|
+
Any read failure degrades to {}: a missing hint must never cost the agent
|
|
150
|
+
its UI tree.
|
|
151
|
+
"""
|
|
152
|
+
if not str(task).strip():
|
|
153
|
+
return {}
|
|
154
|
+
try:
|
|
155
|
+
entry = _load_case_memory_store().find_entry(
|
|
156
|
+
platform=platform,
|
|
157
|
+
control_kind="action",
|
|
158
|
+
control_label=task,
|
|
159
|
+
)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
log.warning(f"⚠️ [Warning] Case-memory lookup failed, continuing without hint: {e}")
|
|
162
|
+
return {}
|
|
163
|
+
return entry or {}
|
|
164
|
+
|
|
165
|
+
|
|
140
166
|
def build_load_case_memory_payload(
|
|
141
167
|
platform: str = "",
|
|
142
168
|
control_kind: str = "",
|
|
@@ -182,13 +208,6 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
|
|
|
182
208
|
1,
|
|
183
209
|
)
|
|
184
210
|
|
|
185
|
-
if not screenshot_base64:
|
|
186
|
-
try:
|
|
187
|
-
img_bytes = adapter.take_screenshot()
|
|
188
|
-
screenshot_base64 = base64.b64encode(img_bytes).decode("utf-8")
|
|
189
|
-
except Exception as e:
|
|
190
|
-
log.warning(f"⚠️ [Warning] inspect_ui screenshot capture failed: {e}")
|
|
191
|
-
|
|
192
211
|
try:
|
|
193
212
|
ui_tree = json.loads(ui_json)
|
|
194
213
|
except json.JSONDecodeError:
|
|
@@ -207,30 +226,36 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
|
|
|
207
226
|
except Exception as e:
|
|
208
227
|
log.warning(f"⚠️ [Warning] Failed to sync ref cache from inspect_ui: {e}")
|
|
209
228
|
|
|
210
|
-
|
|
229
|
+
# Annotate in place: ref labels are only meaningful where the compressor
|
|
230
|
+
# emits ref + bbox + clickable (web). Mobile trees carry none of those, so
|
|
231
|
+
# annotate_screenshot draws nothing and just re-encodes the image — same
|
|
232
|
+
# picture, no labels. Either way exactly ONE image ships.
|
|
211
233
|
if screenshot_base64 and ui_tree.get("ui_elements"):
|
|
212
234
|
try:
|
|
213
235
|
from utils.screenshot_annotator import annotate_screenshot
|
|
214
236
|
raw_bytes = base64.b64decode(screenshot_base64)
|
|
215
237
|
annotated_bytes = annotate_screenshot(raw_bytes, ui_tree["ui_elements"])
|
|
216
|
-
|
|
238
|
+
screenshot_base64 = base64.b64encode(annotated_bytes).decode("utf-8")
|
|
217
239
|
except Exception as e:
|
|
218
240
|
log.warning(f"⚠️ [Warning] Annotated screenshot generation failed: {e}")
|
|
219
241
|
|
|
220
|
-
page_url = current_url(adapter, request.platform)
|
|
221
|
-
|
|
222
242
|
return {
|
|
223
243
|
"ok": True,
|
|
224
244
|
"operation": "inspect_ui",
|
|
225
245
|
"exit_code": 0,
|
|
226
246
|
"platform": request.platform,
|
|
227
247
|
"env": request.env,
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
248
|
+
**build_observation(
|
|
249
|
+
ui_tree=ui_tree,
|
|
250
|
+
current_url=current_url(adapter, request.platform),
|
|
251
|
+
screenshot_base64=screenshot_base64 or "",
|
|
252
|
+
memory=_lookup_task_memory(request.platform, request.task),
|
|
253
|
+
candidates=(
|
|
254
|
+
rank_candidates(request.intent, ui_tree.get("ui_elements") or [])
|
|
255
|
+
if str(request.intent).strip()
|
|
256
|
+
else []
|
|
257
|
+
),
|
|
258
|
+
),
|
|
234
259
|
}
|
|
235
260
|
except Exception as e:
|
|
236
261
|
return {
|
|
@@ -241,6 +266,8 @@ def build_inspect_ui_payload(request, shared_adapter_manager: _SharedAdapterMana
|
|
|
241
266
|
"env": request.env,
|
|
242
267
|
"error": str(e),
|
|
243
268
|
"current_url": "",
|
|
269
|
+
"memory": {},
|
|
270
|
+
"candidates": [],
|
|
244
271
|
}
|
|
245
272
|
finally:
|
|
246
273
|
if owns_adapter and adapter:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import re
|
|
2
3
|
import time
|
|
3
4
|
|
|
4
5
|
from openai import OpenAI
|
|
@@ -18,6 +19,39 @@ def _strip_json_fences(text: str) -> str:
|
|
|
18
19
|
return text
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
# Endpoints (base_url) that 400'd on response_format — never retried with it.
|
|
23
|
+
# response_format={"type":"json_object"} cuts malformed responses (fewer retries,
|
|
24
|
+
# fewer tokens), but is NOT universal across OpenAI-compatible endpoints, which
|
|
25
|
+
# this tool promises to support. So: try once per endpoint, remember a rejection,
|
|
26
|
+
# and fall back to a plain call forever after. Prompts already say "output JSON".
|
|
27
|
+
_JSON_MODE_UNSUPPORTED: set = set()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def chat_completion_json(client, **kwargs):
|
|
31
|
+
"""chat.completions.create that requests JSON mode when the endpoint allows.
|
|
32
|
+
|
|
33
|
+
Detect-once per endpoint: on the first 400-style rejection of response_format
|
|
34
|
+
we record the base_url and never send it again, so an endpoint without JSON
|
|
35
|
+
mode pays one failed call total, not one per request.
|
|
36
|
+
"""
|
|
37
|
+
endpoint = str(getattr(client, "base_url", ""))
|
|
38
|
+
if endpoint not in _JSON_MODE_UNSUPPORTED:
|
|
39
|
+
try:
|
|
40
|
+
return client.chat.completions.create(
|
|
41
|
+
response_format={"type": "json_object"}, **kwargs
|
|
42
|
+
)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
# Only json-mode-shaped rejections fall back; real network/auth errors
|
|
45
|
+
# must surface to the caller's existing try/except unchanged.
|
|
46
|
+
msg = str(e).lower()
|
|
47
|
+
if "response_format" in msg or "json" in msg or "not support" in msg:
|
|
48
|
+
log.warning(f"[AI] Endpoint rejected JSON mode, falling back for {endpoint}")
|
|
49
|
+
_JSON_MODE_UNSUPPORTED.add(endpoint)
|
|
50
|
+
else:
|
|
51
|
+
raise
|
|
52
|
+
return client.chat.completions.create(**kwargs)
|
|
53
|
+
|
|
54
|
+
|
|
21
55
|
class AIBrain:
|
|
22
56
|
def __init__(self):
|
|
23
57
|
# 实例化文本专属客户端
|
|
@@ -39,20 +73,48 @@ class AIBrain:
|
|
|
39
73
|
|
|
40
74
|
def _verify_locator_in_ui(self, decision: dict, ui_dict: dict) -> bool:
|
|
41
75
|
"""
|
|
42
|
-
校验缓存中推荐的动作,其元素是否真实存在于当前的 UI
|
|
76
|
+
校验缓存中推荐的动作,其元素是否真实存在于当前的 UI 树中。
|
|
77
|
+
|
|
78
|
+
L2 是跨页面的全局语义缓存,一条决策可能命中一个并不存在该元素的页面。
|
|
79
|
+
本方法是那道安全网:能确认元素不存在时返回 False 丢弃该命中;无法确认
|
|
80
|
+
(元素无该属性、选择器无法解析)时放行,绝不误伤有效缓存。
|
|
43
81
|
"""
|
|
44
82
|
loc_type = decision.get("locator_type")
|
|
45
83
|
loc_val = decision.get("locator_value")
|
|
46
84
|
|
|
47
|
-
#
|
|
48
|
-
if
|
|
49
|
-
not loc_type
|
|
50
|
-
or not loc_val
|
|
51
|
-
or loc_type not in ["text", "description", "resourceId", "id"]
|
|
52
|
-
):
|
|
85
|
+
# 无定位需求 (swipe / press 等全局动作) 直接放行
|
|
86
|
+
if not loc_type or not loc_val:
|
|
53
87
|
return True
|
|
54
88
|
|
|
55
89
|
elements = ui_dict.get("ui_elements", [])
|
|
90
|
+
|
|
91
|
+
# css 是 Web 端最常用的定位器 (Prompt 铁律排第一),且 L2 是跨页面全局缓存,
|
|
92
|
+
# 因此必须校验而非放行。压缩后的 Web 树只能确认执行器自己产出的两种形态:
|
|
93
|
+
# `#id` -> 元素 id,`[name="x"]` -> 元素 name (见 executor.LocatorBuilder)。
|
|
94
|
+
# 其它选择器 (class/标签/后代) 树里无法还原,判为无法确认 -> 放行。
|
|
95
|
+
if loc_type == "css":
|
|
96
|
+
# Only a SIMPLE selector round-trips to a tree attribute. The charset
|
|
97
|
+
# excludes CSS combinators/escapes (space > + ~ , . [ : \) on purpose:
|
|
98
|
+
# `#form input` and `#user\.email` are compound or escaped, so the raw
|
|
99
|
+
# string never equals an `id` value — comparing anyway would discard a
|
|
100
|
+
# VALID L2 hit and force a fresh paid LLM call, inverting this method's
|
|
101
|
+
# purpose. Unverifiable → pass through.
|
|
102
|
+
simple = r"[^\s>+~,.\[\]:\\#]+"
|
|
103
|
+
m = re.fullmatch(rf"#({simple})", loc_val) or re.fullmatch(
|
|
104
|
+
rf'\[name="({simple})"\]', loc_val
|
|
105
|
+
)
|
|
106
|
+
if not m:
|
|
107
|
+
return True
|
|
108
|
+
attr = "id" if loc_val.startswith("#") else "name"
|
|
109
|
+
target = m.group(1)
|
|
110
|
+
values = [el.get(attr) for el in elements if el.get(attr)]
|
|
111
|
+
if not values: # 树里没有任何该属性 -> 无从确认 -> 放行
|
|
112
|
+
return True
|
|
113
|
+
return target in values
|
|
114
|
+
|
|
115
|
+
if loc_type not in ["text", "description", "resourceId", "id"]:
|
|
116
|
+
return True
|
|
117
|
+
|
|
56
118
|
for el in elements:
|
|
57
119
|
if loc_type == "text" and el.get("text") == loc_val:
|
|
58
120
|
return True
|
|
@@ -263,7 +325,8 @@ class AIBrain:
|
|
|
263
325
|
result_text = ""
|
|
264
326
|
try:
|
|
265
327
|
with ai_status(f"Thinking ({model_name})..."):
|
|
266
|
-
response =
|
|
328
|
+
response = chat_completion_json(
|
|
329
|
+
client,
|
|
267
330
|
model=model_name,
|
|
268
331
|
messages=[
|
|
269
332
|
{"role": "system", "content": system_prompt},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
3
|
import config.config as config
|
|
4
|
-
from common.ai import AIBrain, _strip_json_fences
|
|
4
|
+
from common.ai import AIBrain, _strip_json_fences, chat_completion_json
|
|
5
5
|
from common.logs import log
|
|
6
6
|
from common.progress import ai_status
|
|
7
7
|
|
|
@@ -80,7 +80,8 @@ class AutonomousBrain(AIBrain):
|
|
|
80
80
|
result_text = ""
|
|
81
81
|
try:
|
|
82
82
|
with ai_status("Planning execution steps..."):
|
|
83
|
-
response =
|
|
83
|
+
response = chat_completion_json(
|
|
84
|
+
active_client,
|
|
84
85
|
model=active_model,
|
|
85
86
|
messages=[
|
|
86
87
|
{"role": "system", "content": system_prompt},
|
|
@@ -225,7 +226,8 @@ class AutonomousBrain(AIBrain):
|
|
|
225
226
|
result_text = ""
|
|
226
227
|
try:
|
|
227
228
|
with ai_status("Reasoning about next action..."):
|
|
228
|
-
response =
|
|
229
|
+
response = chat_completion_json(
|
|
230
|
+
active_client,
|
|
229
231
|
model=active_model,
|
|
230
232
|
messages=[
|
|
231
233
|
{"role": "system", "content": system_prompt},
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
_BLOCKLIST = frozenset(
|
|
7
|
+
[
|
|
8
|
+
"加密货币",
|
|
9
|
+
"比特币",
|
|
10
|
+
"meme币",
|
|
11
|
+
"美股代币",
|
|
12
|
+
"贵金属代币",
|
|
13
|
+
"热门资产",
|
|
14
|
+
"已上线",
|
|
15
|
+
"已上架",
|
|
16
|
+
"上架",
|
|
17
|
+
"下架",
|
|
18
|
+
"公告",
|
|
19
|
+
"活动",
|
|
20
|
+
]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _latin_anchor(raw_text: str) -> str:
|
|
25
|
+
"""A Latin/non-CJK anchor from an element's text, or '' if not anchor-like.
|
|
26
|
+
|
|
27
|
+
Mirrors the CJK path's dynamic-data immunity: digits and symbols are
|
|
28
|
+
stripped so a changing price/balance/count doesn't move the skeleton hash
|
|
29
|
+
("Balance 100" and "Balance 9999" both reduce to "balance"). Whitespace is
|
|
30
|
+
collapsed and case is folded so render noise doesn't fork the fingerprint.
|
|
31
|
+
|
|
32
|
+
ponytail: 2..24 chars keeps button/nav labels ("login", "delete account")
|
|
33
|
+
and drops long description sentences — the Latin analogue of the CJK 2..6
|
|
34
|
+
window. Raise the ceiling only if a real multi-word control exceeds it.
|
|
35
|
+
"""
|
|
36
|
+
letters_only = re.sub(r"[^a-zA-Z\s]", "", raw_text)
|
|
37
|
+
normalized = " ".join(letters_only.split()).lower()
|
|
38
|
+
return normalized if 2 <= len(normalized) <= 24 else ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_semantic_fingerprint(ui_json: Dict[str, Any]) -> list:
|
|
42
|
+
"""
|
|
43
|
+
提取页面锚点指纹,免疫动态数据和渲染顺序波动。
|
|
44
|
+
"""
|
|
45
|
+
fingerprint_features = set()
|
|
46
|
+
elements = ui_json.get("ui_elements", [])
|
|
47
|
+
|
|
48
|
+
for el in elements:
|
|
49
|
+
raw_text = el.get("text", "") or el.get("desc", "")
|
|
50
|
+
# 抹除所有数字、字母、符号,只保留纯汉字
|
|
51
|
+
cn_text = re.sub(r"[^\u4e00-\u9fa5]", "", raw_text)
|
|
52
|
+
|
|
53
|
+
# 标准的 UI 导航或按钮,通常在 2 到 6 个汉字之间
|
|
54
|
+
if 2 <= len(cn_text) <= 6 and cn_text not in _BLOCKLIST:
|
|
55
|
+
fingerprint_features.add(f"{el.get('class')}|{cn_text}")
|
|
56
|
+
|
|
57
|
+
# Non-CJK pages (English etc.) carry NO Chinese anchors, so the loop above
|
|
58
|
+
# yields nothing and every such page collapsed to one constant hash — L1's
|
|
59
|
+
# exact key then replayed an action recorded on a DIFFERENT page. Fall back
|
|
60
|
+
# to Latin anchors so those pages get distinct identities too. Page-level,
|
|
61
|
+
# not per-element: a page WITH usable CJK anchors keeps exactly its old
|
|
62
|
+
# fingerprint (Chinese behavior frozen); only a fully-anchorless page falls
|
|
63
|
+
# through here.
|
|
64
|
+
if not fingerprint_features:
|
|
65
|
+
for el in elements:
|
|
66
|
+
anchor = _latin_anchor(el.get("text", "") or el.get("desc", ""))
|
|
67
|
+
if anchor:
|
|
68
|
+
fingerprint_features.add(f"{el.get('class')}|{anchor}")
|
|
69
|
+
|
|
70
|
+
# 强制排序并转为列表,保证哈希的绝对一致性
|
|
71
|
+
return sorted(list(fingerprint_features))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compute_ui_hash(ui_json: Dict[str, Any]) -> str:
|
|
75
|
+
"""计算用于混合缓存匹配的页面骨架 Hash"""
|
|
76
|
+
fingerprint = _extract_semantic_fingerprint(ui_json)
|
|
77
|
+
fingerprint_str = json.dumps(fingerprint)
|
|
78
|
+
hash_obj = hashlib.sha256()
|
|
79
|
+
hash_obj.update(fingerprint_str.encode("utf-8"))
|
|
80
|
+
return hash_obj.hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute_instruction_hash(instruction: str) -> str:
|
|
84
|
+
"""计算用于混合缓存 O(1) 精确匹配的指令 Hash"""
|
|
85
|
+
normalized_inst = re.sub(r"\s+", " ", instruction).strip().lower()
|
|
86
|
+
hash_obj = hashlib.sha256()
|
|
87
|
+
hash_obj.update(normalized_inst.encode("utf-8"))
|
|
88
|
+
return hash_obj.hexdigest()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import os
|
|
2
3
|
|
|
3
4
|
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
|
|
@@ -30,6 +31,7 @@ class CacheManager:
|
|
|
30
31
|
self._cache_dir = cache_dir
|
|
31
32
|
self._enabled = enabled
|
|
32
33
|
self._ttl_seconds = ttl_days * 24 * 60 * 60
|
|
34
|
+
self._max_size_bytes = max_size_mb * 1024 * 1024
|
|
33
35
|
self._stats = CacheStats(cache_dir)
|
|
34
36
|
self._model_loader = EmbeddingModelLoader()
|
|
35
37
|
|
|
@@ -178,6 +180,10 @@ class CacheManager:
|
|
|
178
180
|
return False
|
|
179
181
|
try:
|
|
180
182
|
cache_data = load_cache(self._cache_dir)
|
|
183
|
+
# Prune expired entries here: the write path reloads and re-saves the
|
|
184
|
+
# whole file, so without this it re-persists dead entries forever. The
|
|
185
|
+
# read path only saves on a hit, so misses can't be relied on to prune.
|
|
186
|
+
cache_data = cleanup_expired_entries(cache_data, self._ttl_seconds)
|
|
181
187
|
entries = cache_data.setdefault("entries", {})
|
|
182
188
|
current_vector = self._get_embedding(instruction)
|
|
183
189
|
keys_to_delete = []
|
|
@@ -219,12 +225,45 @@ class CacheManager:
|
|
|
219
225
|
if ui_hash is not None:
|
|
220
226
|
entry["ui_hash"] = ui_hash
|
|
221
227
|
entries[exact_key] = entry
|
|
228
|
+
self._evict_to_fit(cache_data, exact_key)
|
|
222
229
|
save_cache(self._cache_dir, cache_data)
|
|
223
230
|
return True
|
|
224
231
|
except Exception as e:
|
|
225
232
|
log.error(f"[Cache Error] Write failed: {e}")
|
|
226
233
|
return False
|
|
227
234
|
|
|
235
|
+
def _evict_to_fit(self, cache_data: Dict, keep_key: str) -> None:
|
|
236
|
+
"""Evict least-recently-accessed entries until the cache fits max_size_bytes.
|
|
237
|
+
|
|
238
|
+
TTL bounds age; this bounds size within the TTL window. The just-written
|
|
239
|
+
key is never evicted (a single entry larger than the cap still persists —
|
|
240
|
+
the cap is a soft ceiling, not a hard reject of the current write).
|
|
241
|
+
"""
|
|
242
|
+
entries = cache_data.get("entries", {})
|
|
243
|
+
|
|
244
|
+
# ponytail: re-serializes on each eviction (O(n²) worst case). Fine —
|
|
245
|
+
# TTL already bounds entry count and eviction is rare; switch to a running
|
|
246
|
+
# byte tally if a cache ever evicts hundreds of entries in one write.
|
|
247
|
+
def size() -> int:
|
|
248
|
+
return len(json.dumps(cache_data, ensure_ascii=False).encode("utf-8"))
|
|
249
|
+
|
|
250
|
+
if size() <= self._max_size_bytes:
|
|
251
|
+
return
|
|
252
|
+
# Oldest access first; the current write is pinned last so it survives.
|
|
253
|
+
ordered = sorted(
|
|
254
|
+
entries.keys(),
|
|
255
|
+
key=lambda k: (
|
|
256
|
+
k == keep_key,
|
|
257
|
+
entries[k].get("metadata", {}).get("last_accessed", ""),
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
for k in ordered:
|
|
261
|
+
if k == keep_key or len(entries) <= 1:
|
|
262
|
+
break
|
|
263
|
+
del entries[k]
|
|
264
|
+
if size() <= self._max_size_bytes:
|
|
265
|
+
break
|
|
266
|
+
|
|
228
267
|
def get(self, instruction: str, ui_json: Dict[str, Any], platform: str) -> Optional[Dict[str, Any]]:
|
|
229
268
|
try:
|
|
230
269
|
ui_hash = compute_ui_hash(ui_json)
|
|
@@ -24,6 +24,17 @@ def _slugify(value: str) -> str:
|
|
|
24
24
|
return text.lower() or "memory"
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
# Inline `--action` runs all record this same source_ref: it says "typed on the
|
|
28
|
+
# command line", not "which control". Treating it as an identity made the first
|
|
29
|
+
# inline action on a platform absorb every later one, so a named action could
|
|
30
|
+
# never get its own entry — or be found by its name.
|
|
31
|
+
_NON_IDENTIFYING_SOURCE_REFS = frozenset({"inline://action"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _identifying_source_ref(source_ref: str) -> str:
|
|
35
|
+
return "" if source_ref in _NON_IDENTIFYING_SOURCE_REFS else source_ref
|
|
36
|
+
|
|
37
|
+
|
|
27
38
|
def _build_memory_id(
|
|
28
39
|
platform: str,
|
|
29
40
|
control_kind: str,
|
|
@@ -240,7 +251,7 @@ class CaseMemoryStore:
|
|
|
240
251
|
normalized_platform = _normalize_text(platform).lower()
|
|
241
252
|
normalized_kind = _normalize_text(control_kind).lower()
|
|
242
253
|
normalized_label = _normalize_text(control_label)
|
|
243
|
-
normalized_source_ref = _normalize_text(source_ref)
|
|
254
|
+
normalized_source_ref = _identifying_source_ref(_normalize_text(source_ref))
|
|
244
255
|
document = self.load_document()
|
|
245
256
|
|
|
246
257
|
for entry in document.entries:
|
|
@@ -277,10 +288,11 @@ class CaseMemoryStore:
|
|
|
277
288
|
|
|
278
289
|
document = self.load_document()
|
|
279
290
|
existing_entry = None
|
|
291
|
+
identifying_source_ref = _identifying_source_ref(source_ref)
|
|
280
292
|
for entry in document.entries:
|
|
281
293
|
if entry.platform != platform or entry.control_kind != control_kind:
|
|
282
294
|
continue
|
|
283
|
-
if
|
|
295
|
+
if identifying_source_ref and entry.source_ref == identifying_source_ref:
|
|
284
296
|
existing_entry = entry
|
|
285
297
|
break
|
|
286
298
|
if entry.control_label == control_label:
|