android-midscene-automation 0.2.4 → 1.1.1

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 (525) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +159 -29
  3. package/artemis-main/.dockerignore +15 -0
  4. package/artemis-main/.env.example +34 -0
  5. package/artemis-main/CONTRIBUTING.md +35 -0
  6. package/artemis-main/Dockerfile +68 -0
  7. package/artemis-main/LICENSE +202 -0
  8. package/artemis-main/Makefile +115 -0
  9. package/artemis-main/README.md +322 -0
  10. package/artemis-main/README_CN.md +320 -0
  11. package/artemis-main/apps/__init__.py +15 -0
  12. package/artemis-main/apps/admin_console/__init__.py +19 -0
  13. package/artemis-main/apps/admin_console/core/__init__.py +13 -0
  14. package/artemis-main/apps/admin_console/core/config.py +43 -0
  15. package/artemis-main/apps/admin_console/core/security.py +178 -0
  16. package/artemis-main/apps/admin_console/core/state.py +243 -0
  17. package/artemis-main/apps/admin_console/database/__init__.py +13 -0
  18. package/artemis-main/apps/admin_console/database/connection.py +60 -0
  19. package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
  20. package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
  21. package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
  22. package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
  23. package/artemis-main/apps/admin_console/index.html +3787 -0
  24. package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
  25. package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
  26. package/artemis-main/apps/admin_console/routers/media.py +198 -0
  27. package/artemis-main/apps/admin_console/routers/replay.py +91 -0
  28. package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
  29. package/artemis-main/apps/admin_console/routers/steps.py +109 -0
  30. package/artemis-main/apps/admin_console/routers/stream.py +56 -0
  31. package/artemis-main/apps/admin_console/routers/system.py +761 -0
  32. package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
  33. package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
  34. package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
  35. package/artemis-main/apps/admin_console/server.py +508 -0
  36. package/artemis-main/apps/admin_console/services/__init__.py +13 -0
  37. package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
  38. package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
  39. package/artemis-main/apps/admin_console/services/media_service.py +623 -0
  40. package/artemis-main/apps/admin_console/services/model_service.py +148 -0
  41. package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
  42. package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
  43. package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
  44. package/artemis-main/apps/showcase_ui/README.md +31 -0
  45. package/artemis-main/apps/showcase_ui/angular.json +132 -0
  46. package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
  47. package/artemis-main/apps/showcase_ui/package.json +39 -0
  48. package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
  49. package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
  50. package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
  51. package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
  52. package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
  53. package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
  54. package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
  55. package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
  56. package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
  57. package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
  58. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
  59. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
  60. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
  61. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
  62. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
  63. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
  64. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
  65. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
  66. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
  67. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
  68. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
  69. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
  70. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
  71. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
  72. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
  73. package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
  74. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
  75. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
  76. package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
  77. package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
  78. package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
  79. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
  80. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
  81. package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
  82. package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
  83. package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
  84. package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
  85. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
  86. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
  87. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
  88. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
  89. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
  90. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
  91. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
  92. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
  93. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
  94. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
  95. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
  96. package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
  97. package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
  98. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
  99. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
  100. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
  101. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
  102. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
  103. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
  104. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
  105. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
  106. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
  107. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
  108. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
  109. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
  110. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
  111. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
  112. package/artemis-main/apps/showcase_ui/src/index.html +37 -0
  113. package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
  114. package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
  115. package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
  116. package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
  117. package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
  118. package/artemis-main/artemis/__init__.py +44 -0
  119. package/artemis-main/artemis/__main__.py +18 -0
  120. package/artemis-main/artemis/_legacy_imports.py +94 -0
  121. package/artemis-main/artemis/_version.py +30 -0
  122. package/artemis-main/artemis/agents/__init__.py +17 -0
  123. package/artemis-main/artemis/agents/checker/checker.json +9 -0
  124. package/artemis-main/artemis/agents/checker/checker.py +859 -0
  125. package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
  126. package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
  127. package/artemis-main/artemis/agents/explorer/constants.py +52 -0
  128. package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
  129. package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
  130. package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
  131. package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
  132. package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
  133. package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
  134. package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
  135. package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
  136. package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
  137. package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
  138. package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
  139. package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
  140. package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
  141. package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
  142. package/artemis-main/artemis/agents/flash/runner.py +1167 -0
  143. package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
  144. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
  145. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
  146. package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
  147. package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
  148. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
  149. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
  150. package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
  151. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
  152. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
  153. package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
  154. package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
  155. package/artemis-main/artemis/agents/operator/operator.json +3 -0
  156. package/artemis-main/artemis/agents/operator/operator.py +1394 -0
  157. package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
  158. package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
  159. package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
  160. package/artemis-main/artemis/agents/planner/planner.json +32 -0
  161. package/artemis-main/artemis/agents/planner/planner.py +436 -0
  162. package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
  163. package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
  164. package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
  165. package/artemis-main/artemis/agents/validator/categories.py +33 -0
  166. package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
  167. package/artemis-main/artemis/agents/validator/incidents.py +120 -0
  168. package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
  169. package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
  170. package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
  171. package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
  172. package/artemis-main/artemis/agents/validator/validator.py +188 -0
  173. package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
  174. package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
  175. package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
  176. package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
  177. package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
  178. package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
  179. package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
  180. package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
  181. package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
  182. package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
  183. package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
  184. package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
  185. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
  186. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
  187. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
  188. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
  189. package/artemis-main/artemis/clients/accessibility_client.py +438 -0
  190. package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
  191. package/artemis-main/artemis/config/__init__.py +309 -0
  192. package/artemis-main/artemis/config/agent.py +1098 -0
  193. package/artemis-main/artemis/config/constants.py +151 -0
  194. package/artemis-main/artemis/config/core/__init__.py +24 -0
  195. package/artemis-main/artemis/config/core/engine.py +118 -0
  196. package/artemis-main/artemis/config/core/interpolator.py +78 -0
  197. package/artemis-main/artemis/config/llm.py +267 -0
  198. package/artemis-main/artemis/config/paths.py +244 -0
  199. package/artemis-main/artemis/config/runtime.py +197 -0
  200. package/artemis-main/artemis/config/settings.py +315 -0
  201. package/artemis-main/artemis/constants.py +47 -0
  202. package/artemis-main/artemis/context.py +289 -0
  203. package/artemis-main/artemis/controllers/__init__.py +41 -0
  204. package/artemis-main/artemis/controllers/controller_factory.py +41 -0
  205. package/artemis-main/artemis/controllers/device_controller.py +29 -0
  206. package/artemis-main/artemis/controllers/types.py +37 -0
  207. package/artemis-main/artemis/controllers/unified_controller.py +649 -0
  208. package/artemis-main/artemis/core/__init__.py +21 -0
  209. package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
  210. package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
  211. package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
  212. package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
  213. package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
  214. package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
  215. package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
  216. package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
  217. package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
  218. package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
  219. package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
  220. package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
  221. package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
  222. package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
  223. package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
  224. package/artemis-main/artemis/core/tool_declaration.py +43 -0
  225. package/artemis-main/artemis/core/tool_failure.py +56 -0
  226. package/artemis-main/artemis/data_engine/context_vars.py +24 -0
  227. package/artemis-main/artemis/data_engine/engine.py +1615 -0
  228. package/artemis-main/artemis/data_engine/history_reader.py +142 -0
  229. package/artemis-main/artemis/data_engine/models.py +131 -0
  230. package/artemis-main/artemis/data_engine/storage.py +1245 -0
  231. package/artemis-main/artemis/data_engine/trace.py +727 -0
  232. package/artemis-main/artemis/drivers/__init__.py +48 -0
  233. package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
  234. package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
  235. package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
  236. package/artemis-main/artemis/drivers/android/recorder.py +57 -0
  237. package/artemis-main/artemis/drivers/base.py +290 -0
  238. package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
  239. package/artemis-main/artemis/drivers/factory.py +80 -0
  240. package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
  241. package/artemis-main/artemis/drivers/types.py +67 -0
  242. package/artemis-main/artemis/graph/checkpoints.py +1243 -0
  243. package/artemis-main/artemis/graph/graph.py +1055 -0
  244. package/artemis-main/artemis/graph/perception.py +234 -0
  245. package/artemis-main/artemis/graph/state.py +151 -0
  246. package/artemis-main/artemis/graph/visibility.py +238 -0
  247. package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
  248. package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
  249. package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
  250. package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
  251. package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
  252. package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
  253. package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
  254. package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
  255. package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
  256. package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
  257. package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
  258. package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
  259. package/artemis-main/artemis/interfaces/cli/main.py +109 -0
  260. package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
  261. package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
  262. package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
  263. package/artemis-main/artemis/llm/__init__.py +25 -0
  264. package/artemis-main/artemis/llm/fake_model.py +179 -0
  265. package/artemis-main/artemis/llm/google/__init__.py +57 -0
  266. package/artemis-main/artemis/llm/google/provider.py +185 -0
  267. package/artemis-main/artemis/llm/google/usage.py +148 -0
  268. package/artemis-main/artemis/llm/parser.py +32 -0
  269. package/artemis-main/artemis/llm/reliability.py +267 -0
  270. package/artemis-main/artemis/llm/router.py +389 -0
  271. package/artemis-main/artemis/llm/schemas.py +36 -0
  272. package/artemis-main/artemis/llm/structured.py +196 -0
  273. package/artemis-main/artemis/main.py +58 -0
  274. package/artemis-main/artemis/mcp/__init__.py +20 -0
  275. package/artemis-main/artemis/mcp/action_executor.py +760 -0
  276. package/artemis-main/artemis/mcp/action_manifest.py +320 -0
  277. package/artemis-main/artemis/mcp/action_names.py +172 -0
  278. package/artemis-main/artemis/mcp/action_server.py +202 -0
  279. package/artemis-main/artemis/mcp/action_session.py +245 -0
  280. package/artemis-main/artemis/mcp/action_specs.py +868 -0
  281. package/artemis-main/artemis/mcp/action_types.py +86 -0
  282. package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
  283. package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
  284. package/artemis-main/artemis/mcp/actuators/base.py +113 -0
  285. package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
  286. package/artemis-main/artemis/mcp/adb_server.py +490 -0
  287. package/artemis-main/artemis/mcp/observation.py +133 -0
  288. package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
  289. package/artemis-main/artemis/memory/__init__.py +90 -0
  290. package/artemis-main/artemis/memory/chunking.py +1843 -0
  291. package/artemis-main/artemis/memory/context_policy.py +226 -0
  292. package/artemis-main/artemis/memory/step_capsule.md +73 -0
  293. package/artemis-main/artemis/memory/step_memory.py +281 -0
  294. package/artemis-main/artemis/memory/transcript.py +798 -0
  295. package/artemis-main/artemis/platform/__init__.py +61 -0
  296. package/artemis-main/artemis/platform/base.py +104 -0
  297. package/artemis-main/artemis/platform/darwin.py +184 -0
  298. package/artemis-main/artemis/platform/linux.py +207 -0
  299. package/artemis-main/artemis/platform/windows.py +193 -0
  300. package/artemis-main/artemis/resources/__init__.py +46 -0
  301. package/artemis-main/artemis/runtime/__init__.py +108 -0
  302. package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
  303. package/artemis-main/artemis/runtime/awake_lease.py +283 -0
  304. package/artemis-main/artemis/runtime/awake_service.py +343 -0
  305. package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
  306. package/artemis-main/artemis/runtime/daemon_client.py +422 -0
  307. package/artemis-main/artemis/runtime/device_lock.py +1013 -0
  308. package/artemis-main/artemis/runtime/device_pool.py +478 -0
  309. package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
  310. package/artemis-main/artemis/runtime/process_probe.py +98 -0
  311. package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
  312. package/artemis-main/artemis/runtime/supervisor.py +121 -0
  313. package/artemis-main/artemis/runtime/trace_store.py +375 -0
  314. package/artemis-main/artemis/sdk/__init__.py +26 -0
  315. package/artemis-main/artemis/sdk/agent.py +485 -0
  316. package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
  317. package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
  318. package/artemis-main/artemis/sdk/constants.py +15 -0
  319. package/artemis-main/artemis/sdk/run_outcome.py +58 -0
  320. package/artemis-main/artemis/sdk/types/__init__.py +61 -0
  321. package/artemis-main/artemis/sdk/types/agent.py +89 -0
  322. package/artemis-main/artemis/sdk/utils.py +28 -0
  323. package/artemis-main/artemis/services/llm.py +1040 -0
  324. package/artemis-main/artemis/services/token_meter.py +267 -0
  325. package/artemis-main/artemis/telemetry/__init__.py +35 -0
  326. package/artemis-main/artemis/telemetry/models.py +64 -0
  327. package/artemis-main/artemis/telemetry/replay.py +34 -0
  328. package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
  329. package/artemis-main/artemis/telemetry/tracer.py +85 -0
  330. package/artemis-main/artemis/toolchain/__init__.py +52 -0
  331. package/artemis-main/artemis/toolchain/descriptors.py +92 -0
  332. package/artemis-main/artemis/toolchain/resolver.py +185 -0
  333. package/artemis-main/artemis/tools/base.py +232 -0
  334. package/artemis-main/artemis/tools/command_tool.py +1240 -0
  335. package/artemis-main/artemis/tools/committee_prompts.json +5 -0
  336. package/artemis-main/artemis/tools/committee_tool.py +422 -0
  337. package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
  338. package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
  339. package/artemis-main/artemis/tools/explorer_tool.py +581 -0
  340. package/artemis-main/artemis/tools/history/__init__.py +398 -0
  341. package/artemis-main/artemis/tools/history/replay.py +120 -0
  342. package/artemis-main/artemis/tools/history/screenshot.py +139 -0
  343. package/artemis-main/artemis/tools/history/search.py +563 -0
  344. package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
  345. package/artemis-main/artemis/tools/index.py +47 -0
  346. package/artemis-main/artemis/tools/log_tool.py +108 -0
  347. package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
  348. package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
  349. package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
  350. package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
  351. package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
  352. package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
  353. package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
  354. package/artemis-main/artemis/tools/scratchpad.py +819 -0
  355. package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
  356. package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
  357. package/artemis-main/artemis/tools/video_tool.py +261 -0
  358. package/artemis-main/artemis/tools/wait_tool.py +97 -0
  359. package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
  360. package/artemis-main/artemis/utils/coordinates.py +606 -0
  361. package/artemis-main/artemis/utils/credentials_validator.py +201 -0
  362. package/artemis-main/artemis/utils/cv_canvas.py +189 -0
  363. package/artemis-main/artemis/utils/cython_compat.py +45 -0
  364. package/artemis-main/artemis/utils/element_hit_test.py +80 -0
  365. package/artemis-main/artemis/utils/image_diff.py +335 -0
  366. package/artemis-main/artemis/utils/image_hash.py +80 -0
  367. package/artemis-main/artemis/utils/notes.py +470 -0
  368. package/artemis-main/artemis/utils/ocr_api.py +105 -0
  369. package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
  370. package/artemis-main/artemis/utils/plan_grammar.py +513 -0
  371. package/artemis-main/artemis/utils/python_executor.py +213 -0
  372. package/artemis-main/artemis/utils/startup_progress.py +60 -0
  373. package/artemis-main/artemis/utils/task_tree.py +1174 -0
  374. package/artemis-main/artemis/utils/text.py +37 -0
  375. package/artemis-main/artemis/utils/ui_filter.py +651 -0
  376. package/artemis-main/artemis/utils/video.py +685 -0
  377. package/artemis-main/artemis/utils/visualization.py +802 -0
  378. package/artemis-main/config/artemis.example.jsonc +12 -0
  379. package/artemis-main/mcp_server/README.md +111 -0
  380. package/artemis-main/mcp_server/__init__.py +21 -0
  381. package/artemis-main/mcp_server/__main__.py +20 -0
  382. package/artemis-main/mcp_server/background/__init__.py +15 -0
  383. package/artemis-main/mcp_server/background/task_runner.py +453 -0
  384. package/artemis-main/mcp_server/base.py +49 -0
  385. package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
  386. package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
  387. package/artemis-main/mcp_server/notifiers/base.py +45 -0
  388. package/artemis-main/mcp_server/notifiers/composite.py +87 -0
  389. package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
  390. package/artemis-main/mcp_server/notifiers/file.py +69 -0
  391. package/artemis-main/mcp_server/notifiers/script.py +90 -0
  392. package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
  393. package/artemis-main/mcp_server/rules.md +68 -0
  394. package/artemis-main/mcp_server/server.py +106 -0
  395. package/artemis-main/mcp_server/tools/__init__.py +29 -0
  396. package/artemis-main/mcp_server/tools/device_state.py +107 -0
  397. package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
  398. package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
  399. package/artemis-main/mcp_server/tools/task_manager.py +627 -0
  400. package/artemis-main/mcp_server/tools/task_runner.py +548 -0
  401. package/artemis-main/mcp_server/utils/__init__.py +15 -0
  402. package/artemis-main/mcp_server/utils/device_utils.py +180 -0
  403. package/artemis-main/mcp_server/utils/env_utils.py +65 -0
  404. package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
  405. package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
  406. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
  407. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
  408. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
  409. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
  410. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
  411. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
  412. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
  413. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
  414. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
  415. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
  416. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
  417. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
  418. package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
  419. package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
  420. package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
  421. package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
  422. package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
  423. package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
  424. package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
  425. package/artemis-main/packages/artemis-client/README.md +112 -0
  426. package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
  427. package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
  428. package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
  429. package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
  430. package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
  431. package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
  432. package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
  433. package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
  434. package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
  435. package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
  436. package/artemis-main/pyproject.toml +259 -0
  437. package/artemis-main/pyright-core.json +17 -0
  438. package/artemis-main/scripts/check_dependency_sources.py +68 -0
  439. package/artemis-main/scripts/install_deps.ps1 +520 -0
  440. package/artemis-main/scripts/install_deps.sh +533 -0
  441. package/artemis-main/scripts/quality_ratchet.py +97 -0
  442. package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
  443. package/artemis-main/scripts/run_edge_case_part.py +327 -0
  444. package/artemis-main/scripts/run_ui.py +242 -0
  445. package/artemis-main/scripts/start.ps1 +500 -0
  446. package/artemis-main/scripts/test_all_edge_cases.py +269 -0
  447. package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
  448. package/artemis-main/setup.py +164 -0
  449. package/artemis-main/start.bat +35 -0
  450. package/artemis-main/start.sh +423 -0
  451. package/artemis-main/third_party/mobile_use/LICENSE +190 -0
  452. package/artemis-main/third_party/mobile_use/METADATA +27 -0
  453. package/artemis-main/third_party/mobile_use/NOTICE +12 -0
  454. package/artemis-main/third_party/mobile_use/__init__.py +19 -0
  455. package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
  456. package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
  457. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
  458. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
  459. package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
  460. package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
  461. package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
  462. package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
  463. package/artemis-main/third_party/mobile_use/config/output.py +184 -0
  464. package/artemis-main/third_party/mobile_use/context.py +119 -0
  465. package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
  466. package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
  467. package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
  468. package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
  469. package/artemis-main/third_party/mobile_use/graph.py +54 -0
  470. package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
  471. package/artemis-main/third_party/mobile_use/main.py +130 -0
  472. package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
  473. package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
  474. package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
  475. package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
  476. package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
  477. package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
  478. package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
  479. package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
  480. package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
  481. package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
  482. package/artemis-main/third_party/mobile_use/testing.py +59 -0
  483. package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
  484. package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
  485. package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
  486. package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
  487. package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
  488. package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
  489. package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
  490. package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
  491. package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
  492. package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
  493. package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
  494. package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
  495. package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
  496. package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
  497. package/artemis-main/uv.lock +4908 -0
  498. package/bin/android-midscene-automation.js +1 -5
  499. package/bin/artemis-config.mjs +15 -0
  500. package/bin/artemis-embedded.py +56 -0
  501. package/bin/start-artemis.mjs +30 -0
  502. package/bin/start-dev.mjs +112 -0
  503. package/bin/test_artemis_embedded.py +44 -0
  504. package/package.json +4 -2
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +12 -0
  507. package/src/App.vue +10 -5
  508. package/src/api.ts +5 -0
  509. package/src/appium-recorder/AppiumPage.vue +1 -1
  510. package/src/appium-recorder/components/ComponentInspector.vue +1 -1
  511. package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
  512. package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
  513. package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
  514. package/src/appium-recorder/flow-appearance.ts +4 -4
  515. package/src/components/AppSidebar.vue +8 -7
  516. package/src/components/MidsceneMaintenanceNotice.vue +17 -0
  517. package/src/components/analytics/AnalysisCharts.vue +4 -4
  518. package/src/components/analytics/ReportAnalysis.vue +1 -1
  519. package/src/components/generator/TestCaseFileUpload.vue +1 -1
  520. package/src/components/help/HelpCenter.vue +56 -37
  521. package/src/pages/AiGeneratorPage.vue +1 -1
  522. package/src/pages/ArtemisPage.vue +72 -0
  523. package/src/pages/ConfigPage.vue +18 -19
  524. package/src/style.css +340 -121
  525. package/src/types.ts +1 -1
@@ -0,0 +1,1064 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import base64
16
+ from functools import lru_cache
17
+ import io
18
+ import json
19
+ from pathlib import Path
20
+ from typing import NamedTuple
21
+
22
+ from jinja2 import Environment, StrictUndefined, Template
23
+ from langchain_core.messages import HumanMessage, SystemMessage
24
+
25
+ from artemis.tools.history import HISTORY_TOOL_NAMES
26
+ from PIL import Image
27
+
28
+ from artemis.context import ArtemisContext
29
+ from artemis.graph.state import State
30
+ from artemis.tools.command_tool import (
31
+ _format_long_output_response,
32
+ _is_output_long,
33
+ )
34
+ from artemis.utils.plan_grammar import parse_plan, render_plan_grammar_spec
35
+ from artemis.utils.task_tree import SELF_DESCRIBED_MARKER, action_intent_phrase
36
+ from third_party.mobile_use.utils.logger import get_logger
37
+
38
+ logger = get_logger(__name__)
39
+
40
+
41
+ from artemis.agents.prompt_assembly import render_tool_enum, resolve_available
42
+ from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER
43
+
44
+
45
+ @lru_cache(maxsize=1)
46
+ def load_operator_prompts() -> dict[str, str]:
47
+ """Load and cache the Operator templates from operator.json."""
48
+ prompts_path = Path(__file__).with_name("operator.json")
49
+ return json.loads(prompts_path.read_text(encoding="utf-8"))
50
+
51
+
52
+ # --- Assembled tool references -------------------------------------------------------
53
+ # The operator.json templates carry availability slots rendered by
54
+ # ``apply_operator_prompt_contract``: ``[[ tool_enum(...) ]]`` expands an ordered,
55
+ # backticked enumeration limited to the available tool set, and
56
+ # ``[% if "x" in available_tools %]...[% endif %]`` gates a self-contained teaching
57
+ # segment. An unavailable tool therefore leaves no trace in the prompt at all. The
58
+ # slot delimiters are square-bracketed so the standard ``{{ ... }}`` context
59
+ # variables (initial_goal, plan_and_history, plan_grammar, ...) pass through
60
+ # untouched for the later context render.
61
+ #
62
+ # The orderings below reproduce the historical prompt wording exactly; with the full
63
+ # tool set the rendered output is byte-identical to the pre-assembly literals. See
64
+ # artemis/agents/prompt_assembly.py for the assembly rationale.
65
+
66
+ _PRE_DECISION_HELPER_TOOLS = ("ask_explorer", "ask_diagnoser", "video_analyzer")
67
+ _PRE_DECISION_ADB_TOOLS = ("run_adb_command", "manage_task")
68
+ _PRE_DECISION_MEMORY_TOOLS = (
69
+ "read_note",
70
+ "list_notes",
71
+ "search_history",
72
+ "replay_steps",
73
+ "get_step_screenshot",
74
+ )
75
+ _PRE_DECISION_ALL_TOOLS = (
76
+ _PRE_DECISION_HELPER_TOOLS + _PRE_DECISION_ADB_TOOLS + _PRE_DECISION_MEMORY_TOOLS
77
+ )
78
+
79
+ # The two device-action enumeration slots in operator.json (identical in both
80
+ # templates), in their historical orders. The physical order is the canonical
81
+ # manifest's shell-binding order; the turn-ending order is a prompt property.
82
+ _PHYSICAL_ACTIONS_ORDER = OPERATOR_SHELL_ORDER
83
+ _TURN_ENDING_ORDER = (
84
+ "click",
85
+ "swipe",
86
+ "input_text",
87
+ "long_press",
88
+ "press_key",
89
+ "manage_app",
90
+ "wait_for_delay",
91
+ )
92
+
93
+ #: Tool-loop ceiling per Operator turn (recited in the template, enforced in
94
+ #: ``OperatorNode._invoke_llm_loop``).
95
+ OPERATOR_MAX_TOOL_ITERATIONS = 20
96
+
97
+ #: Every tool name the operator prompt slots can reference. ``available_tools=None``
98
+ #: resolves to this set, preserving the historical output.
99
+ OPERATOR_PROMPT_TOOLSET: frozenset[str] = frozenset(
100
+ _PRE_DECISION_ALL_TOOLS + _PHYSICAL_ACTIONS_ORDER
101
+ )
102
+
103
+ #: Renders the availability slots in operator.json. Square-bracket delimiters keep
104
+ #: the standard ``{{ ... }}`` context placeholders inert during this phase.
105
+ _CONTRACT_ENV = Environment(
106
+ block_start_string="[%",
107
+ block_end_string="%]",
108
+ variable_start_string="[[",
109
+ variable_end_string="]]",
110
+ comment_start_string="[#",
111
+ comment_end_string="#]",
112
+ keep_trailing_newline=True,
113
+ undefined=StrictUndefined,
114
+ )
115
+
116
+
117
+ def apply_operator_prompt_contract(
118
+ prompt_template: str,
119
+ available_tools: frozenset[str] | None = None,
120
+ ) -> str:
121
+ """Renders a template's availability slots against the actually-available tools.
122
+
123
+ Args:
124
+ prompt_template: One of the raw operator.json templates.
125
+ available_tools: Tools that actually exist this run. ``None`` renders with the
126
+ full historical tool set, producing byte-identical output to the
127
+ pre-assembly contract. Unavailable tools disappear from every enumeration
128
+ slot, and instruction segments teaching them are removed wholesale.
129
+ """
130
+ available = frozenset(resolve_available(available_tools, OPERATOR_PROMPT_TOOLSET))
131
+
132
+ def tool_enum(names, final_sep: str | None = None) -> str:
133
+ return render_tool_enum(tuple(names), available, final_sep=final_sep)
134
+
135
+ return _CONTRACT_ENV.from_string(prompt_template).render(
136
+ available_tools=available,
137
+ tool_enum=tool_enum,
138
+ pre_decision_tools=_PRE_DECISION_ALL_TOOLS,
139
+ helper_tools=_PRE_DECISION_HELPER_TOOLS,
140
+ adb_tools=_PRE_DECISION_ADB_TOOLS,
141
+ memory_tools=_PRE_DECISION_MEMORY_TOOLS,
142
+ physical_actions=_PHYSICAL_ACTIONS_ORDER,
143
+ turn_ending_actions=_TURN_ENDING_ORDER,
144
+ )
145
+
146
+
147
+ class PromptBuilder:
148
+ def __init__(self):
149
+ self.system_parts = []
150
+ self.human_parts = []
151
+ self.human_footer = None
152
+ #: Indices into ``human_parts`` of blocks that only matter this turn (see
153
+ #: ``artemis.memory.transcript.EPHEMERAL_BLOCKS_KEY``).
154
+ self.ephemeral_indices: list[int] = []
155
+
156
+ def add_system_text(self, text: str):
157
+ self.system_parts.append(text)
158
+
159
+ def add_human_content(self, content: str | dict, *, ephemeral: bool = False):
160
+ """Append a block to the observation. ``ephemeral=True`` marks it as valid
161
+ for this turn only: the transcript scrub edge deletes it at depth K, so it
162
+ never reaches the frozen history or a chunk capsule."""
163
+ if ephemeral:
164
+ self.ephemeral_indices.append(len(self.human_parts))
165
+ self.human_parts.append(content)
166
+
167
+ def set_human_footer(self, content: str):
168
+ self.human_footer = content
169
+
170
+ def build(self) -> list[SystemMessage | HumanMessage]:
171
+ system_content = "".join(self.system_parts)
172
+ human_content = []
173
+ for p in self.human_parts:
174
+ if isinstance(p, str):
175
+ human_content.append({"type": "text", "text": p})
176
+ else:
177
+ human_content.append(p)
178
+
179
+ if self.human_footer:
180
+ human_content.append({"type": "text", "text": self.human_footer})
181
+
182
+ human = HumanMessage(content=human_content)
183
+ if self.ephemeral_indices:
184
+ from artemis.memory.transcript import mark_ephemeral
185
+
186
+ mark_ephemeral(human, self.ephemeral_indices)
187
+ return [SystemMessage(content=system_content), human]
188
+
189
+
190
+ class PromptComponent:
191
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
192
+ raise NotImplementedError
193
+
194
+
195
+ def resolve_operator_prompt_tools(ctx: ArtemisContext) -> frozenset[str]:
196
+ """The tool set the operator prompt may advertise for this run.
197
+
198
+ Extracted verbatim from the template component so the transcript static
199
+ prefix (M2) and the legacy per-turn render assemble against the identical
200
+ availability set.
201
+ """
202
+ # video_analyzer is bound conditionally (graph.py gates it on
203
+ # video_recording_tools_enabled); the prompt must not advertise it when the
204
+ # tool is not actually available this run.
205
+ available = set(OPERATOR_PROMPT_TOOLSET)
206
+ setup = getattr(ctx, "execution_setup", None)
207
+ if not (setup and getattr(setup, "video_recording_tools_enabled", False)):
208
+ available.discard("video_analyzer")
209
+
210
+ # The history tools need a DataEngine to read, and search_history is also
211
+ # config-gated; the prompt must not advertise a tool that is not bound.
212
+ if getattr(ctx, "data_engine", None) is None:
213
+ available.difference_update(HISTORY_TOOL_NAMES)
214
+ elif not _recall_enabled():
215
+ available.discard("search_history")
216
+
217
+ # Device actions the installed actuator backend does not implement disappear
218
+ # from the prompt in lockstep with their tool declarations.
219
+ actuator = getattr(ctx, "actuator", None)
220
+ if actuator is not None and callable(getattr(actuator, "capabilities", None)):
221
+ try:
222
+ from artemis.mcp.action_manifest import (
223
+ DEVICE_ACTIONS,
224
+ available_device_actions,
225
+ )
226
+
227
+ available -= DEVICE_ACTIONS - available_device_actions(actuator)
228
+ except Exception as e:
229
+ logger.warning(f"Failed to assemble prompt against actuator: {e}")
230
+
231
+ return frozenset(available)
232
+
233
+
234
+ def _operator_grammar_flags(ctx: ArtemisContext) -> tuple[bool, bool, bool]:
235
+ """(midway_checks, final_check, verification_active) for the template render.
236
+
237
+ The two check gates are read separately so the grammar and the check-line
238
+ guidance are worded for what actually runs: with midway checks off there is
239
+ no repair loop to teach, and with both off the check-line grammar never
240
+ enters any prompt.
241
+ """
242
+ setup = getattr(ctx, "execution_setup", None)
243
+ midway = bool(setup and getattr(setup, "midway_checks_enabled", False))
244
+ final = bool(setup and getattr(setup, "final_check_enabled", False))
245
+ # The rejection/finding diagnosis trigger only exists while a mechanism
246
+ # that can produce rejections or findings is active.
247
+ verification_active = (
248
+ midway or final or bool(setup and not getattr(setup, "disable_planner_validation", True))
249
+ )
250
+ return midway, final, verification_active
251
+
252
+
253
+ def _checkpoint_max_repairs(ctx: ArtemisContext) -> int:
254
+ """The midway repair budget recited in the static prompt."""
255
+ setup = getattr(ctx, "execution_setup", None)
256
+ max_repairs = getattr(setup, "checkpoint_max_repairs", None)
257
+ return max_repairs if isinstance(max_repairs, int) else 2
258
+
259
+
260
+ def _grammar_render_context(ctx: ArtemisContext) -> dict:
261
+ """Template variables shared by the static and legacy system renders."""
262
+ midway, final, verification_active = _operator_grammar_flags(ctx)
263
+ return {
264
+ "plan_grammar": render_plan_grammar_spec(midway=midway, final=final),
265
+ "verification_active": verification_active,
266
+ "checks_active": midway or final,
267
+ "midway_checks_active": midway,
268
+ "checkpoint_max_repairs": _checkpoint_max_repairs(ctx),
269
+ }
270
+
271
+
272
+ # --- M2 template split -----------------------------------------------------------
273
+ # The only volatile span of operator.json's main_template is the plan+history
274
+ # section below. The transcript path replaces it with a static pointer so the
275
+ # whole system message becomes a byte-stable S region; the legacy path renders
276
+ # the untouched template and stays byte-identical.
277
+
278
+ #: The volatile section of ``main_template`` (must occur exactly once).
279
+ PLAN_HISTORY_TEMPLATE_SECTION = "## Current Plan & Execution History\n{{ plan_and_history }}"
280
+
281
+ #: Static replacement used by the transcript S region.
282
+ PLAN_HISTORY_STATIC_POINTER = (
283
+ "## Current Plan & Execution History\n"
284
+ "Provided in the conversation that follows: earlier turns carry the raw"
285
+ " step-by-step execution history (a restored-history block precedes them"
286
+ " after a process restart), and each turn's observation message recites"
287
+ " the current task plan."
288
+ )
289
+
290
+
291
+ def render_transcript_static_system(
292
+ prompts: dict,
293
+ ctx: ArtemisContext,
294
+ state: State,
295
+ template_name: str = "main_template",
296
+ ) -> str:
297
+ """Render the transcript path's byte-stable static system prompt (S region).
298
+
299
+ Same template, same availability assembly, and the same render inputs as
300
+ the legacy path — except the volatile plan+history section is swapped for
301
+ a static pointer, so the output never changes across the session.
302
+ """
303
+ prompt_template = prompts.get(template_name)
304
+ if not prompt_template:
305
+ raise KeyError("Failed to format prompt, template not found in operator prompts config.")
306
+ if prompt_template.count(PLAN_HISTORY_TEMPLATE_SECTION) != 1:
307
+ # Hard dependency of the M2 split (redesign §9): without a clean
308
+ # section boundary the S region cannot be byte-stable.
309
+ raise ValueError(
310
+ "operator main_template no longer contains exactly one plan+history"
311
+ " section; the transcript static split cannot be applied."
312
+ )
313
+ static_template = prompt_template.replace(
314
+ PLAN_HISTORY_TEMPLATE_SECTION, PLAN_HISTORY_STATIC_POINTER
315
+ )
316
+
317
+ available = resolve_operator_prompt_tools(ctx)
318
+ static_template = apply_operator_prompt_contract(static_template, available_tools=available)
319
+ return Template(static_template).render(
320
+ initial_goal=state.initial_goal,
321
+ subgoals_status="",
322
+ plan_and_history="",
323
+ unified_history="",
324
+ transcript_history=True,
325
+ max_burst_actions=_max_burst_actions_for_prompt(),
326
+ max_tool_calls=OPERATOR_MAX_TOOL_ITERATIONS,
327
+ **_grammar_render_context(ctx),
328
+ )
329
+
330
+
331
+ def _legacy_elapsed_suffix(ctx: ArtemisContext | None) -> str:
332
+ """`` [T+mm:ss]`` for the legacy observation header (empty without a session clock)."""
333
+ import time
334
+
335
+ from artemis.memory.transcript import format_session_offset
336
+
337
+ engine = getattr(ctx, "data_engine", None)
338
+ start = getattr(engine, "session_start_time", None)
339
+ if not isinstance(start, (int, float)):
340
+ return ""
341
+ return f" [{format_session_offset(time.time() - start)}]"
342
+
343
+
344
+ class TemplatePromptComponent(PromptComponent):
345
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
346
+ prompts = kwargs.get("prompts", {})
347
+ template_name = kwargs.get("template_name", "main_template")
348
+ prompt_template = prompts.get(template_name)
349
+ if not prompt_template:
350
+ raise KeyError(
351
+ "Failed to format prompt, template not found in operator prompts config."
352
+ )
353
+
354
+ available = resolve_operator_prompt_tools(ctx)
355
+
356
+ prompt_template = apply_operator_prompt_contract(prompt_template, available_tools=available)
357
+
358
+ plan_and_history = kwargs.get("plan_and_history", "No plan or history yet.")
359
+
360
+ full_prompt = Template(prompt_template).render(
361
+ initial_goal=state.initial_goal,
362
+ subgoals_status="",
363
+ plan_and_history=plan_and_history,
364
+ unified_history="",
365
+ transcript_history=False,
366
+ max_burst_actions=_max_burst_actions_for_prompt(),
367
+ max_tool_calls=OPERATOR_MAX_TOOL_ITERATIONS,
368
+ **_grammar_render_context(ctx),
369
+ )
370
+
371
+ parts = full_prompt.split("# CURRENT OBSERVATION")
372
+ builder.add_system_text(parts[0] + f"# CURRENT OBSERVATION{_legacy_elapsed_suffix(ctx)}\n")
373
+
374
+ if len(parts) > 1:
375
+ builder.set_human_footer(parts[1])
376
+
377
+
378
+ class ObservationPromptComponent(PromptComponent):
379
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
380
+ latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
381
+ minimal_list = kwargs.get("minimal_list")
382
+
383
+ builder.add_human_content("--- Current Screenshot ---")
384
+ builder.add_human_content(
385
+ {
386
+ "type": "image_url",
387
+ "image_url": {"url": f"data:image/jpeg;base64,{latest_screenshot_b64}"},
388
+ }
389
+ )
390
+ builder.add_human_content(f"--- Visible UI Elements ---\n{minimal_list}")
391
+
392
+
393
+ class PlanRecitationPromptComponent(PromptComponent):
394
+ """Per-turn task-plan recitation for the transcript tail (M2).
395
+
396
+ With the plan+history section moved out of the (now static) system
397
+ message, every observation recites the live task plan; the scrub edge
398
+ strips the copies from older turns at depth 1.
399
+ """
400
+
401
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
402
+ from artemis.memory.transcript import PLAN_RECITATION_MARKER
403
+
404
+ task_plan = kwargs.get("task_plan") or "No task plan yet."
405
+ builder.add_human_content(f"{PLAN_RECITATION_MARKER}\n{task_plan}")
406
+
407
+
408
+ class FeedbackPromptComponent(PromptComponent):
409
+ """Append-only injection of source-tagged findings.
410
+
411
+ Reads ``state.operator_feedback`` (written by ``execution_check_node`` at
412
+ harvest / planner rejection and by exit settlement on a bounce-back).
413
+ Renders nothing when there are no findings — the prompt template itself
414
+ is never switched.
415
+ """
416
+
417
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
418
+ findings = getattr(state, "operator_feedback", None)
419
+ if not findings:
420
+ return
421
+ lines = "\n".join(f"- {f}" for f in findings)
422
+ builder.add_human_content(
423
+ "--- Verification Findings ---\n"
424
+ f"{lines}\n"
425
+ "Each finding is tagged with its source. Checker verdicts"
426
+ " ([verify failed], [final check], [unmet subgoal]) are independent judgments —"
427
+ " address any reverted subgoal accordingly; do not re-litigate"
428
+ " them. [planner] findings are advisory: a lightweight reviewer"
429
+ " had a concern about a plan change that stayed applied — weigh"
430
+ " the reason against your own observations.",
431
+ ephemeral=True,
432
+ )
433
+
434
+
435
+ #: Header of the execution-incident block in the observation tail.
436
+ EXECUTION_INCIDENT_MARKER = "--- Execution Incident (OPEN) ---"
437
+
438
+
439
+ def _max_burst_actions_for_prompt() -> int:
440
+ """The configured fast-action burst ceiling, recited in the operator template."""
441
+ try:
442
+ from artemis.config import load_agent_config
443
+
444
+ return int(load_agent_config().pro.execution.max_burst_actions)
445
+ except Exception:
446
+ return 4
447
+
448
+
449
+ def _last_successful_action_before(steps: list, step_number: int | None) -> tuple[str, int] | None:
450
+ """The most recent step before ``step_number`` whose terminal action was dispatched.
451
+
452
+ Returns ``(clean action description, step number)`` or None. This is the
453
+ Operator's best candidate for the *trigger* of a transient state: the
454
+ action that summoned the UI the failed target belonged to.
455
+ """
456
+ from artemis.utils.task_tree import format_actions_clean
457
+
458
+ candidates = []
459
+ for step in steps or []:
460
+ number = step.get("step_number")
461
+ if not isinstance(number, int):
462
+ continue
463
+ if step_number is not None and number >= step_number:
464
+ continue
465
+ action = step.get("action_taken")
466
+ if not action:
467
+ continue
468
+ result = step.get("last_execution_result")
469
+ if isinstance(result, dict) and result.get("status") not in (None, "dispatched"):
470
+ continue
471
+ candidates.append((number, format_actions_clean(action)))
472
+ if not candidates:
473
+ return None
474
+ number, description = max(candidates, key=lambda c: c[0])
475
+ return description, number
476
+
477
+
478
+ def _incident_target_label(incident: dict) -> str:
479
+ action = incident.get("action") or {}
480
+ # The Operator only ever sees 0–1000 coordinates: the recorded pixel
481
+ # target is the controller's business and is never rendered here.
482
+ normalized = action.get("normalized_coordinates")
483
+ parts = []
484
+ if normalized:
485
+ parts.append(f"normalized {list(normalized)}")
486
+ if action.get("target_text"):
487
+ parts.append(f'"{action["target_text"]}"')
488
+ elif action.get("target_description"):
489
+ parts.append(f'"{action["target_description"]}" {SELF_DESCRIBED_MARKER}')
490
+ return " ".join(parts) if parts else "the recorded target"
491
+
492
+
493
+ def render_execution_incident(incident: dict, steps: list) -> str:
494
+ """The Operator-facing explanation of an open execution incident.
495
+
496
+ Structure (deliberately the same every turn so it reads as one continuing
497
+ incident, not a new alarm): facts -> category-specific evidence. The
498
+ response protocol is stated once, in the static system prompt.
499
+ """
500
+ kind = incident.get("kind") or "exec_error"
501
+ category = str(incident.get("category") or "general")
502
+ consecutive = int(incident.get("consecutive_failures") or 1)
503
+ # Intent form ("launch app 'X'"): the action did not happen, so the
504
+ # past-tense outcome phrase would misreport it as done.
505
+ description = action_intent_phrase(incident.get("action_description") or "the planned action")
506
+ reason = str(incident.get("reason") or "").strip()
507
+ burst_size = int(incident.get("burst_size") or 1)
508
+ index = int(incident.get("action_index") or 0)
509
+ evidence = incident.get("evidence") or {}
510
+ step_number = incident.get("step_number")
511
+ target_label = _incident_target_label(incident)
512
+ prior = _last_successful_action_before(steps, step_number)
513
+
514
+ lines = [EXECUTION_INCIDENT_MARKER]
515
+ opened = f"Opened at Step {step_number}" if step_number else "Opened last turn"
516
+ lines.append(f"{opened}; consecutive failed turns: {consecutive}.")
517
+
518
+ # --- What happened -------------------------------------------------------------
519
+ if burst_size > 1:
520
+ remaining = burst_size - index - 1
521
+ skipped = (
522
+ f" The {remaining} action(s) after it were NOT executed, so the device may"
523
+ " be mid-sequence."
524
+ if remaining > 0
525
+ else ""
526
+ )
527
+ lines.append(
528
+ f"What happened: action {index + 1} of your {burst_size}-action fast burst,"
529
+ f" `{description}`, failed: {reason}.{skipped}"
530
+ )
531
+ elif kind == "safety_net":
532
+ lines.append(
533
+ f"What happened: your planned action `{description}` was NOT executed. The"
534
+ f" pre-execution safety net refused it: {reason}"
535
+ )
536
+ else:
537
+ lines.append(
538
+ f"What happened: your planned action `{description}` could"
539
+ f" not be executed; the device/executor reported: {reason}"
540
+ )
541
+
542
+ # --- Category-specific evidence -------------------------------------------------
543
+ if category == "target_shifted":
544
+ location = evidence.get("new_location")
545
+ bounds = evidence.get("new_bounds")
546
+ where = f" at normalized {location}" if location else ""
547
+ bounds_str = f" (bounds {bounds})" if bounds else ""
548
+ lines.append(
549
+ f"Evidence: the same element still exists but has moved{where}{bounds_str};"
550
+ " the shift exceeded the safety net's auto-correction tolerance."
551
+ )
552
+ elif category == "target_occupied":
553
+ occupant = evidence.get("occupant") or "a different element"
554
+ lines.append(
555
+ f"Evidence: the target position is now covered by {occupant}. Something"
556
+ " appeared on top of your target (dialog, sheet, banner, keyboard, or a"
557
+ " re-laid-out screen)."
558
+ )
559
+ elif category in ("target_disappeared", "pixel_target_disappeared"):
560
+ prior_str = (
561
+ f" Your last dispatched action was `{prior[0]}` (Step {prior[1]});"
562
+ " if the vanished target belonged to a state that action summoned, that action"
563
+ " is the trigger."
564
+ if prior
565
+ else ""
566
+ )
567
+ lines.append(
568
+ "Evidence: the element you saw in the previous screenshot is no longer on"
569
+ f" screen. Its recorded target was {target_label}.{prior_str}"
570
+ )
571
+
572
+ # The response protocol lives in the static system prompt (Execution Incident).
573
+ return "\n".join(lines)
574
+
575
+
576
+ def render_closed_incident(closed: dict) -> str:
577
+ """One-turn notice after an incident closes: settle the original intent."""
578
+ opened = closed.get("step_number")
579
+ closed_at = closed.get("closed_at_step")
580
+ description = action_intent_phrase(closed.get("action_description") or "the blocked action")
581
+ header = (
582
+ f"--- Execution Incident (CLOSED at Step {closed_at}) ---"
583
+ if closed_at
584
+ else "--- Execution Incident (CLOSED) ---"
585
+ )
586
+ opened_str = f" opened at Step {opened}" if opened else ""
587
+ return (
588
+ f"{header}\n"
589
+ f"The incident{opened_str} on `{description}` closed because your last Turn-Ending"
590
+ " Action executed. Settle its original intent against the plan and the live screen:"
591
+ " already served, still pending, or no longer needed."
592
+ )
593
+
594
+
595
+ class ExecutionIncidentPromptComponent(PromptComponent):
596
+ """Renders the open execution incident until a terminal action succeeds,
597
+ then a one-turn CLOSED notice asking the Operator to settle the intent.
598
+
599
+ Reads ``state.open_incident`` / ``state.last_closed_incident`` (both written
600
+ by the Validator). Both blocks are ephemeral: the open block is re-rendered
601
+ every turn the incident stays open, and the step record already carries the
602
+ failed execution result, so older copies add nothing to the history.
603
+ """
604
+
605
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
606
+ incident = getattr(state, "open_incident", None)
607
+ if isinstance(incident, dict) and incident.get("reason"):
608
+ builder.add_human_content(
609
+ render_execution_incident(incident, kwargs.get("steps") or []),
610
+ ephemeral=True,
611
+ )
612
+ return
613
+ closed = getattr(state, "last_closed_incident", None)
614
+ if isinstance(closed, dict) and closed.get("kind"):
615
+ builder.add_human_content(render_closed_incident(closed), ephemeral=True)
616
+
617
+
618
+ class BackgroundTasksPromptComponent(PromptComponent):
619
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
620
+ active_tasks = kwargs.get("active_background_tasks", [])
621
+ if active_tasks:
622
+ lines = [
623
+ "--- Active Background ADB Tasks ---",
624
+ ]
625
+ for task in active_tasks:
626
+ lines.append(
627
+ f"- TaskId: {task['task_id']}\n Command:"
628
+ f" `{task['command']}`\n Cwd: `{task['cwd']}`\n "
629
+ f" TerminalID: `{task['terminal_id']}`\n Accumulated"
630
+ f" Output: {task['output_line_count']} lines of logs"
631
+ )
632
+ builder.add_human_content("\n".join(lines) + "\n")
633
+
634
+ newly_finished_tasks = kwargs.get("newly_finished_tasks", [])
635
+ if newly_finished_tasks:
636
+ lines = [
637
+ "--- NEWLY FINISHED ADB TASKS (Since last step) ---",
638
+ ]
639
+ for task in newly_finished_tasks:
640
+ task_id = task["task_id"]
641
+ command = task["command"]
642
+ status = task["status"]
643
+ output_text = task.get("output_text", "")
644
+
645
+ intro = f"- TaskId: {task_id}\n Command: `{command[:60]}...`\n Status: {status}"
646
+
647
+ if _is_output_long(output_text):
648
+ formatted = _format_long_output_response(task_id, output_text, intro)
649
+ # format_long_output_response is multi-line, let's indent it nicely
650
+ indented = "\n".join(f" {line}" for line in formatted.splitlines())
651
+ lines.append(indented)
652
+ else:
653
+ lines.append(f"{intro}\n Final Output:\n {output_text.strip()}")
654
+ builder.add_human_content("\n".join(lines) + "\n")
655
+
656
+
657
+ _PLAN_NOTE_TOOLS = ("update_note", "save_note", "append_note")
658
+
659
+
660
+ def step_updated_task_plan(step: dict) -> bool:
661
+ """Whether a recorded step's tool calls wrote the ``task_plan`` note."""
662
+ for tc in step.get("tool_calls", []):
663
+ if tc.get("name") not in _PLAN_NOTE_TOOLS:
664
+ continue
665
+ args = tc.get("args", {})
666
+ note_key = args.get("key") or args.get("name")
667
+ if note_key == "task_plan":
668
+ return True
669
+ return False
670
+
671
+
672
+ def unwritten_action_streak(steps: list[dict]) -> int:
673
+ """Count trailing action steps since the last ``task_plan`` write."""
674
+ streak = 0
675
+ for step in reversed(steps or []):
676
+ if step_updated_task_plan(step):
677
+ break
678
+ if step.get("action_taken"):
679
+ streak += 1
680
+ return streak
681
+
682
+
683
+ def _clip(text: str, limit: int = 80) -> str:
684
+ text = " ".join(text.split())
685
+ return text if len(text) <= limit else text[: limit - 1] + "\u2026"
686
+
687
+
688
+ _LEDGER_BOUNCE_TAIL = (
689
+ " with `update_note` (key `task_plan`), then re-issue the action in the same"
690
+ " tool-call list (Live Sub-goal Ledger)."
691
+ )
692
+
693
+
694
+ #: One-line tail notice for the turn after a turn whose replies carried no visible
695
+ #: reasoning text. Thinking-mode models satisfy "reason first" inside their thought
696
+ #: channel and emit bare tool calls; the reminder costs no extra model call.
697
+ REASONING_REMINDER = (
698
+ "--- Reminder ---\n"
699
+ "Your previous turn carried no visible reasoning text (internal thinking is not"
700
+ " shown). This turn, write one short paragraph of plain prose before calling a"
701
+ " Turn-Ending Action: what the screen shows, whether the last action worked, and"
702
+ " what you do next and why."
703
+ )
704
+
705
+
706
+ class ReasoningReminderPromptComponent(PromptComponent):
707
+ """Renders :data:`REASONING_REMINDER` when the previous turn was silent."""
708
+
709
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
710
+ if kwargs.get("previous_turn_silent"):
711
+ builder.add_human_content(REASONING_REMINDER, ephemeral=True)
712
+
713
+
714
+ def render_plan_ledger_bounce(task_plan: str, streak: int, stale_turns: int) -> str | None:
715
+ """Return a plan-ledger validation error, if any."""
716
+ snapshot = parse_plan(task_plan or "")
717
+ if not snapshot.has_top_level or snapshot.all_top_level_done:
718
+ return None
719
+ milestone = snapshot.active_milestone()
720
+ if milestone is None:
721
+ return (
722
+ "Not executed: no milestone is marked in progress. Mark the one you are"
723
+ " executing `[/]` and open its `[/]` sub-goal" + _LEDGER_BOUNCE_TAIL
724
+ )
725
+ leaf = snapshot.active_leaf(milestone)
726
+ if leaf is None:
727
+ return (
728
+ f'Not executed: the active milestone "{_clip(milestone.text)}" has no'
729
+ " in-progress `[/]` sub-goal beneath it. Open one for what you are doing now"
730
+ + _LEDGER_BOUNCE_TAIL
731
+ )
732
+ turns = streak + 1
733
+ if stale_turns > 0 and turns >= stale_turns:
734
+ return (
735
+ f'Not executed: the sub-goal "{_clip(leaf.text)}" has stayed in progress for'
736
+ f" {turns} action turns without any plan change. Settle it (`[x]` / `[!]`) or"
737
+ " record its progress and open the next leaf" + _LEDGER_BOUNCE_TAIL
738
+ )
739
+ return None
740
+
741
+
742
+ class ToolLimitWarningPromptComponent(PromptComponent):
743
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
744
+ if getattr(state, "operator_tool_limit_exceeded", False):
745
+ builder.add_human_content(
746
+ "\nWarning: your last turn used up its tool-call budget without a"
747
+ " Turn-Ending Action. Re-examine the task goal and your plan;"
748
+ " the current approach may not be the right path. If the cause"
749
+ " is unclear, ask_diagnoser can help.",
750
+ ephemeral=True,
751
+ )
752
+
753
+
754
+ #: Header of the user-guidance block (shared by the Flash runner and the Pro
755
+ #: operator; the operator's static prompt explains the channel once).
756
+ USER_GUIDANCE_MARKER = "--- User Guidance ---"
757
+
758
+
759
+ #: Prefix of the persistent user-instruction line (see :func:`render_user_guidance`).
760
+ USER_INSTRUCTION_PREFIX = "User instruction"
761
+
762
+
763
+ class UserGuidance(NamedTuple):
764
+ """The two observation blocks delivering a mid-run user instruction.
765
+
766
+ ``wrapper`` is the per-turn ``--- User Guidance ---`` framing (relay
767
+ notice, rank, release-loop wording): it is marked ephemeral and leaves
768
+ with the recent window. ``body`` is the verbatim instruction as one
769
+ self-contained line; it is added as a regular block so it stays in the
770
+ active window until the turn is chunk-compressed, at which point the
771
+ chunk's ``user_lines`` carry it verbatim. Standing instructions remain
772
+ visible after the turn in which they arrived.
773
+ """
774
+
775
+ body: str
776
+ wrapper: str
777
+
778
+
779
+ def render_user_instruction(instruction: str, *, offset_label: str | None = None) -> str:
780
+ """The persistent one-line body: ``User instruction (T+mm:ss): "..."``.
781
+
782
+ ``offset_label`` is the session-relative time the instruction was
783
+ delivered when the call site has it; omitted otherwise.
784
+ """
785
+ when = f" ({offset_label})" if offset_label else ""
786
+ return f'{USER_INSTRUCTION_PREFIX}{when}: "{instruction}"'
787
+
788
+
789
+ def render_user_guidance(
790
+ instruction: str,
791
+ *,
792
+ release_loop: bool = False,
793
+ has_plan: bool = True,
794
+ offset_label: str | None = None,
795
+ ) -> UserGuidance:
796
+ """The observation blocks delivering a mid-run user instruction.
797
+
798
+ One rendering for both profiles: the instruction is stated as what it is (an
799
+ external interruption from the user, relayed by the system) rather than as
800
+ advice to be weighed, and it explicitly outranks the plan and its check lines.
801
+ Returns ``(body, wrapper)``; callers add ``wrapper`` with ``ephemeral=True``
802
+ and ``body`` without (see :class:`UserGuidance`).
803
+ """
804
+ rank = (
805
+ " It outranks the task plan and its check lines: edit the plan to match,"
806
+ " check lines included."
807
+ if has_plan
808
+ else " It outranks your current milestones: adjust them to match."
809
+ )
810
+ lines = [
811
+ USER_GUIDANCE_MARKER,
812
+ "The user watching this run sent the instruction quoted below; the system"
813
+ " relays it as an external interruption, like a stop signal." + rank,
814
+ "Act on it from this turn: reconcile it with the current screen and recent"
815
+ " history, then choose the next action. The quoted line stays in your"
816
+ " history as a standing instruction until the user sends another.",
817
+ ]
818
+ if release_loop:
819
+ lines.append(
820
+ "The user has explicitly authorized stopping any ongoing monitoring loop;"
821
+ " you may now wrap up and complete the task."
822
+ )
823
+ return UserGuidance(
824
+ body=render_user_instruction(instruction, offset_label=offset_label),
825
+ wrapper=chr(10).join(lines),
826
+ )
827
+
828
+
829
+ def add_user_guidance(builder: PromptBuilder, guidance: UserGuidance) -> None:
830
+ """Adds the two guidance blocks: the wrapper ephemeral, the body persistent."""
831
+ builder.add_human_content(guidance.wrapper, ephemeral=True)
832
+ builder.add_human_content(guidance.body)
833
+
834
+
835
+ class InjectedInstructionPromptComponent(PromptComponent):
836
+ """The mid-run user instruction: an ephemeral wrapper plus the persistent
837
+ verbatim body (:class:`UserGuidance`).
838
+
839
+ The step record also keeps the instruction verbatim (``extra_metadata``),
840
+ which is what the chunk ledger renders once the turn is compressed.
841
+ """
842
+
843
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
844
+ injected = getattr(state, "injected_instruction", None)
845
+ if injected:
846
+ release_loop = bool(getattr(state, "user_stop_requested", False))
847
+ add_user_guidance(
848
+ builder,
849
+ render_user_guidance(
850
+ injected,
851
+ release_loop=release_loop,
852
+ has_plan=True,
853
+ offset_label=_session_offset_label(ctx),
854
+ ),
855
+ )
856
+
857
+
858
+ def _session_offset_label(ctx: ArtemisContext | None) -> str | None:
859
+ """The transcript ledger's ``T+mm:ss`` label when a ledger is attached."""
860
+ ledger = getattr(ctx, "transcript_ledger", None) if ctx is not None else None
861
+ elapsed_label = getattr(ledger, "elapsed_label", None)
862
+ if ledger is None or not callable(elapsed_label):
863
+ return None
864
+ try:
865
+ label = elapsed_label()
866
+ except Exception:
867
+ return None
868
+ return label if isinstance(label, str) and label else None
869
+
870
+
871
+ class ScreenshotSimilarityPromptComponent(PromptComponent):
872
+ """Background check: compare current screen against post-action screenshots of the last few steps.
873
+
874
+ Inject a note if any are identical. This helps detect if the same screen
875
+ keeps reappearing unexpectedly.
876
+ """
877
+
878
+ NUM_STEPS_BACK: int = 3
879
+ MAX_ALLOWED_DIFF_PIXELS: int = 3
880
+ COLOR_TOLERANCE: int = 8
881
+
882
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
883
+ if not ctx or not ctx.data_engine:
884
+ return
885
+ latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
886
+ steps = kwargs.get("steps") or []
887
+ if not latest_screenshot_b64 or not steps:
888
+ return
889
+ # 1. Load current live image and re-encode to JPEG for symmetric compression matching
890
+ try:
891
+ # Strip data URI header if present
892
+ if "," in latest_screenshot_b64:
893
+ latest_screenshot_b64 = latest_screenshot_b64.split(",", 1)[1]
894
+ curr_img_bytes = base64.b64decode(latest_screenshot_b64)
895
+ raw_img = Image.open(io.BytesIO(curr_img_bytes)).convert("RGB")
896
+ # Symmetric in-memory re-encoding to match JPEG compression artifacts
897
+ jpeg_buf = io.BytesIO()
898
+ raw_img.save(jpeg_buf, format="JPEG", quality=75)
899
+ jpeg_buf.seek(0)
900
+ curr_img = Image.open(jpeg_buf).convert("RGB")
901
+ except Exception:
902
+ return
903
+ # 2. Get last steps with a recorded post_image_name or pre_image_name
904
+ history_steps = [s for s in steps if s.get("post_image_name") or s.get("pre_image_name")]
905
+ recent_steps = history_steps[-self.NUM_STEPS_BACK :]
906
+ matched_step_nums = []
907
+ images_dir = Path(ctx.data_engine.global_base_dir) / "images"
908
+ # 3. Compare current image with each past step's post-action screenshot
909
+ for step_rec in recent_steps:
910
+ step_num = step_rec.get("step_number")
911
+ if step_num is None:
912
+ continue
913
+ image_name = step_rec.get("post_image_name") or step_rec.get("pre_image_name")
914
+ if not image_name:
915
+ image_name = step_rec.get("post_image_name")
916
+ if not image_name:
917
+ continue
918
+ image_path = images_dir / f"{image_name}.jpg"
919
+ if not image_path.exists():
920
+ continue
921
+ try:
922
+ past_img = Image.open(image_path).convert("RGB")
923
+
924
+ # Must be same dimensions to compare pixel-by-pixel
925
+ if curr_img.size != past_img.size:
926
+ continue
927
+ # Count differing pixels
928
+ diff_count = self._count_differing_pixels(
929
+ curr_img, past_img, max_allowed=self.MAX_ALLOWED_DIFF_PIXELS
930
+ )
931
+ if diff_count <= self.MAX_ALLOWED_DIFF_PIXELS:
932
+ matched_step_nums.append(str(step_num))
933
+ except Exception:
934
+ continue
935
+ # 4. Inject note if any identical screenshots are found
936
+ if matched_step_nums:
937
+ steps_str = ", ".join(matched_step_nums)
938
+ note_text = f"Note: the screen is unchanged since step {steps_str} (pixel-identical)."
939
+ builder.add_human_content(note_text, ephemeral=True)
940
+
941
+ def _count_differing_pixels(
942
+ self,
943
+ img1: Image.Image,
944
+ img2: Image.Image,
945
+ max_allowed: int | None = None,
946
+ ) -> int:
947
+ """Count pixels that differ between two RGB images.
948
+
949
+ Early-exits if diff_count exceeds max_allowed for performance.
950
+ """
951
+ if max_allowed is None:
952
+ max_allowed = self.MAX_ALLOWED_DIFF_PIXELS
953
+ data1 = img1.getdata()
954
+ data2 = img2.getdata()
955
+ diff_count = 0
956
+ tolerance = self.COLOR_TOLERANCE
957
+ for p1, p2 in zip(data1, data2):
958
+ if (
959
+ abs(p1[0] - p2[0]) > tolerance
960
+ or abs(p1[1] - p2[1]) > tolerance
961
+ or abs(p1[2] - p2[2]) > tolerance
962
+ ):
963
+ diff_count += 1
964
+ if diff_count > max_allowed:
965
+ break
966
+ return diff_count
967
+
968
+
969
+ def _recall_enabled() -> bool:
970
+ """Whether the search_history tool is enabled by configuration."""
971
+ try:
972
+ from artemis.config import load_agent_config
973
+
974
+ return bool(load_agent_config().memory.recall.enabled)
975
+ except Exception:
976
+ return True
977
+
978
+
979
+ class HistoricalStateHintPromptComponent(PromptComponent):
980
+ """Local historical-state hint from stored perceptual hashes (M4).
981
+
982
+ Compares the current screenshot's dHash against the post-action hashes
983
+ stamped on every recorded step (``extra_metadata["post_image_dhash"]``,
984
+ including steps already chunk-compressed out of the visible transcript) —
985
+ an O(n) integer scan, no model call, no historical image bytes. On a
986
+ close match to a step *older* than the recent window it injects a one-line
987
+ hint pointing at ``search_history``.
988
+
989
+ Division of labor with :class:`ScreenshotSimilarityPromptComponent`: that
990
+ component's pixel-exact 3-step look-back covers "stuck on the same
991
+ screen"; this one covers "returned to a much earlier state". When any of
992
+ the last :attr:`RECENT_SILENT_STEPS` steps also matches, this hint stays
993
+ silent so the two never fire on the same regime.
994
+ """
995
+
996
+ #: Matches within this many most-recent steps stay silent (the pixel
997
+ #: same-screen note owns that window).
998
+ RECENT_SILENT_STEPS: int = 3
999
+ #: Upper bound on how many historical steps are scanned (most recent first).
1000
+ SCAN_CAP: int = 500
1001
+
1002
+ async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
1003
+ latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
1004
+ steps = kwargs.get("steps") or []
1005
+ if not latest_screenshot_b64 or len(steps) <= self.RECENT_SILENT_STEPS:
1006
+ return
1007
+
1008
+ max_distance = 8
1009
+ try:
1010
+ from artemis.config import load_agent_config
1011
+
1012
+ transcript_cfg = load_agent_config().memory.transcript
1013
+ if not getattr(transcript_cfg, "similarity_hint", True):
1014
+ return
1015
+ max_distance = int(getattr(transcript_cfg, "similarity_max_distance", 8))
1016
+ except Exception as exc:
1017
+ logger.debug(
1018
+ "Transcript similarity config unavailable; using max_distance=%s: %s",
1019
+ max_distance,
1020
+ exc,
1021
+ exc_info=True,
1022
+ )
1023
+
1024
+ try:
1025
+ from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
1026
+
1027
+ if "," in latest_screenshot_b64:
1028
+ latest_screenshot_b64 = latest_screenshot_b64.split(",", 1)[1]
1029
+ current_hash = dhash_hex(base64.b64decode(latest_screenshot_b64))
1030
+ except Exception:
1031
+ return
1032
+ if not current_hash:
1033
+ return
1034
+
1035
+ def _step_hash(step: dict) -> str | None:
1036
+ meta = step.get("extra_metadata") or {}
1037
+ return meta.get("post_image_dhash") or meta.get("pre_image_dhash")
1038
+
1039
+ # Silence rule first: a close match inside the recent window belongs
1040
+ # to the pixel-level same-screen note, not this hint.
1041
+ for step in steps[-self.RECENT_SILENT_STEPS :]:
1042
+ distance = hamming_distance_hex(current_hash, _step_hash(step))
1043
+ if distance is not None and distance <= max_distance:
1044
+ return
1045
+
1046
+ older_steps = steps[: -self.RECENT_SILENT_STEPS][-self.SCAN_CAP :]
1047
+ best_step_number = None
1048
+ best_distance = None
1049
+ for step in older_steps:
1050
+ distance = hamming_distance_hex(current_hash, _step_hash(step))
1051
+ if distance is None or distance > max_distance:
1052
+ continue
1053
+ if best_distance is None or distance <= best_distance:
1054
+ # <= keeps the most recent step on ties.
1055
+ best_distance = distance
1056
+ best_step_number = step.get("step_number")
1057
+
1058
+ if best_step_number is not None:
1059
+ builder.add_human_content(
1060
+ f"Historical state hint: current screen closely resembles the"
1061
+ f" post-action screen from Step {best_step_number}. Use"
1062
+ " search_history / replay_steps only if its details are needed.",
1063
+ ephemeral=True,
1064
+ )