screenforge 0.10.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.
Files changed (138) hide show
  1. {screenforge-0.10.0 → screenforge-0.11.0}/PKG-INFO +1 -1
  2. screenforge-0.11.0/cli/_version.py +1 -0
  3. {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/default.py +17 -0
  4. {screenforge-0.10.0 → screenforge-0.11.0}/cli/session.py +25 -1
  5. {screenforge-0.10.0 → screenforge-0.11.0}/common/ai.py +71 -8
  6. {screenforge-0.10.0 → screenforge-0.11.0}/common/ai_autonomous.py +5 -3
  7. screenforge-0.11.0/common/cache/cache_hash.py +88 -0
  8. {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/cache_manager.py +39 -0
  9. {screenforge-0.10.0 → screenforge-0.11.0}/config/config.py +10 -5
  10. {screenforge-0.10.0 → screenforge-0.11.0}/pyproject.toml +1 -1
  11. {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/PKG-INFO +1 -1
  12. {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/SOURCES.txt +2 -0
  13. screenforge-0.11.0/tests/test_ai_brain.py +210 -0
  14. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_cache_hash.py +28 -0
  15. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_cache_manager.py +41 -0
  16. screenforge-0.11.0/tests/test_config_app_target.py +23 -0
  17. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mode_error_code_threading.py +28 -0
  18. screenforge-0.11.0/tests/test_session_reap.py +43 -0
  19. screenforge-0.10.0/cli/_version.py +0 -1
  20. screenforge-0.10.0/common/cache/cache_hash.py +0 -57
  21. screenforge-0.10.0/tests/test_ai_brain.py +0 -106
  22. {screenforge-0.10.0 → screenforge-0.11.0}/LICENSE +0 -0
  23. {screenforge-0.10.0 → screenforge-0.11.0}/README.md +0 -0
  24. {screenforge-0.10.0 → screenforge-0.11.0}/cli/__init__.py +0 -0
  25. {screenforge-0.10.0 → screenforge-0.11.0}/cli/dispatch.py +0 -0
  26. {screenforge-0.10.0 → screenforge-0.11.0}/cli/doctor.py +0 -0
  27. {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/__init__.py +0 -0
  28. {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/action.py +0 -0
  29. {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/demo.py +0 -0
  30. {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/dry_run.py +0 -0
  31. {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/init.py +0 -0
  32. {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/plan.py +0 -0
  33. {screenforge-0.10.0 → screenforge-0.11.0}/cli/modes/workflow.py +0 -0
  34. {screenforge-0.10.0 → screenforge-0.11.0}/cli/parser.py +0 -0
  35. {screenforge-0.10.0 → screenforge-0.11.0}/cli/playground_sink.py +0 -0
  36. {screenforge-0.10.0 → screenforge-0.11.0}/cli/reporter.py +0 -0
  37. {screenforge-0.10.0 → screenforge-0.11.0}/cli/shared.py +0 -0
  38. {screenforge-0.10.0 → screenforge-0.11.0}/cli/shorthand.py +0 -0
  39. {screenforge-0.10.0 → screenforge-0.11.0}/cli/tool_protocol_handlers.py +0 -0
  40. {screenforge-0.10.0 → screenforge-0.11.0}/common/__init__.py +0 -0
  41. {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/__init__.py +0 -0
  42. {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/android_adapter.py +0 -0
  43. {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/base_adapter.py +0 -0
  44. {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/ios_adapter.py +0 -0
  45. {screenforge-0.10.0 → screenforge-0.11.0}/common/adapters/web_adapter.py +0 -0
  46. {screenforge-0.10.0 → screenforge-0.11.0}/common/ai_heal.py +0 -0
  47. {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/__init__.py +0 -0
  48. {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/cache_stats.py +0 -0
  49. {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/cache_storage.py +0 -0
  50. {screenforge-0.10.0 → screenforge-0.11.0}/common/cache/embedding_loader.py +0 -0
  51. {screenforge-0.10.0 → screenforge-0.11.0}/common/capabilities.py +0 -0
  52. {screenforge-0.10.0 → screenforge-0.11.0}/common/case_memory.py +0 -0
  53. {screenforge-0.10.0 → screenforge-0.11.0}/common/error_codes.py +0 -0
  54. {screenforge-0.10.0 → screenforge-0.11.0}/common/exceptions.py +0 -0
  55. {screenforge-0.10.0 → screenforge-0.11.0}/common/executor.py +0 -0
  56. {screenforge-0.10.0 → screenforge-0.11.0}/common/failure_diagnosis.py +0 -0
  57. {screenforge-0.10.0 → screenforge-0.11.0}/common/history_manager.py +0 -0
  58. {screenforge-0.10.0 → screenforge-0.11.0}/common/logs.py +0 -0
  59. {screenforge-0.10.0 → screenforge-0.11.0}/common/mcp_server.py +0 -0
  60. {screenforge-0.10.0 → screenforge-0.11.0}/common/observation.py +0 -0
  61. {screenforge-0.10.0 → screenforge-0.11.0}/common/preflight.py +0 -0
  62. {screenforge-0.10.0 → screenforge-0.11.0}/common/progress.py +0 -0
  63. {screenforge-0.10.0 → screenforge-0.11.0}/common/run_reporter.py +0 -0
  64. {screenforge-0.10.0 → screenforge-0.11.0}/common/run_resume.py +0 -0
  65. {screenforge-0.10.0 → screenforge-0.11.0}/common/runtime_modes.py +0 -0
  66. {screenforge-0.10.0 → screenforge-0.11.0}/common/tool_protocol.py +0 -0
  67. {screenforge-0.10.0 → screenforge-0.11.0}/common/visual_fallback.py +0 -0
  68. {screenforge-0.10.0 → screenforge-0.11.0}/common/workflow_schema.py +0 -0
  69. {screenforge-0.10.0 → screenforge-0.11.0}/config/__init__.py +0 -0
  70. {screenforge-0.10.0 → screenforge-0.11.0}/config/env_loader.py +0 -0
  71. {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/dependency_links.txt +0 -0
  72. {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/entry_points.txt +0 -0
  73. {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/requires.txt +0 -0
  74. {screenforge-0.10.0 → screenforge-0.11.0}/screenforge.egg-info/top_level.txt +0 -0
  75. {screenforge-0.10.0 → screenforge-0.11.0}/setup.cfg +0 -0
  76. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_action_mode_observation_stash.py +0 -0
  77. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_ai_autonomous.py +0 -0
  78. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_ai_heal.py +0 -0
  79. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_android_smoke_live.py +0 -0
  80. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_capabilities.py +0 -0
  81. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_case_memory_identity.py +0 -0
  82. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_cli_action_json.py +0 -0
  83. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_codegen_quality.py +0 -0
  84. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_dispatch.py +0 -0
  85. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_doctor_orphan_browser.py +0 -0
  86. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_dom_capture.py +0 -0
  87. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_error_codes.py +0 -0
  88. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_executor.py +0 -0
  89. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_failure_diagnosis.py +0 -0
  90. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_failure_diagnosis_ios.py +0 -0
  91. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_failure_diagnosis_mobile.py +0 -0
  92. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_forward_candidates.py +0 -0
  93. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_inspect_ui_candidates.py +0 -0
  94. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_inspect_ui_envelope.py +0 -0
  95. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_inspect_ui_memory.py +0 -0
  96. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_interaction_actions.py +0 -0
  97. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_ios_smoke_live.py +0 -0
  98. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mcp_execute_observation.py +0 -0
  99. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mcp_execute_parity_live.py +0 -0
  100. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mcp_ref_cache.py +0 -0
  101. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_mcp_tool_result.py +0 -0
  102. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_ml_optional.py +0 -0
  103. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_observation.py +0 -0
  104. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_observation_action_parity.py +0 -0
  105. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_parser.py +0 -0
  106. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_playground_app.py +0 -0
  107. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_playground_sink.py +0 -0
  108. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_playground_sink_integration.py +0 -0
  109. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_cases.py +0 -0
  110. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_conftest_gating.py +0 -0
  111. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_filmstrip.py +0 -0
  112. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_html.py +0 -0
  113. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_json.py +0 -0
  114. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_locate_frame.py +0 -0
  115. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_patching.py +0 -0
  116. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_recorder.py +0 -0
  117. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_screenshot.py +0 -0
  118. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_review_smoke_live.py +0 -0
  119. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_run_reporter.py +0 -0
  120. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_run_resume.py +0 -0
  121. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_runtime_modes.py +0 -0
  122. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_screenshot_annotator.py +0 -0
  123. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_shared_observation_stash.py +0 -0
  124. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_shorthand.py +0 -0
  125. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_tool_protocol_diagnosis.py +0 -0
  126. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_utils_ios.py +0 -0
  127. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_utils_web.py +0 -0
  128. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_utils_xml.py +0 -0
  129. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_visual_fallback.py +0 -0
  130. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_web_adapter.py +0 -0
  131. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_web_dom_complex_live.py +0 -0
  132. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_web_smoke_live.py +0 -0
  133. {screenforge-0.10.0 → screenforge-0.11.0}/tests/test_workflow_mode_observation_stash.py +0 -0
  134. {screenforge-0.10.0 → screenforge-0.11.0}/utils/__init__.py +0 -0
  135. {screenforge-0.10.0 → screenforge-0.11.0}/utils/screenshot_annotator.py +0 -0
  136. {screenforge-0.10.0 → screenforge-0.11.0}/utils/utils_ios.py +0 -0
  137. {screenforge-0.10.0 → screenforge-0.11.0}/utils/utils_web.py +0 -0
  138. {screenforge-0.10.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.10.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
@@ -0,0 +1 @@
1
+ __version__ = "0.11.0"
@@ -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
- os.waitpid(pid, 0)
147
+ _reap_with_timeout(pid, timeout=10.0)
124
148
  except OSError:
125
149
  try:
126
150
  os.kill(pid, signal.SIGINT)
@@ -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
- # 如果动作不需要特定元素 (比如 answer 或某些全局操作),直接放行
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 = client.chat.completions.create(
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 = active_client.chat.completions.create(
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 = active_client.chat.completions.create(
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)
@@ -51,12 +51,17 @@ DEFAULT_TIMEOUT = float(os.getenv("DEFAULT_TIMEOUT", 30.0))
51
51
  # ==========================================
52
52
  # 用户填入各环境/平台的 App 包名或 URL 后,launch_app 会自动拉起;默认留空=不自动启动。
53
53
  # 这是文档化的用户旋钮(见 docs/capability-matrix.md),不是死代码。
54
+ # Auto-launch target per (env, platform). Env-driven so a pip-installed user can
55
+ # set an Android package / iOS bundle / web start-URL without editing the source.
56
+ # Empty (the default) means "don't auto-launch" — correct for web (the brain
57
+ # issues goto) and for driving an already-open mobile app. Env vars follow the
58
+ # pattern APP_TARGET_<ENV>_<PLATFORM>, e.g. APP_TARGET_DEV_ANDROID=com.foo.bar.
54
59
  APP_ENV_CONFIG = {
55
- "dev": {
56
- "android": "",
57
- "ios": "",
58
- "web": "",
59
- },
60
+ env: {
61
+ platform: os.getenv(f"APP_TARGET_{env.upper()}_{platform.upper()}", "")
62
+ for platform in ("android", "ios", "web")
63
+ }
64
+ for env in ("dev", "prod", "us_dev", "us_prod")
60
65
  }
61
66
 
62
67
  # ==========================================
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "screenforge"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  description = "AI-driven cross-platform UI automation engine with test script generation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenforge
3
- Version: 0.10.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
@@ -73,6 +73,7 @@ tests/test_capabilities.py
73
73
  tests/test_case_memory_identity.py
74
74
  tests/test_cli_action_json.py
75
75
  tests/test_codegen_quality.py
76
+ tests/test_config_app_target.py
76
77
  tests/test_dispatch.py
77
78
  tests/test_doctor_orphan_browser.py
78
79
  tests/test_dom_capture.py
@@ -113,6 +114,7 @@ tests/test_run_reporter.py
113
114
  tests/test_run_resume.py
114
115
  tests/test_runtime_modes.py
115
116
  tests/test_screenshot_annotator.py
117
+ tests/test_session_reap.py
116
118
  tests/test_shared_observation_stash.py
117
119
  tests/test_shorthand.py
118
120
  tests/test_tool_protocol_diagnosis.py
@@ -0,0 +1,210 @@
1
+ """Tests for common/ai.py — AIBrain action decision and cache interaction."""
2
+
3
+ import json
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+
9
+ @pytest.fixture
10
+ def mock_config(monkeypatch):
11
+ monkeypatch.setattr("config.config.OPENAI_API_KEY", "test-key")
12
+ monkeypatch.setattr("config.config.OPENAI_BASE_URL", "http://localhost")
13
+ monkeypatch.setattr("config.config.VISION_API_KEY", "test-key")
14
+ monkeypatch.setattr("config.config.VISION_BASE_URL", "http://localhost")
15
+ monkeypatch.setattr("config.config.VISION_MODEL_NAME", "gpt-4o")
16
+ monkeypatch.setattr("config.config.MODEL_NAME", "gpt-4o")
17
+ monkeypatch.setattr("config.config.CACHE_DIR", "/tmp/test_cache")
18
+ monkeypatch.setattr("config.config.CACHE_ENABLED", False)
19
+ monkeypatch.setattr("config.config.CACHE_TTL_DAYS", 1)
20
+ monkeypatch.setattr("config.config.CACHE_MAX_SIZE_MB", 10)
21
+
22
+
23
+ @pytest.fixture
24
+ def brain(mock_config):
25
+ with patch("common.ai.OpenAI") as mock_openai_cls:
26
+ mock_openai_cls.side_effect = [MagicMock(), MagicMock()]
27
+ from common.ai import AIBrain
28
+ return AIBrain()
29
+
30
+
31
+ class TestVerifyLocatorInUi:
32
+ def test_no_locator_type_passes(self, brain):
33
+ decision = {"action": "swipe", "locator_type": "", "locator_value": ""}
34
+ assert brain._verify_locator_in_ui(decision, {"ui_elements": []}) is True
35
+
36
+ def test_text_locator_found(self, brain):
37
+ decision = {"locator_type": "text", "locator_value": "Login"}
38
+ ui = {"ui_elements": [{"text": "Login", "id": ""}]}
39
+ assert brain._verify_locator_in_ui(decision, ui) is True
40
+
41
+ def test_text_locator_not_found(self, brain):
42
+ decision = {"locator_type": "text", "locator_value": "Login"}
43
+ ui = {"ui_elements": [{"text": "Register", "id": ""}]}
44
+ assert brain._verify_locator_in_ui(decision, ui) is False
45
+
46
+ def test_id_locator_found(self, brain):
47
+ decision = {"locator_type": "resourceId", "locator_value": "btn_submit"}
48
+ ui = {"ui_elements": [{"text": "", "id": "btn_submit"}]}
49
+ assert brain._verify_locator_in_ui(decision, ui) is True
50
+
51
+ def test_description_locator_found(self, brain):
52
+ decision = {"locator_type": "description", "locator_value": "Close dialog"}
53
+ ui = {"ui_elements": [{"desc": "Close dialog", "text": ""}]}
54
+ assert brain._verify_locator_in_ui(decision, ui) is True
55
+
56
+ def test_css_locator_passthrough(self, brain):
57
+ # Empty tree → nothing to verify against → pass through (don't discard).
58
+ decision = {"locator_type": "css", "locator_value": "#btn"}
59
+ ui = {"ui_elements": []}
60
+ assert brain._verify_locator_in_ui(decision, ui) is True
61
+
62
+ def test_css_id_present_is_accepted(self, brain):
63
+ decision = {"locator_type": "css", "locator_value": "#username"}
64
+ ui = {"ui_elements": [{"id": "username", "class": "input"}]}
65
+ assert brain._verify_locator_in_ui(decision, ui) is True
66
+
67
+ def test_css_id_absent_on_a_page_that_has_ids_is_rejected(self, brain):
68
+ # P1: L2 is a GLOBAL cross-page cache. A `#username` decision learned on
69
+ # page A must NOT replay on page B where ids exist but none match — the
70
+ # old allowlist waved ALL css through, so this returned True (wrong).
71
+ decision = {"locator_type": "css", "locator_value": "#username"}
72
+ ui = {"ui_elements": [{"id": "search", "class": "input"}]}
73
+ assert brain._verify_locator_in_ui(decision, ui) is False
74
+
75
+ def test_css_id_passes_through_when_tree_carries_no_ids(self, brain):
76
+ # Can't confirm presence or absence → pass through, never false-reject.
77
+ decision = {"locator_type": "css", "locator_value": "#username"}
78
+ ui = {"ui_elements": [{"text": "Login", "class": "button"}]}
79
+ assert brain._verify_locator_in_ui(decision, ui) is True
80
+
81
+ def test_css_name_attribute_verified(self, brain):
82
+ present = {"locator_type": "css", "locator_value": '[name="email"]'}
83
+ absent = {"locator_type": "css", "locator_value": '[name="email"]'}
84
+ ui = {"ui_elements": [{"name": "email", "class": "input"}]}
85
+ other = {"ui_elements": [{"name": "phone", "class": "input"}]}
86
+ assert brain._verify_locator_in_ui(present, ui) is True
87
+ assert brain._verify_locator_in_ui(absent, other) is False
88
+
89
+ def test_css_complex_selector_passes_through(self, brain):
90
+ # A selector the compressed tree can't resolve (class/tag/descendant)
91
+ # is unverifiable → pass through rather than discard a valid hit.
92
+ decision = {"locator_type": "css", "locator_value": "div.card > button.primary"}
93
+ ui = {"ui_elements": [{"id": "search", "class": "button"}]}
94
+ assert brain._verify_locator_in_ui(decision, ui) is True
95
+
96
+ @pytest.mark.parametrize(
97
+ "selector",
98
+ [
99
+ "#login-form input", # descendant
100
+ "#login-form > .btn", # child combinator
101
+ "#username.form-control", # id + class compound
102
+ '#login-form input[name="u"]', # descendant + attribute
103
+ "#a, #b", # selector list
104
+ ],
105
+ )
106
+ def test_css_compound_selector_starting_with_id_passes_through(self, brain, selector):
107
+ # A compound selector only STARTS with #id — the rest narrows to a
108
+ # descendant/class. Comparing the whole string against `id` values always
109
+ # fails, which DISCARDS a valid L2 hit and forces a fresh paid LLM call —
110
+ # the opposite of why this verification exists. Unverifiable → pass through.
111
+ ui = {"ui_elements": [{"id": "login-form", "class": "form"}]}
112
+ assert brain._verify_locator_in_ui({"locator_type": "css", "locator_value": selector}, ui) is True
113
+
114
+ @pytest.mark.parametrize(
115
+ ("raw_id", "selector"),
116
+ [
117
+ ("user.email", r"#user\.email"), # Angular/JSF-style dotted id
118
+ ("form:submit", r"#form\:submit"), # JSF colon id
119
+ ("2fa_code", "#\\32 fa_code"), # leading digit, CSS.escape form
120
+ ],
121
+ )
122
+ def test_css_escaped_id_selector_is_not_false_rejected(self, brain, raw_id, selector):
123
+ # executor._escape_css_ident backslash-escapes special chars, so the
124
+ # selector text never equals the raw `id` in the tree. A literal compare
125
+ # rejects a locator whose element is RIGHT THERE. Must not return False.
126
+ ui = {"ui_elements": [{"id": raw_id, "class": "input"}]}
127
+ assert brain._verify_locator_in_ui({"locator_type": "css", "locator_value": selector}, ui) is True
128
+
129
+ def test_css_escaped_name_selector_is_not_false_rejected(self, brain):
130
+ # Same for [name="..."]: a quote in the name is escaped when built.
131
+ ui = {"ui_elements": [{"name": 'we"ird', "class": "input"}]}
132
+ decision = {"locator_type": "css", "locator_value": '[name="we\\"ird"]'}
133
+ assert brain._verify_locator_in_ui(decision, ui) is True
134
+
135
+ def test_css_plain_id_rejection_still_works(self, brain):
136
+ # The safety net must stay armed for the simple case it was built for.
137
+ ui = {"ui_elements": [{"id": "search", "class": "input"}]}
138
+ decision = {"locator_type": "css", "locator_value": "#username"}
139
+ assert brain._verify_locator_in_ui(decision, ui) is False
140
+
141
+
142
+ class TestGetAction:
143
+ def _make_llm_response(self, content: str):
144
+ response = MagicMock()
145
+ response.choices = [MagicMock()]
146
+ response.choices[0].message.content = content
147
+ return response
148
+
149
+ def test_returns_parsed_result(self, brain):
150
+ result_json = json.dumps({"result": {"action": "click", "locator_type": "text", "locator_value": "Login"}})
151
+ brain.text_client.chat.completions.create.return_value = self._make_llm_response(result_json)
152
+
153
+ result = brain.get_action("click login button", '{"ui_elements": []}', "web", skip_cache=True)
154
+ assert result["action"] == "click"
155
+ assert result["locator_value"] == "Login"
156
+
157
+ def test_handles_markdown_wrapped_json(self, brain):
158
+ content = '```json\n{"result": {"action": "input", "locator_type": "css", "locator_value": "#email"}}\n```'
159
+ brain.text_client.chat.completions.create.return_value = self._make_llm_response(content)
160
+
161
+ result = brain.get_action("type in email", '{"ui_elements": []}', "web", skip_cache=True)
162
+ assert result["action"] == "input"
163
+
164
+ def test_returns_empty_on_parse_failure(self, brain):
165
+ brain.text_client.chat.completions.create.return_value = self._make_llm_response("not json at all")
166
+
167
+ result = brain.get_action("do something", '{"ui_elements": []}', "web", skip_cache=True)
168
+ assert result == {}
169
+
170
+ def test_returns_empty_on_api_error(self, brain):
171
+ brain.text_client.chat.completions.create.side_effect = Exception("connection timeout")
172
+
173
+ result = brain.get_action("do something", '{"ui_elements": []}', "web", skip_cache=True)
174
+ assert result == {}
175
+
176
+ def test_requests_json_mode_when_supported(self, brain):
177
+ result_json = json.dumps({"result": {"action": "click", "locator_type": "text", "locator_value": "Login"}})
178
+ brain.text_client.chat.completions.create.return_value = self._make_llm_response(result_json)
179
+
180
+ brain.get_action("click login", '{"ui_elements": []}', "web", skip_cache=True)
181
+ _, kwargs = brain.text_client.chat.completions.create.call_args
182
+ assert kwargs.get("response_format") == {"type": "json_object"}
183
+
184
+ def test_falls_back_when_endpoint_rejects_json_mode(self, brain):
185
+ import common.ai as ai_mod
186
+ ai_mod._JSON_MODE_UNSUPPORTED.clear()
187
+ result_json = json.dumps({"result": {"action": "click", "locator_type": "text", "locator_value": "Go"}})
188
+ good = self._make_llm_response(result_json)
189
+
190
+ def create(**kwargs):
191
+ if "response_format" in kwargs:
192
+ raise Exception("400: response_format is not supported by this model")
193
+ return good
194
+
195
+ brain.text_client.chat.completions.create.side_effect = create
196
+ result = brain.get_action("click go", '{"ui_elements": []}', "web", skip_cache=True)
197
+ assert result["action"] == "click" # fallback (no response_format) succeeded
198
+ ai_mod._JSON_MODE_UNSUPPORTED.clear()
199
+
200
+ def test_uses_vision_client_when_screenshot_provided(self, brain):
201
+ result_json = json.dumps({"result": {"action": "click", "locator_type": "text", "locator_value": "OK"}})
202
+ brain.vision_client.chat.completions.create.return_value = self._make_llm_response(result_json)
203
+
204
+ result = brain.get_action(
205
+ "click ok", '{"ui_elements": []}', "web",
206
+ screenshot_base64="base64data", skip_cache=True,
207
+ )
208
+ assert result["action"] == "click"
209
+ brain.vision_client.chat.completions.create.assert_called_once()
210
+ brain.text_client.chat.completions.create.assert_not_called()