android-midscene-automation 0.2.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (525) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +63 -1
  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 +10 -8
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +13 -1
  507. package/src/App.vue +72 -23
  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 +369 -186
  525. package/src/types.ts +1 -1
@@ -0,0 +1,1243 @@
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
+ """Plan-driven checkpoint scheduling, the append-only verdict ledger, and exit
16
+ settlement bookkeeping.
17
+
18
+ Checkpoint processing preserves three invariants:
19
+
20
+ 1. **Append-only history**: the Operator's context is never rolled back or
21
+ switched; verdicts influence the future (status flips forward, findings
22
+ injected) but never rewrite the past.
23
+ 2. **Release / verdict / wrap-up separation**: fail-open only affects the
24
+ release decision — the verdict value itself (``inconclusive``) is always
25
+ recorded verbatim; assert failures are never repaired but always produce a
26
+ machine-readable test result.
27
+ 3. **Zero side effects inside check tasks**: state mutation happens only at
28
+ harvest points (``execution_check_node``) and at exit settlement.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import json
35
+ import re
36
+ import time
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any, Literal
40
+
41
+ from pydantic import BaseModel, Field
42
+
43
+ from artemis.utils.notes import append_note_content, get_note_file_path
44
+ from artemis.utils.plan_grammar import (
45
+ CHECKBOX_LINE_RE,
46
+ STATUS_ACTIVE,
47
+ STATUS_DONE,
48
+ CheckItem,
49
+ PlanSnapshot,
50
+ apply_finding_lines,
51
+ parse_plan,
52
+ subgoal_hash,
53
+ )
54
+ from third_party.mobile_use.utils.logger import get_logger
55
+
56
+ logger = get_logger(__name__)
57
+
58
+ LEDGER_FILENAME = "check_ledger.jsonl"
59
+ RUN_OUTCOME_FILENAME = "run_outcome.json"
60
+ #: Per-attempt transcript of the Checker's own streamed reasoning (one record
61
+ #: per attempt, appended by the Checker when its run ends). Companion of the
62
+ #: ledger for UI replay: the ledger books verdicts, this file keeps what the
63
+ #: Checker said while reaching them.
64
+ STREAMS_FILENAME = "check_streams.jsonl"
65
+ #: Upper bound on the persisted text of one attempt (all segments together);
66
+ #: the middle is cut out with a marker so both the opening and the conclusion
67
+ #: survive.
68
+ STREAM_TEXT_LIMIT = 20_000
69
+
70
+ #: Event type published on the DataEngine bus (and forwarded over IPC/SSE to the
71
+ #: admin console) for every visible step of the Checker's process.
72
+ CHECKER_EVENT = "checker_event"
73
+ FINAL_CHECKPOINT_ID = "final"
74
+ FINAL_SUBGOAL_TEXT = "Final review against the user's original goal"
75
+
76
+ # --- Config accessors (all fail-safe against a missing execution_setup) --------------
77
+
78
+
79
+ def _setup(ctx) -> Any:
80
+ return getattr(ctx, "execution_setup", None)
81
+
82
+
83
+ def midway_checks_enabled(ctx) -> bool:
84
+ setup = _setup(ctx)
85
+ return bool(setup) and bool(getattr(setup, "midway_checks_enabled", False))
86
+
87
+
88
+ def final_check_enabled(ctx) -> bool:
89
+ setup = _setup(ctx)
90
+ return bool(setup) and bool(getattr(setup, "final_check_enabled", False))
91
+
92
+
93
+ def _setting(ctx, name: str, default):
94
+ setup = _setup(ctx)
95
+ value = getattr(setup, name, None) if setup else None
96
+ return default if value is None else value
97
+
98
+
99
+ # --- Data contracts ------------------------------------------------------------------
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class EvidenceAnchor:
104
+ """Anchors a checkpoint to the evidence available at completion time."""
105
+
106
+ anchor_step_id: str | None
107
+ trigger_ts: float
108
+ plan_text: str
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class PendingCheckpoint:
113
+ """A queued checkpoint: enqueue happens at plan-write time, spawn happens in
114
+ ``execution_check_node`` after the turn's step is recorded."""
115
+
116
+ checkpoint_id: str
117
+ subgoal_text: str
118
+ check_items: tuple[CheckItem, ...]
119
+ plan_text: str
120
+ trigger_ts: float
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class CheckpointRun:
125
+ """One in-flight (or finished) check attempt held in ``ctx.checkpoint_tasks``."""
126
+
127
+ attempt_id: str
128
+ task: asyncio.Task
129
+ checkpoint: PendingCheckpoint
130
+
131
+
132
+ class TestSummary(BaseModel):
133
+ passed: int = 0
134
+ failed: int = 0
135
+ inconclusive: int = 0
136
+ unchecked: int = 0
137
+ failed_items: list[dict] = Field(default_factory=list)
138
+ #: Check lines the user retired mid-run (dropped from the plan under user
139
+ #: guidance): their earlier verdicts are reported here, never as failures.
140
+ retired: int = 0
141
+ retired_items: list[dict] = Field(default_factory=list)
142
+
143
+ @property
144
+ def total(self) -> int:
145
+ return self.passed + self.failed + self.inconclusive + self.unchecked
146
+
147
+
148
+ class RunOutcome(BaseModel):
149
+ """Machine-readable run outcome: goal-achievement axis x test-result axis."""
150
+
151
+ task_status: Literal["completed", "partial", "blocked"]
152
+ tests: TestSummary
153
+
154
+
155
+ # --- Live process events --------------------------------------------------------------
156
+
157
+
158
+ def publish_checker_event(ctx, payload: dict) -> None:
159
+ """Publishes one ``checker_event`` on the DataEngine bus.
160
+
161
+ The event is a *projection* for observers (admin console / SSE): the
162
+ append-only ledger stays the single source of truth, and publishing never
163
+ influences booking or release. Best-effort: a missing engine or a failing
164
+ subscriber is logged and ignored.
165
+ """
166
+ engine = getattr(ctx, "data_engine", None)
167
+ publish = getattr(engine, "_publish", None)
168
+ if not callable(publish):
169
+ return
170
+ now = time.time()
171
+ try:
172
+ # ``timestamp`` mirrors the other bus events so the UI orders checker
173
+ # blocks on the same timeline as steps and traces.
174
+ publish(CHECKER_EVENT, {"ts": now, "timestamp": now, **payload})
175
+ except Exception as e:
176
+ logger.debug(f"Failed to publish checker event: {e}")
177
+
178
+
179
+ def announce_checker_attempt(
180
+ ctx,
181
+ *,
182
+ attempt_id: str | None,
183
+ phase: str,
184
+ checkpoint_id: str,
185
+ subgoal_text: str,
186
+ anchor_step_id: str | None,
187
+ check_items,
188
+ ) -> None:
189
+ """Publishes ``attempt_started`` from inside the Checker's traced scope.
190
+
191
+ Called at the entry of the checker functions so the event can carry the
192
+ Checker's own ``trace_id``: the UI uses it to route the Checker's streamed
193
+ reasoning (``llm_stream``) and tool traces into the attempt's timeline
194
+ block instead of the Operator step that happens to be current.
195
+ """
196
+ if not attempt_id:
197
+ return
198
+ try:
199
+ from artemis.data_engine.context_vars import CURRENT_TRACE_ID
200
+
201
+ trace_id = CURRENT_TRACE_ID.get()
202
+ except (ImportError, LookupError):
203
+ trace_id = None
204
+ if trace_id:
205
+ _attempt_traces(ctx)[attempt_id] = str(trace_id)
206
+ publish_checker_event(
207
+ ctx,
208
+ {
209
+ "event": "attempt_started",
210
+ "phase": phase,
211
+ "attempt_id": attempt_id,
212
+ "checkpoint_id": checkpoint_id,
213
+ "subgoal_text": subgoal_text,
214
+ "anchor_step_id": anchor_step_id,
215
+ "trace_id": str(trace_id) if trace_id else None,
216
+ "items": check_item_payloads(check_items),
217
+ },
218
+ )
219
+
220
+
221
+ def _attempt_traces(ctx) -> dict:
222
+ """attempt_id -> the Checker's trace id for that attempt (set at announce
223
+ time). Persisted on ledger records so the UI backfill can re-attach the
224
+ attempt's tool traces (which carry that trace as parent) to its block."""
225
+ traces = getattr(ctx, "checker_attempt_traces", None)
226
+ if not isinstance(traces, dict):
227
+ traces = {}
228
+ try:
229
+ ctx.checker_attempt_traces = traces
230
+ except (AttributeError, TypeError, ValueError):
231
+ pass
232
+ return traces
233
+
234
+
235
+ def attempt_trace_id(ctx, attempt_id: str | None) -> str | None:
236
+ if not attempt_id:
237
+ return None
238
+ value = _attempt_traces(ctx).get(attempt_id)
239
+ return str(value) if value else None
240
+
241
+
242
+ def verdict_payloads(verdicts) -> list[dict]:
243
+ """Plain-dict projection of verdict objects (pydantic or attribute bags)."""
244
+ out: list[dict] = []
245
+ for v in verdicts or []:
246
+ out.append(
247
+ {
248
+ "item_text": str(getattr(v, "item_text", "") or ""),
249
+ "kind": str(getattr(v, "kind", "") or ""),
250
+ "status": str(getattr(v, "status", "") or ""),
251
+ "evidence": str(getattr(v, "evidence", "") or ""),
252
+ "suggestion": str(getattr(v, "suggestion", "") or ""),
253
+ }
254
+ )
255
+ return out
256
+
257
+
258
+ def check_item_payloads(check_items) -> list[dict]:
259
+ return [
260
+ {"kind": ci.kind, "text": ci.text, "when": getattr(ci, "when", "on_complete")}
261
+ for ci in (check_items or [])
262
+ ]
263
+
264
+
265
+ # --- Ledger (append-only) ------------------------------------------------------------
266
+
267
+
268
+ def ledger_path(base_dir: str | Path) -> Path:
269
+ return Path(base_dir) / LEDGER_FILENAME
270
+
271
+
272
+ def append_ledger_record(base_dir: str | Path, record: dict) -> None:
273
+ """Appends one verdict record. Records are immutable once written; any later
274
+ result for the same item can only be appended as a new record."""
275
+ record = dict(record)
276
+ record.setdefault("ts", time.time())
277
+ path = ledger_path(base_dir)
278
+ try:
279
+ path.parent.mkdir(parents=True, exist_ok=True)
280
+ with path.open("a", encoding="utf-8") as f:
281
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
282
+ except Exception as e:
283
+ logger.error(f"Failed to append check ledger record: {e}")
284
+
285
+
286
+ def read_ledger(base_dir: str | Path) -> list[dict]:
287
+ path = ledger_path(base_dir)
288
+ if not path.exists():
289
+ return []
290
+ records: list[dict] = []
291
+ try:
292
+ for line in path.read_text(encoding="utf-8").splitlines():
293
+ line = line.strip()
294
+ if not line:
295
+ continue
296
+ try:
297
+ records.append(json.loads(line))
298
+ except Exception:
299
+ logger.warning(f"Skipping malformed ledger line: {line[:120]}")
300
+ except Exception as e:
301
+ logger.error(f"Failed to read check ledger: {e}")
302
+ return records
303
+
304
+
305
+ def has_ledger_records(base_dir: str | Path) -> bool:
306
+ path = ledger_path(base_dir)
307
+ try:
308
+ return path.exists() and path.stat().st_size > 0
309
+ except Exception:
310
+ return False
311
+
312
+
313
+ # --- Attempt stream transcripts (append-only, one record per attempt) ----------------
314
+
315
+
316
+ def streams_path(base_dir: str | Path) -> Path:
317
+ return Path(base_dir) / STREAMS_FILENAME
318
+
319
+
320
+ def clamp_stream_segments(
321
+ segments: list[dict], limit: int = STREAM_TEXT_LIMIT
322
+ ) -> tuple[list[dict], int]:
323
+ """Bounds the total text of an attempt's stream segments to ``limit`` chars.
324
+
325
+ The head and the tail of the attempt (in stream order) are kept and the
326
+ middle is replaced by one ``…[N chars truncated]…`` marker, attached to the
327
+ segment where the cut happens. Returns ``(segments, dropped_chars)``;
328
+ segments left with no text are removed.
329
+ """
330
+ texts = [str(s.get("text") or "") for s in segments]
331
+ total = sum(len(t) for t in texts)
332
+ if total <= limit:
333
+ return [dict(s) for s in segments], 0
334
+
335
+ head_end = limit // 2
336
+ tail_start = total - (limit - head_end)
337
+ dropped = tail_start - head_end
338
+ marker = f"\n…[{dropped} chars truncated]…\n"
339
+ out: list[dict] = []
340
+ pos = 0
341
+ marker_done = False
342
+ for seg, text in zip(segments, texts):
343
+ start, end = pos, pos + len(text)
344
+ pos = end
345
+ piece = ""
346
+ if start < head_end:
347
+ piece += text[: max(0, min(len(text), head_end - start))]
348
+ if end > tail_start:
349
+ if not marker_done:
350
+ piece += marker
351
+ marker_done = True
352
+ piece += text[max(0, tail_start - start) :]
353
+ elif start < head_end < end and not marker_done:
354
+ piece += marker
355
+ marker_done = True
356
+ if not piece:
357
+ continue
358
+ out.append({**seg, "text": piece})
359
+ return out, dropped
360
+
361
+
362
+ def append_attempt_stream_record(
363
+ base_dir: str | Path, record: dict, limit: int = STREAM_TEXT_LIMIT
364
+ ) -> None:
365
+ """Appends one attempt's streamed-reasoning transcript.
366
+
367
+ ``record["segments"]`` is a list of ``{execution_id, role, when, text}``
368
+ dicts (``role`` is ``thought`` for the model's reasoning stream and
369
+ ``answer`` for its visible text) in the order the turns started. The text
370
+ is clamped to ``limit`` chars before writing; the record then carries
371
+ ``truncated`` / ``dropped_chars``.
372
+ """
373
+ record = dict(record)
374
+ record.setdefault("ts", time.time())
375
+ segments, dropped = clamp_stream_segments(list(record.get("segments") or []), limit)
376
+ record["segments"] = segments
377
+ record["truncated"] = dropped > 0
378
+ record["dropped_chars"] = dropped
379
+ path = streams_path(base_dir)
380
+ try:
381
+ path.parent.mkdir(parents=True, exist_ok=True)
382
+ with path.open("a", encoding="utf-8") as f:
383
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
384
+ except Exception as e:
385
+ logger.error(f"Failed to append check stream record: {e}")
386
+
387
+
388
+ def read_attempt_streams(base_dir: str | Path) -> list[dict]:
389
+ path = streams_path(base_dir)
390
+ if not path.exists():
391
+ return []
392
+ records: list[dict] = []
393
+ try:
394
+ for line in path.read_text(encoding="utf-8").splitlines():
395
+ line = line.strip()
396
+ if not line:
397
+ continue
398
+ try:
399
+ loaded = json.loads(line)
400
+ except Exception:
401
+ logger.warning(f"Skipping malformed check stream line: {line[:120]}")
402
+ continue
403
+ if isinstance(loaded, dict):
404
+ records.append(loaded)
405
+ except Exception as e:
406
+ logger.error(f"Failed to read check streams: {e}")
407
+ return records
408
+
409
+
410
+ # --- Plan helpers --------------------------------------------------------------------
411
+
412
+
413
+ def _read_plan_text(ctx) -> str:
414
+ if not getattr(ctx, "data_engine", None):
415
+ return ""
416
+ path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
417
+ if not path.exists():
418
+ return ""
419
+ try:
420
+ return path.read_text(encoding="utf-8")
421
+ except Exception as e:
422
+ logger.error(f"Failed to read task plan: {e}")
423
+ return ""
424
+
425
+
426
+ def _subgoal_still_current(ctx, checkpoint_id: str) -> bool:
427
+ """The anchored subgoal's text is unchanged iff an item with the same
428
+ content hash still exists in the current plan."""
429
+ snapshot = parse_plan(_read_plan_text(ctx))
430
+ return any(i.is_top_level and i.key == checkpoint_id for i in snapshot.items)
431
+
432
+
433
+ def revert_subgoal_status(ctx, subgoal_key: str) -> bool:
434
+ """Flips the matching top-level subgoal from ``[x]`` back to ``[/]``.
435
+
436
+ This is a forward-looking state change (never a rollback of history) and is
437
+ written directly to the file, bypassing the wrapped note tools.
438
+ """
439
+ if not getattr(ctx, "data_engine", None):
440
+ return False
441
+ path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
442
+ if not path.exists():
443
+ return False
444
+ try:
445
+ lines = path.read_text(encoding="utf-8").split("\n")
446
+ changed = False
447
+ for idx, line in enumerate(lines):
448
+ match = CHECKBOX_LINE_RE.match(line)
449
+ if not match or match.group("indent"):
450
+ continue
451
+ if subgoal_hash(match.group("text").strip()) == subgoal_key:
452
+ if match.group("status") == STATUS_DONE:
453
+ lines[idx] = re.sub(
454
+ r"^(\s*-\s*\[)" + re.escape(STATUS_DONE) + r"(\])",
455
+ lambda m: m.group(1) + STATUS_ACTIVE + m.group(2),
456
+ line,
457
+ )
458
+ changed = True
459
+ break
460
+ if changed:
461
+ path.write_text("\n".join(lines), encoding="utf-8")
462
+ return changed
463
+ except Exception as e:
464
+ logger.error(f"Failed to revert subgoal status: {e}")
465
+ return False
466
+
467
+
468
+ # --- Verify-finding persistence (four layers) -------------
469
+ #
470
+ # Layer 1: the append-only ledger above (single source of truth — unchanged).
471
+ # Layer 2: `- finding:` plan lines — a single-direction projection rendered by
472
+ # :func:`sync_finding_lines`; harness code never reads them back.
473
+ # Layer 3: the system-authored `checker-<checkpoint_id>` note — full details on
474
+ # first failure, then one appended section per attempt verdict.
475
+ # Layer 4: single-turn ``operator_feedback`` findings (unchanged).
476
+
477
+ #: Reserved note-key prefixes for system-authored checker repair logs. The
478
+ #: model-side note tool wrappers reject writes to these keys. ``checker-`` is
479
+ #: the prefix actually used for storage (a colon is not a legal filename
480
+ #: character on Windows/NTFS); ``checker:`` is reserved alongside it so the
481
+ #: conceptual spelling can never be squatted either.
482
+ CHECKER_NOTE_PREFIXES = ("checker-", "checker:")
483
+
484
+ _FINDING_HEADLINE_EVIDENCE_LIMIT = 160
485
+
486
+
487
+ def checker_note_key(checkpoint_id: str) -> str:
488
+ """Note key of the system-authored detail/repair log for one checkpoint."""
489
+ return f"checker-{checkpoint_id}"
490
+
491
+
492
+ def is_checker_note_key(key: str) -> bool:
493
+ """True when a note key is reserved for system-authored checker logs."""
494
+ return str(key or "").strip().lower().startswith(CHECKER_NOTE_PREFIXES)
495
+
496
+
497
+ def _finding_registry(ctx) -> dict:
498
+ """checkpoint_id -> rendered headline of the unresolved verify finding.
499
+
500
+ Kept as a dynamic context attribute (``ArtemisContext`` allows extras) so
501
+ the registry lives exactly as long as the run.
502
+ """
503
+ registry = getattr(ctx, "checker_findings", None)
504
+ if not isinstance(registry, dict):
505
+ registry = {}
506
+ try:
507
+ ctx.checker_findings = registry
508
+ except (AttributeError, TypeError, ValueError):
509
+ pass
510
+ return registry
511
+
512
+
513
+ def _finding_items(ctx) -> dict:
514
+ """checkpoint_id -> ``{(kind, item_text), ...}`` of the failed verify items
515
+ that produced the headline currently held in :func:`_finding_registry`.
516
+
517
+ Sibling map of the registry (same lifetime, same keys) so a headline can
518
+ be retired when user guidance withdraws every item behind it.
519
+ """
520
+ items = getattr(ctx, "checker_finding_items", None)
521
+ if not isinstance(items, dict):
522
+ items = {}
523
+ try:
524
+ ctx.checker_finding_items = items
525
+ except (AttributeError, TypeError, ValueError):
526
+ pass
527
+ return items
528
+
529
+
530
+ def _register_finding(ctx, checkpoint_id: str, verify_failures: list) -> None:
531
+ _finding_registry(ctx)[checkpoint_id] = _render_finding_headline(checkpoint_id, verify_failures)
532
+ _finding_items(ctx)[checkpoint_id] = {
533
+ (str(getattr(v, "kind", "") or ""), str(getattr(v, "item_text", "") or ""))
534
+ for v in verify_failures
535
+ }
536
+
537
+
538
+ def _clear_finding(ctx, checkpoint_id: str) -> bool:
539
+ """Drops a checkpoint's standing headline; True when one was registered."""
540
+ _finding_items(ctx).pop(checkpoint_id, None)
541
+ return _finding_registry(ctx).pop(checkpoint_id, None) is not None
542
+
543
+
544
+ def _retired_checks(ctx) -> set[tuple[str, str]]:
545
+ retired = getattr(ctx, "guidance_retired_checks", None)
546
+ return retired if isinstance(retired, set) else set()
547
+
548
+
549
+ def retire_check_items(ctx, items) -> None:
550
+ """Records ``(kind, text)`` check items the Operator dropped under user
551
+ guidance and withdraws their already-materialized findings.
552
+
553
+ Recording feeds the harvest gate (:func:`_active_verdicts`) and the run
554
+ outcome. Cleanup: every standing headline produced *only* by now-retired
555
+ items is popped and the plan's finding lines are re-projected; a headline
556
+ that still names a live item stays untouched.
557
+ """
558
+ incoming = {(str(kind), str(text)) for kind, text in items}
559
+ if not incoming:
560
+ return
561
+ retired = _retired_checks(ctx) | incoming
562
+ ctx.guidance_retired_checks = retired
563
+ registry = _finding_registry(ctx)
564
+ produced_by = _finding_items(ctx)
565
+ stale = [cid for cid in list(registry) if produced_by.get(cid) and produced_by[cid] <= retired]
566
+ for cid in stale:
567
+ _clear_finding(ctx, cid)
568
+ logger.info(
569
+ f"Checkpoint {cid}: standing finding headline withdrawn — every item"
570
+ " behind it was retired by user guidance."
571
+ )
572
+ if stale:
573
+ sync_finding_lines(ctx)
574
+
575
+
576
+ def reinstate_check_items(ctx, items) -> None:
577
+ """Removes ``(kind, text)`` items from the retired set: the Operator has
578
+ declared them in the plan again, so their verdicts drive side effects
579
+ once more and the run outcome counts them normally. A no-op when none of
580
+ the given items is retired."""
581
+ retired = _retired_checks(ctx)
582
+ if not retired:
583
+ return
584
+ back = {(str(kind), str(text)) for kind, text in items} & retired
585
+ if not back:
586
+ return
587
+ ctx.guidance_retired_checks = retired - back
588
+ logger.info(
589
+ "Check line(s) retired under user guidance are declared again and"
590
+ f" reinstated: {sorted(back)}"
591
+ )
592
+
593
+
594
+ def _active_verdicts(ctx, checkpoint, verdicts: list) -> list:
595
+ """The verdicts still allowed to drive side effects: those whose
596
+ ``(kind, item_text)`` was not retired by user guidance. Ledger recording is
597
+ never filtered — this gate only protects execution state."""
598
+ retired = _retired_checks(ctx)
599
+ if not retired:
600
+ return list(verdicts)
601
+ active: list = []
602
+ dropped: list = []
603
+ for v in verdicts:
604
+ key = (str(getattr(v, "kind", "") or ""), str(getattr(v, "item_text", "") or ""))
605
+ (dropped if key in retired else active).append(v)
606
+ if dropped:
607
+ logger.info(
608
+ f"Checkpoint {checkpoint.checkpoint_id}: {len(dropped)} verdict(s)"
609
+ " recorded but dropped from side effects — their check items were"
610
+ " retired by user guidance: "
611
+ + ", ".join(
612
+ f"[{k}] '{t}'"
613
+ for k, t in ((getattr(v, "kind", ""), getattr(v, "item_text", "")) for v in dropped)
614
+ )
615
+ )
616
+ return active
617
+
618
+
619
+ def _render_finding_headline(checkpoint_id: str, verify_failures: list) -> str:
620
+ first = verify_failures[0]
621
+ evidence = str(getattr(first, "evidence", "") or "").strip()
622
+ if len(evidence) > _FINDING_HEADLINE_EVIDENCE_LIMIT:
623
+ evidence = evidence[: _FINDING_HEADLINE_EVIDENCE_LIMIT - 1] + "…"
624
+ detail = f": {evidence}" if evidence else ""
625
+ return (
626
+ f"verify failed — '{first.item_text}'{detail}"
627
+ f" (details & repair log: note '{checker_note_key(checkpoint_id)}')"
628
+ )
629
+
630
+
631
+ def sync_finding_lines(ctx) -> None:
632
+ """Re-projects the unresolved verify findings into ``task_plan.md``.
633
+
634
+ Deterministic merge-back protection for the ``- finding:`` channel: every
635
+ call strips all finding lines and re-renders one per unresolved checkpoint
636
+ under its subgoal, so a model deletion grows back and a resolved finding
637
+ disappears. Pure projection — nothing is ever parsed back out of the plan.
638
+ """
639
+ if not getattr(ctx, "data_engine", None):
640
+ return
641
+ path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
642
+ if not path.exists():
643
+ return
644
+ try:
645
+ content = path.read_text(encoding="utf-8")
646
+ registry = getattr(ctx, "checker_findings", None)
647
+ findings = dict(registry) if isinstance(registry, dict) else {}
648
+ projected = apply_finding_lines(content, findings)
649
+ if projected != content:
650
+ path.write_text(projected, encoding="utf-8")
651
+ except Exception as e:
652
+ logger.error(f"Failed to project finding lines into task plan: {e}")
653
+
654
+
655
+ def _log_checker_note(ctx, run: CheckpointRun, verdicts: list) -> None:
656
+ """Appends this attempt's verdicts to the checkpoint's system note.
657
+
658
+ The note starts at the FIRST failed verify (full criterion/evidence/
659
+ suggestion detail) and afterwards accumulates one section per attempt —
660
+ including the eventually passing one — as a persistent repair log. It is
661
+ written through the notes storage API directly (never the model tool
662
+ path), stays on disk after resolution, and is discoverable via read_note /
663
+ search_history under the key returned by :func:`checker_note_key`.
664
+ """
665
+ try:
666
+ base_dir = ctx.data_engine.base_dir
667
+ cid = run.checkpoint.checkpoint_id
668
+ key = checker_note_key(cid)
669
+ note_path = get_note_file_path(base_dir, key)
670
+ verify_failures = [v for v in verdicts if v.kind == "verify" and v.status == "failed"]
671
+ if not note_path.exists() and not verify_failures:
672
+ return # the log only starts once a verify criterion has failed
673
+
674
+ blocks: list[str] = []
675
+ if not note_path.exists():
676
+ blocks.append(
677
+ f"# Checker findings — subgoal: {run.checkpoint.subgoal_text}\n"
678
+ "System-authored verification log (append-only). Each section"
679
+ " records one checkpoint attempt's verdicts."
680
+ )
681
+ outcome = "failed" if verify_failures else "passed"
682
+ lines = [f"## attempt {run.attempt_id} — verify {outcome}"]
683
+ for v in verdicts:
684
+ line = f"- [{v.kind}] '{v.item_text}': {v.status} — {v.evidence}"
685
+ suggestion = str(getattr(v, "suggestion", "") or "")
686
+ if suggestion:
687
+ line += f"\n suggestion: {suggestion}"
688
+ lines.append(line)
689
+ blocks.append("\n".join(lines))
690
+ append_note_content(base_dir, key, "\n\n".join(blocks))
691
+ except Exception as e:
692
+ logger.error(f"Failed to append checker note for {run.attempt_id}: {e}")
693
+
694
+
695
+ # --- Queueing (called from _process_plan_write; never spawns) ------------------------
696
+
697
+
698
+ def queue_checkpoints(
699
+ ctx,
700
+ state,
701
+ after: PlanSnapshot,
702
+ new_completions: list[str],
703
+ plan_text: str,
704
+ ) -> None:
705
+ """Enqueues a pending checkpoint for every newly completed top-level subgoal
706
+ that carries ``on_complete`` check items.
707
+
708
+ Spawning is deliberately deferred to ``execution_check_node``: at this
709
+ moment the turn's step is not yet recorded, so an anchor taken here would
710
+ point at the previous turn.
711
+ """
712
+ if not midway_checks_enabled(ctx):
713
+ return
714
+ if state is not None and getattr(state, "user_stop_requested", False):
715
+ return
716
+ items_by_key = {i.key: i for i in after.top_level}
717
+ for text in new_completions:
718
+ key = subgoal_hash(text)
719
+ item = items_by_key.get(key)
720
+ if item is None:
721
+ continue
722
+ check_items = tuple(ci for ci in after.check_items_of(item) if ci.when == "on_complete")
723
+ if not check_items:
724
+ continue
725
+ ctx.pending_checkpoints.append(
726
+ PendingCheckpoint(
727
+ checkpoint_id=key,
728
+ subgoal_text=text,
729
+ check_items=check_items,
730
+ plan_text=plan_text,
731
+ trigger_ts=time.time(),
732
+ )
733
+ )
734
+ logger.info(f"Queued checkpoint for completed subgoal: {text}")
735
+
736
+
737
+ # --- Harvest ------------------------------------------------------------------
738
+
739
+
740
+ def _record_verdicts(
741
+ ctx,
742
+ run: CheckpointRun,
743
+ verdicts: list,
744
+ anchor_step_id: str | None,
745
+ ) -> None:
746
+ base_dir = ctx.data_engine.base_dir
747
+ when_by_text = {(ci.kind, ci.text): ci.when for ci in run.checkpoint.check_items}
748
+ for v in verdicts:
749
+ append_ledger_record(
750
+ base_dir,
751
+ {
752
+ "attempt_id": run.attempt_id,
753
+ "checkpoint_id": run.checkpoint.checkpoint_id,
754
+ "subgoal_text": run.checkpoint.subgoal_text,
755
+ "trace_id": attempt_trace_id(ctx, run.attempt_id),
756
+ "item_text": v.item_text,
757
+ "kind": v.kind,
758
+ "when": when_by_text.get((v.kind, v.item_text), "on_complete"),
759
+ "status": v.status,
760
+ "evidence": v.evidence,
761
+ "suggestion": getattr(v, "suggestion", ""),
762
+ "anchor_step_id": anchor_step_id,
763
+ },
764
+ )
765
+
766
+
767
+ def _annotate_history_chunks(ctx, run: CheckpointRun, verdicts) -> None:
768
+ """Annotate an existing history chunk: a verdict harvested after its subgoal's
769
+ segment was chunk-compressed is appended to that chunk and bumps its
770
+ version. Best-effort — history annotation never gates checkpoint booking."""
771
+ try:
772
+ chunker = getattr(getattr(ctx, "transcript_ledger", None), "chunker", None)
773
+ if chunker is None:
774
+ return
775
+ payload = [
776
+ {
777
+ "kind": getattr(v, "kind", "check"),
778
+ "item_text": getattr(v, "item_text", ""),
779
+ "status": getattr(v, "status", ""),
780
+ "evidence": getattr(v, "evidence", ""),
781
+ }
782
+ for v in verdicts
783
+ ]
784
+ chunker.annotate_from_checkpoint(run.checkpoint.checkpoint_id, payload)
785
+ except Exception as e:
786
+ logger.debug(f"History chunk annotation skipped: {e}")
787
+
788
+
789
+ def _record_attempt_failure(ctx, run: CheckpointRun, status: str, evidence: str) -> None:
790
+ base_dir = ctx.data_engine.base_dir
791
+ for ci in run.checkpoint.check_items:
792
+ append_ledger_record(
793
+ base_dir,
794
+ {
795
+ "attempt_id": run.attempt_id,
796
+ "checkpoint_id": run.checkpoint.checkpoint_id,
797
+ "subgoal_text": run.checkpoint.subgoal_text,
798
+ "trace_id": attempt_trace_id(ctx, run.attempt_id),
799
+ "item_text": ci.text,
800
+ "kind": ci.kind,
801
+ "when": ci.when,
802
+ "status": status,
803
+ "evidence": evidence,
804
+ "anchor_step_id": None,
805
+ },
806
+ )
807
+
808
+
809
+ def harvest_run(
810
+ ctx,
811
+ state,
812
+ run: CheckpointRun,
813
+ *,
814
+ allow_side_effects: bool,
815
+ cancelled_status: str = "superseded",
816
+ anchor_step_id: str | None = None,
817
+ ) -> list[str]:
818
+ """Books a finished (done/cancelled) attempt into the ledger and, when the
819
+ verdict is still applicable, applies its execution-state side effects.
820
+
821
+ Returns Operator-facing findings (injected as ``operator_feedback``). The
822
+ verdict values themselves are never altered by the release decision:
823
+ an errored verify is *released* (fail-open) but *recorded* inconclusive.
824
+ """
825
+ task = run.task
826
+ findings: list[str] = []
827
+ checkpoint = run.checkpoint
828
+
829
+ def _event(status: str, verdicts_payload: list[dict], **extra) -> None:
830
+ publish_checker_event(
831
+ ctx,
832
+ {
833
+ "event": "attempt_finished",
834
+ "phase": "checkpoint",
835
+ "attempt_id": run.attempt_id,
836
+ "checkpoint_id": checkpoint.checkpoint_id,
837
+ "subgoal_text": checkpoint.subgoal_text,
838
+ "trace_id": attempt_trace_id(ctx, run.attempt_id),
839
+ "status": status,
840
+ "verdicts": verdicts_payload,
841
+ "findings": list(findings),
842
+ **extra,
843
+ },
844
+ )
845
+
846
+ def _failure_verdicts(status: str, evidence: str) -> list[dict]:
847
+ return [
848
+ {
849
+ "item_text": ci.text,
850
+ "kind": ci.kind,
851
+ "status": status,
852
+ "evidence": evidence,
853
+ "suggestion": "",
854
+ }
855
+ for ci in checkpoint.check_items
856
+ ]
857
+
858
+ if task.cancelled():
859
+ evidence = (
860
+ "attempt superseded by a newer completion of the same subgoal"
861
+ if cancelled_status == "superseded"
862
+ else "cancelled at exit settlement (settlement timeout)"
863
+ )
864
+ _record_attempt_failure(ctx, run, cancelled_status, evidence)
865
+ _event(cancelled_status, _failure_verdicts(cancelled_status, evidence))
866
+ return findings
867
+
868
+ exc = task.exception()
869
+ if exc is not None:
870
+ status = "inconclusive"
871
+ reason = (
872
+ f"check timed out after {_setting(ctx, 'checkpoint_timeout', 180.0)}s"
873
+ if isinstance(exc, (asyncio.TimeoutError, TimeoutError))
874
+ else f"check raised an exception: {exc}"
875
+ )
876
+ logger.warning(
877
+ f"Checkpoint attempt {run.attempt_id} did not produce a verdict"
878
+ f" ({reason}); releasing (fail-open) but recording inconclusive."
879
+ )
880
+ _record_attempt_failure(ctx, run, status, reason)
881
+ _event("error", _failure_verdicts(status, reason), error=reason)
882
+ return findings
883
+
884
+ report = task.result()
885
+ verdicts = list(getattr(report, "verdicts", []) or [])
886
+ _record_verdicts(ctx, run, verdicts, anchor_step_id)
887
+ _annotate_history_chunks(ctx, run, verdicts)
888
+ # Layer 3: the per-checkpoint repair log accumulates every booked attempt.
889
+ _log_checker_note(ctx, run, verdicts)
890
+
891
+ # Guidance gate: a verdict for a check item the user retired mid-run (the
892
+ # Operator dropped its line under a guidance waiver) is a result for a
893
+ # withdrawn requirement — booked above, never a repair/halt trigger.
894
+ active = _active_verdicts(ctx, checkpoint, verdicts)
895
+ all_retired = bool(verdicts) and not active
896
+
897
+ # Applicability gate for side effects: the verdict must belong to the
898
+ # current attempt of its checkpoint AND the anchored subgoal's text must be
899
+ # unchanged in the current plan AND at least one verdict must still be
900
+ # live. Stale/mismatched/fully retired verdicts are ledger-only.
901
+ applicable = (
902
+ allow_side_effects
903
+ and not all_retired
904
+ and _subgoal_still_current(ctx, checkpoint.checkpoint_id)
905
+ )
906
+ user_stopped = bool(state is not None and getattr(state, "user_stop_requested", False))
907
+
908
+ verify_failures = [v for v in active if v.kind == "verify" and v.status == "failed"]
909
+ assert_failures = [v for v in active if v.kind == "assert" and v.status == "failed"]
910
+
911
+ registry_changed = False
912
+ reverted = False
913
+
914
+ if verify_failures and applicable and not user_stopped:
915
+ max_repairs = int(_setting(ctx, "checkpoint_max_repairs", 2))
916
+ repairs = ctx.checkpoint_repairs.get(checkpoint.checkpoint_id, 0)
917
+ if repairs < max_repairs:
918
+ ctx.checkpoint_repairs[checkpoint.checkpoint_id] = repairs + 1
919
+ reverted = revert_subgoal_status(ctx, checkpoint.checkpoint_id)
920
+ # Layer 2: register the standing plan headline for this unresolved
921
+ # finding; the projection below renders it under the subgoal.
922
+ _register_finding(ctx, checkpoint.checkpoint_id, verify_failures)
923
+ registry_changed = True
924
+ for v in verify_failures:
925
+ suggestion = getattr(v, "suggestion", "") or ""
926
+ findings.append(
927
+ f"[verify failed] Subgoal '{checkpoint.subgoal_text}' —"
928
+ f" criterion '{v.item_text}': {v.evidence}"
929
+ + (f" Suggestion: {suggestion}" if suggestion else "")
930
+ )
931
+ if reverted:
932
+ findings.append(
933
+ f"The subgoal '{checkpoint.subgoal_text}' has been set back to"
934
+ " in-progress ([/]). Address the findings above, then complete"
935
+ " it again."
936
+ )
937
+ else:
938
+ # Quota exhausted: the run is settled for this checkpoint — the
939
+ # standing headline is retired (the ledger and the checker note
940
+ # keep the full record).
941
+ registry_changed = _clear_finding(ctx, checkpoint.checkpoint_id)
942
+ logger.warning(
943
+ f"Checkpoint {checkpoint.checkpoint_id} exhausted its repair"
944
+ f" quota ({max_repairs}); verdict stays failed, no further"
945
+ " repair loop."
946
+ )
947
+ elif not verify_failures and not all_retired:
948
+ # A booked attempt without failed verify criteria resolves (or
949
+ # releases) the checkpoint: its standing headline is retired. (A fully
950
+ # retired report says nothing about the checkpoint — its headline, if
951
+ # any, was already handled by ``retire_check_items``.)
952
+ registry_changed = _clear_finding(ctx, checkpoint.checkpoint_id)
953
+
954
+ if registry_changed:
955
+ sync_finding_lines(ctx)
956
+
957
+ if assert_failures:
958
+ policy = str(_setting(ctx, "assert_failure_policy", "continue"))
959
+ if policy == "halt":
960
+ ctx.assert_halt = True
961
+ logger.warning(
962
+ "Assert failure under 'halt' policy: latching halt flag for exit settlement."
963
+ )
964
+
965
+ # Event payload: ``verdicts`` carries every booked verdict (ledger view);
966
+ # ``applicable`` is False when nothing could take effect — including a
967
+ # report whose check items were all retired by user guidance; ``retired``
968
+ # lists the ``(kind, item_text)`` pairs of the verdicts dropped by that
969
+ # guidance gate (empty when none were).
970
+ _event(
971
+ "done",
972
+ verdict_payloads(verdicts),
973
+ applicable=bool(applicable),
974
+ reverted=bool(reverted),
975
+ retired=[
976
+ {"kind": p["kind"], "item_text": p["item_text"]}
977
+ for p in verdict_payloads(verdicts)
978
+ if (p["kind"], p["item_text"]) in _retired_checks(ctx)
979
+ ],
980
+ repairs_used=int(ctx.checkpoint_repairs.get(checkpoint.checkpoint_id, 0)),
981
+ )
982
+ return findings
983
+
984
+
985
+ def harvest_finished_checkpoints(ctx, state) -> list[str]:
986
+ """Non-blocking harvest: books every ``done()`` attempt, never awaits a
987
+ running one. Returns Operator-facing findings."""
988
+ if not getattr(ctx, "data_engine", None):
989
+ return []
990
+ findings: list[str] = []
991
+ for cid, run in list(ctx.checkpoint_tasks.items()):
992
+ if not run.task.done():
993
+ continue
994
+ del ctx.checkpoint_tasks[cid]
995
+ findings.extend(harvest_run(ctx, state, run, allow_side_effects=True))
996
+ return findings
997
+
998
+
999
+ # --- Spawn after record_step -------------------------------------------------
1000
+
1001
+
1002
+ async def spawn_pending_checkpoints(ctx, state, anchor_step_id: str | None) -> list[str]:
1003
+ """Spawns queued checkpoints against the just-recorded step, honoring the
1004
+ concurrency cap and superseding stale attempts without losing verdicts.
1005
+
1006
+ Returns findings harvested from superseded-but-finished attempts.
1007
+ """
1008
+ findings: list[str] = []
1009
+ if not getattr(ctx, "data_engine", None):
1010
+ return findings
1011
+ if not midway_checks_enabled(ctx):
1012
+ return findings
1013
+ if state is not None and getattr(state, "user_stop_requested", False):
1014
+ return findings
1015
+
1016
+ from artemis.agents.checker.checker import run_checkpoint_check
1017
+
1018
+ max_concurrent = int(_setting(ctx, "max_concurrent_checkpoints", 3))
1019
+ timeout = float(_setting(ctx, "checkpoint_timeout", 180.0))
1020
+ goal = getattr(state, "initial_goal", "") if state is not None else ""
1021
+
1022
+ while ctx.pending_checkpoints and len(ctx.checkpoint_tasks) < max_concurrent:
1023
+ pending: PendingCheckpoint = ctx.pending_checkpoints.pop(0)
1024
+ cid = pending.checkpoint_id
1025
+
1026
+ old_run: CheckpointRun | None = ctx.checkpoint_tasks.pop(cid, None)
1027
+ if old_run is not None:
1028
+ if old_run.task.done():
1029
+ # A finished-but-unharvested attempt: book it first (verdicts,
1030
+ # including failures, are never dropped), ledger-only.
1031
+ findings.extend(harvest_run(ctx, state, old_run, allow_side_effects=False))
1032
+ else:
1033
+ old_run.task.cancel()
1034
+ try:
1035
+ await old_run.task
1036
+ except (asyncio.CancelledError, Exception) as exc:
1037
+ logger.debug(
1038
+ f"Superseded checkpoint attempt {old_run.attempt_id} ended with "
1039
+ f"{exc!r}; harvesting anyway",
1040
+ exc_info=True,
1041
+ )
1042
+ harvest_run(ctx, state, old_run, allow_side_effects=False)
1043
+
1044
+ seq = ctx.checkpoint_attempt_seq.get(cid, 0) + 1
1045
+ ctx.checkpoint_attempt_seq[cid] = seq
1046
+ attempt_id = f"{cid}#{seq}"
1047
+ anchor = EvidenceAnchor(
1048
+ anchor_step_id=anchor_step_id,
1049
+ trigger_ts=pending.trigger_ts,
1050
+ plan_text=pending.plan_text,
1051
+ )
1052
+ task = asyncio.create_task(
1053
+ asyncio.wait_for(
1054
+ run_checkpoint_check(
1055
+ ctx,
1056
+ check_items=pending.check_items,
1057
+ anchor=anchor,
1058
+ goal=goal,
1059
+ subgoal_text=pending.subgoal_text,
1060
+ attempt_id=attempt_id,
1061
+ ),
1062
+ timeout=timeout,
1063
+ )
1064
+ )
1065
+ ctx.checkpoint_tasks[cid] = CheckpointRun(
1066
+ attempt_id=attempt_id, task=task, checkpoint=pending
1067
+ )
1068
+ logger.info(f"Spawned checkpoint attempt {attempt_id} anchored at step {anchor_step_id}")
1069
+
1070
+ return findings
1071
+
1072
+
1073
+ # --- Exit settlement barrier ------------------------------------------
1074
+
1075
+
1076
+ async def settle_all_checkpoints(ctx, state) -> None:
1077
+ """The one intentional blocking wait of the whole flow: collects every
1078
+ outstanding checkpoint before exit, bounded by ``settlement_timeout``.
1079
+ Attempts still running at the deadline are cancelled and recorded
1080
+ ``unchecked`` (settlement timeout). Runs unconditionally — a started check
1081
+ must be booked before exit regardless of the final-check switch."""
1082
+ if not getattr(ctx, "data_engine", None):
1083
+ return
1084
+ runs = list(ctx.checkpoint_tasks.values())
1085
+ ctx.checkpoint_tasks.clear()
1086
+ if not runs:
1087
+ return
1088
+
1089
+ timeout = float(_setting(ctx, "settlement_timeout", 120.0))
1090
+ tasks = [r.task for r in runs]
1091
+ try:
1092
+ _done, pending = await asyncio.wait(tasks, timeout=timeout)
1093
+ except Exception as e:
1094
+ logger.error(f"Settlement wait failed: {e}")
1095
+ pending = {t for t in tasks if not t.done()}
1096
+
1097
+ for t in pending:
1098
+ t.cancel()
1099
+ for t in pending:
1100
+ try:
1101
+ await t
1102
+ except (asyncio.CancelledError, Exception) as exc:
1103
+ logger.debug(
1104
+ f"Cancelled checkpoint attempt ended with {exc!r} at settlement; harvesting anyway",
1105
+ exc_info=True,
1106
+ )
1107
+
1108
+ for run in runs:
1109
+ # Settlement is bookkeeping, not a repair point: side effects off.
1110
+ harvest_run(
1111
+ ctx,
1112
+ state,
1113
+ run,
1114
+ allow_side_effects=False,
1115
+ cancelled_status="unchecked",
1116
+ )
1117
+
1118
+
1119
+ # --- Run outcome ---------------------------------------------------------------------
1120
+
1121
+ _RESOLVABLE = ("passed", "failed", "inconclusive")
1122
+
1123
+
1124
+ def resolve_item_status(kind: str, records: list[dict]) -> str:
1125
+ """Resolves one check item's final status from its ledger records.
1126
+
1127
+ Assert: the first failure is permanent — any failed record makes the item
1128
+ failed. Verify: the latest substantive verdict wins (a repaired-then-passed
1129
+ verify is passed). No substantive record at all -> unchecked.
1130
+ """
1131
+ substantive = [r for r in records if r.get("status") in _RESOLVABLE]
1132
+ if kind == "assert" and any(r.get("status") == "failed" for r in substantive):
1133
+ return "failed"
1134
+ if substantive:
1135
+ return substantive[-1]["status"]
1136
+ return "unchecked"
1137
+
1138
+
1139
+ def compute_test_summary(
1140
+ check_items: list[CheckItem],
1141
+ records: list[dict],
1142
+ retired: set[tuple[str, str]] | None = None,
1143
+ ) -> TestSummary:
1144
+ """Folds the verdict ledger into pass/fail counts over the declared items.
1145
+
1146
+ ``retired`` names check lines the Operator dropped under user guidance; a
1147
+ line that is retired and no longer declared in the plan is reported under
1148
+ ``retired`` with its last recorded verdict, not as a pass or a failure —
1149
+ the user withdrew that requirement.
1150
+ """
1151
+ summary = TestSummary()
1152
+ retired = retired or set()
1153
+ by_item: dict[tuple[str, str], list[dict]] = {}
1154
+ for r in records:
1155
+ by_item.setdefault((str(r.get("kind")), str(r.get("item_text"))), []).append(r)
1156
+
1157
+ seen: set[tuple[str, str]] = set()
1158
+ all_items: list[tuple[str, str]] = []
1159
+ for ci in check_items:
1160
+ sig = (ci.kind, ci.text)
1161
+ if sig not in seen:
1162
+ seen.add(sig)
1163
+ all_items.append(sig)
1164
+ # Items that only ever existed in the ledger (e.g. their plan lines were
1165
+ # legitimately revised by the Planner) still count.
1166
+ for sig in by_item:
1167
+ if sig not in seen:
1168
+ seen.add(sig)
1169
+ all_items.append(sig)
1170
+
1171
+ declared = {(ci.kind, ci.text) for ci in check_items}
1172
+ for kind, text in all_items:
1173
+ status = resolve_item_status(kind, by_item.get((kind, text), []))
1174
+ if (kind, text) in retired and (kind, text) not in declared:
1175
+ summary.retired += 1
1176
+ summary.retired_items.append({"item_text": text, "kind": kind, "last_status": status})
1177
+ continue
1178
+ if status == "passed":
1179
+ summary.passed += 1
1180
+ elif status == "failed":
1181
+ summary.failed += 1
1182
+ failing = [r for r in by_item.get((kind, text), []) if r.get("status") == "failed"]
1183
+ summary.failed_items.append(
1184
+ {
1185
+ "item_text": text,
1186
+ "kind": kind,
1187
+ "evidence": failing[-1].get("evidence", "") if failing else "",
1188
+ }
1189
+ )
1190
+ elif status == "inconclusive":
1191
+ summary.inconclusive += 1
1192
+ else:
1193
+ summary.unchecked += 1
1194
+ return summary
1195
+
1196
+
1197
+ def compute_run_outcome(
1198
+ snapshot: PlanSnapshot,
1199
+ records: list[dict],
1200
+ *,
1201
+ verify_blocked: bool,
1202
+ retired: set[tuple[str, str]] | None = None,
1203
+ ) -> RunOutcome:
1204
+ """Assembles the machine-readable run outcome.
1205
+
1206
+ ``verify_blocked`` marks the "verify unmet and budget exhausted" exit —
1207
+ the goal-achievement axis then reads blocked even though the plan claims
1208
+ completion. Assert failures never change ``task_status``: a finished task
1209
+ with failed assertions is ``completed`` with ``tests.failed > 0``.
1210
+ """
1211
+ tests = compute_test_summary(list(snapshot.all_check_items), records, retired=retired)
1212
+ if verify_blocked:
1213
+ task_status: Literal["completed", "partial", "blocked"] = "blocked"
1214
+ elif snapshot.all_top_level_done:
1215
+ task_status = "completed"
1216
+ else:
1217
+ task_status = "partial"
1218
+ return RunOutcome(task_status=task_status, tests=tests)
1219
+
1220
+
1221
+ def write_run_outcome(base_dir: str | Path, outcome: RunOutcome, extra: dict | None = None) -> None:
1222
+ try:
1223
+ payload = outcome.model_dump()
1224
+ if extra:
1225
+ payload.update(extra)
1226
+ path = Path(base_dir) / RUN_OUTCOME_FILENAME
1227
+ path.parent.mkdir(parents=True, exist_ok=True)
1228
+ path.write_text(
1229
+ json.dumps(payload, ensure_ascii=False, indent=2),
1230
+ encoding="utf-8",
1231
+ )
1232
+ except Exception as e:
1233
+ logger.error(f"Failed to write run outcome: {e}")
1234
+
1235
+
1236
+ def read_run_outcome(base_dir: str | Path) -> dict | None:
1237
+ path = Path(base_dir) / RUN_OUTCOME_FILENAME
1238
+ if not path.exists():
1239
+ return None
1240
+ try:
1241
+ return json.loads(path.read_text(encoding="utf-8"))
1242
+ except Exception:
1243
+ return None