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,2585 @@
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 json
16
+ import os
17
+ from pathlib import Path
18
+ import shutil
19
+ import sqlite3
20
+ import sys
21
+ import traceback
22
+ from typing import Any
23
+ from uuid import UUID
24
+
25
+ WORKSPACE_ROOT = Path(__file__).parent.parent.resolve()
26
+
27
+ # Try to import settings from artemis
28
+ try:
29
+ from artemis.config import settings
30
+
31
+ TRACES_PATH = Path(settings.TRACES_PATH)
32
+ except ImportError:
33
+ # Fallback to default path if run outside project environment or imports fail
34
+ TRACES_PATH = WORKSPACE_ROOT / "traces"
35
+
36
+ DB_PATH = TRACES_PATH / "data_engine.db"
37
+ IMAGES_DIR = TRACES_PATH / "images"
38
+
39
+ try:
40
+ from artemis.context import ArtemisContext
41
+ from artemis.graph.state import State
42
+ except ImportError:
43
+ ArtemisContext = Any
44
+ State = Any
45
+
46
+ REPLAY_TOOLS_CONFIG = {
47
+ "ask_explorer": {
48
+ "display_name": "Ask Explorer",
49
+ "description": (
50
+ "Visual parsing agent that executes visual ReAct search loops using multi-modal models."
51
+ ),
52
+ "module": "artemis.tools.explorer_tool",
53
+ "function": "_run_explorer_logic",
54
+ "agent_name": "explorer",
55
+ "denylist_args": ["ctx", "state"],
56
+ "fallback_mappings": {},
57
+ "llm_span_name": "gemini_explorer_call",
58
+ },
59
+ "ask_image_processor": {
60
+ "display_name": "Ask Image Processor",
61
+ "description": (
62
+ "Visual parsing agent that writes Python code using OpenCV and"
63
+ " computer vision to analyze and modify screen images."
64
+ ),
65
+ "module": "artemis.tools.image_processor_tool",
66
+ "function": "_run_image_processor_logic",
67
+ "agent_name": "image_processor",
68
+ "denylist_args": ["ctx", "state"],
69
+ "fallback_mappings": {},
70
+ "llm_span_name": "gemini_image_processor_call",
71
+ },
72
+ # Future tools can easily be registered here using plain dictionaries:
73
+ # "ask_validator": {
74
+ # "display_name": "Ask Validator",
75
+ # "description": "Validation agent that checks if a step or action was completed successfully.",
76
+ # "module": "artemis.tools.validator_tool",
77
+ # ...
78
+ # }
79
+ }
80
+
81
+
82
+ class ReplayManager:
83
+ """Manages sandboxed execution and diagnostics of visual agent steps.
84
+
85
+ The ReplayManager is the core orchestrator for the Artemis step replay system.
86
+ It reconstructs historical execution states (SQLite database records, trace
87
+ histories, and screenshots) and replays a single agent step in an isolated,
88
+ deterministic sandbox environment while interacting live with a physical
89
+ Android device and Gemini.
90
+
91
+ Key Responsibilities:
92
+ - Reconstructs and preloads historical SQLite states up to a specific
93
+ step.
94
+ - Establishes sandboxed `ArtemisContext` and `DataEngine` instances.
95
+ - Integrates with physical devices via ADB and UIAutomator.
96
+ - Hooks and spies on the Gemini API to capture thoughts and raw outputs.
97
+ - Reconciles live and preloaded traces into unified chronological trees.
98
+
99
+ Attributes:
100
+ workspace_root (Path): The absolute path to the root of the project
101
+ workspace.
102
+ replay_base_dir (Path): Base directory containing replay data and
103
+ outputs.
104
+ test_data_dir (Path): Directory containing chunked historical step
105
+ traces.
106
+ test_outputs_dir (Path): Sandbox directory where replay outputs are
107
+ written.
108
+ db_path (Path): Path to the master Data Engine SQLite database.
109
+ traces_path (Path): Path to the master traces directory.
110
+ images_dir (Path): Path to the master images directory.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ workspace_root: Path = WORKSPACE_ROOT,
116
+ device_id: str = None,
117
+ original_db_path: Path = None,
118
+ init_device: bool = False,
119
+ ):
120
+ """Initializes the ReplayManager and autodetects the connected Android device.
121
+
122
+ Args:
123
+ workspace_root (Path, optional): The absolute path to the workspace
124
+ root. Defaults to WORKSPACE_ROOT.
125
+ device_id (str, optional): The serial ID of the target Android
126
+ device. If not specified, autodetects the first connected device.
127
+ original_db_path (Path, optional): Path to the master SQLite
128
+ database. Defaults to DB_PATH.
129
+ init_device (bool, optional): Whether to initialize the ADB device
130
+ connection on startup.
131
+ """
132
+ self.workspace_root = workspace_root
133
+ self.traces_path = TRACES_PATH
134
+ self.replay_base_dir = self.traces_path / "replay"
135
+ self.test_data_dir = self.replay_base_dir / "data"
136
+ self.test_outputs_dir = self.replay_base_dir / "outputs"
137
+ self.db_path = Path(original_db_path) if original_db_path else DB_PATH
138
+ self.images_dir = IMAGES_DIR
139
+
140
+ # Device connection and autodetection
141
+ self.device_id = None
142
+ self.w = None
143
+ self.h = None
144
+ self.adb = None
145
+ self.ui_client = None
146
+
147
+ if init_device:
148
+ try:
149
+ self._init_device(device_id)
150
+ except Exception as e:
151
+ print(
152
+ "Warning: Connected device could not be initialized during"
153
+ f" ReplayManager startup: {e}"
154
+ )
155
+
156
+ def _init_device(self, device_id: str = None):
157
+ """Initializes connection to the target device and queries screen metrics."""
158
+ from adbutils import AdbClient
159
+
160
+ try:
161
+ from third_party.mobile_use.clients.ui_automator_client import UIAutomatorClient
162
+ except ImportError:
163
+ raise ImportError(
164
+ "Failed to import UIAutomatorClient. Ensure artemis package is installed in path."
165
+ )
166
+
167
+ self.adb = AdbClient(host="localhost", port=5037)
168
+ try:
169
+ devices = self.adb.device_list()
170
+ except Exception as adb_err:
171
+ raise ConnectionError(
172
+ f"Failed to query device list from ADB server: {adb_err}"
173
+ ) from adb_err
174
+
175
+ if not devices:
176
+ raise ConnectionError(
177
+ "No active ADB devices connected. Please connect an Android device via ADB."
178
+ )
179
+
180
+ if device_id:
181
+ matched_device = next((d.serial for d in devices if d.serial == device_id), None)
182
+ if not matched_device:
183
+ raise ConnectionError(
184
+ f"Requested device '{device_id}' is not connected."
185
+ f" Connected devices: {[d.serial for d in devices]}"
186
+ )
187
+ self.device_id = matched_device
188
+ else:
189
+ self.device_id = devices[0].serial
190
+
191
+ self.ui_client = UIAutomatorClient(device_id=self.device_id)
192
+ ui_data = self.ui_client.get_screen_data()
193
+ self.w, self.h = ui_data.width, ui_data.height
194
+ print(f"Connected to device: {self.device_id} ({self.w}x{self.h})")
195
+
196
+ def chunk_session_traces(self, session_id: str, output_dir: Path = None) -> Path:
197
+ """Chunks traces, screenshots, and metadata for a specific execution session.
198
+
199
+ Queries the master SQLite database and exports step-by-step
200
+ subdirectories
201
+ (e.g., `step_00/`, `step_01/`) inside the output directory.
202
+
203
+ Args:
204
+ session_id: The UUID string of the session to extract and chunk.
205
+ output_dir: Optional path to the directory where chunked results
206
+ should be saved. Defaults to `self.test_data_dir /
207
+ f"{session_id}_chunked"`.
208
+ """
209
+ import sqlite3
210
+ import json
211
+ import shutil
212
+
213
+ db_path = self.db_path
214
+ traces_dir = self.traces_path
215
+
216
+ if not db_path.exists():
217
+ raise FileNotFoundError(f"Master database not found at {db_path}")
218
+
219
+ if not output_dir:
220
+ output_dir = self.test_data_dir / f"{session_id}_chunked"
221
+ else:
222
+ output_dir = Path(output_dir)
223
+
224
+ if output_dir.exists() or output_dir.is_symlink():
225
+ # Safety check: only delete directories that are named *_chunked
226
+ if output_dir.name.endswith("_chunked"):
227
+ if output_dir.is_symlink():
228
+ output_dir.unlink()
229
+ elif (
230
+ self.test_data_dir in output_dir.parents
231
+ or output_dir.parent == self.test_data_dir
232
+ or output_dir.parent.name == "inputs"
233
+ ):
234
+ shutil.rmtree(output_dir)
235
+ output_dir.mkdir(parents=True, exist_ok=True)
236
+ print(f"Chunking traces for session: {session_id}")
237
+ print(f"Using database: {db_path}")
238
+ print(f"Output directory: {output_dir}")
239
+
240
+ conn = sqlite3.connect(db_path)
241
+ conn.row_factory = sqlite3.Row
242
+ cursor = conn.cursor()
243
+
244
+ cursor.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,))
245
+ session_row = cursor.fetchone()
246
+
247
+ cursor.execute(
248
+ "SELECT * FROM steps WHERE session_id = ? ORDER BY step_number ASC",
249
+ (session_id,),
250
+ )
251
+ steps = cursor.fetchall()
252
+ print(f"Found {len(steps)} steps for session {session_id}")
253
+
254
+ if not session_row and not steps:
255
+ conn.close()
256
+ raise ValueError(f"Session {session_id} not found in database.")
257
+
258
+ for step in steps:
259
+ step_number = step["step_number"]
260
+ step_id = step["step_id"]
261
+ step_dir = output_dir / f"step_{step_number:02d}"
262
+ step_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ # 1. Process and save the step's primary metadata (step.json)
265
+ step_data = {}
266
+ for key in step.keys():
267
+ val = step[key]
268
+ if (
269
+ key
270
+ in (
271
+ "action_taken",
272
+ "last_execution_result",
273
+ "extra_metadata",
274
+ )
275
+ and val
276
+ ):
277
+ try:
278
+ step_data[key] = json.loads(val)
279
+ except Exception:
280
+ step_data[key] = val
281
+ else:
282
+ step_data[key] = val
283
+
284
+ step_json_path = step_dir / "step.json"
285
+ with open(step_json_path, "w", encoding="utf-8") as f:
286
+ json.dump(step_data, f, indent=2, ensure_ascii=False)
287
+
288
+ # 2. Retrieve and save trace logs recorded during this step (traces.json)
289
+ cursor.execute(
290
+ "SELECT * FROM traces WHERE step_id = ? ORDER BY timestamp ASC",
291
+ (step_id,),
292
+ )
293
+ traces = cursor.fetchall()
294
+
295
+ traces_data = []
296
+ for trace_row in traces:
297
+ trace_dict = {}
298
+ for key in trace_row.keys():
299
+ val = trace_row[key]
300
+ if key == "payload" and val:
301
+ try:
302
+ trace_dict[key] = json.loads(val)
303
+ except Exception:
304
+ trace_dict[key] = val
305
+ else:
306
+ trace_dict[key] = val
307
+ traces_data.append(trace_dict)
308
+
309
+ traces_json_path = step_dir / "traces.json"
310
+ with open(traces_json_path, "w", encoding="utf-8") as f:
311
+ json.dump(traces_data, f, indent=2, ensure_ascii=False)
312
+
313
+ # 3. Copy the step's screenshots (pre-action and post-action screenshots)
314
+ pre_image_name = step_data.get("pre_image_name")
315
+ post_image_name = step_data.get("post_image_name")
316
+ if pre_image_name and post_image_name == pre_image_name:
317
+ post_image_name = None
318
+ images_dir = traces_dir / "images"
319
+
320
+ if pre_image_name:
321
+ src_pre = images_dir / f"{pre_image_name}.jpg"
322
+ if src_pre.exists():
323
+ shutil.copy2(src_pre, step_dir / "pre.jpg")
324
+ else:
325
+ print(f"Warning: Pre-action screenshot not found at {src_pre}")
326
+
327
+ if post_image_name:
328
+ src_post = images_dir / f"{post_image_name}.jpg"
329
+ if src_post.exists():
330
+ shutil.copy2(src_post, step_dir / "post.jpg")
331
+ else:
332
+ print(f"Warning: Post-action screenshot not found at {src_post}")
333
+
334
+ # 4. Save metadata of the pre-action screenshot (pre_image_meta.json)
335
+ if pre_image_name:
336
+ cursor.execute(
337
+ "SELECT * FROM images WHERE image_name = ?",
338
+ (pre_image_name,),
339
+ )
340
+ img_row = cursor.fetchone()
341
+ if img_row:
342
+ img_meta = {}
343
+ for key in img_row.keys():
344
+ val = img_row[key]
345
+ if key in ("ocr_result", "ui_tree", "extra_metadata") and val:
346
+ try:
347
+ img_meta[key] = json.loads(val)
348
+ except Exception:
349
+ img_meta[key] = val
350
+ else:
351
+ img_meta[key] = val
352
+
353
+ pre_img_meta_path = step_dir / "pre_image_meta.json"
354
+ with open(pre_img_meta_path, "w", encoding="utf-8") as f:
355
+ json.dump(img_meta, f, indent=2, ensure_ascii=False)
356
+ else:
357
+ print(
358
+ f"Warning: Image metadata row for {pre_image_name} not"
359
+ " found in images table."
360
+ )
361
+
362
+ # 5. Save metadata of the post-action screenshot (post_image_meta.json)
363
+ if post_image_name:
364
+ cursor.execute(
365
+ "SELECT * FROM images WHERE image_name = ?",
366
+ (post_image_name,),
367
+ )
368
+ img_row = cursor.fetchone()
369
+ if img_row:
370
+ img_meta = {}
371
+ for key in img_row.keys():
372
+ val = img_row[key]
373
+ if key in ("ocr_result", "ui_tree", "extra_metadata") and val:
374
+ try:
375
+ img_meta[key] = json.loads(val)
376
+ except Exception:
377
+ img_meta[key] = val
378
+ else:
379
+ img_meta[key] = val
380
+
381
+ post_img_meta_path = step_dir / "post_image_meta.json"
382
+ with open(post_img_meta_path, "w", encoding="utf-8") as f:
383
+ json.dump(img_meta, f, indent=2, ensure_ascii=False)
384
+ else:
385
+ print(
386
+ f"Warning: Image metadata row for {post_image_name} not"
387
+ " found in images table."
388
+ )
389
+
390
+ conn.close()
391
+ # Write sentinel file to indicate successful chunking
392
+ try:
393
+ (output_dir / ".chunked").touch()
394
+ except Exception as e:
395
+ print(f"Warning: Failed to write chunked sentinel file: {e}")
396
+ print("Chunking trace completion status: success")
397
+ return output_dir
398
+
399
+ def list_devices(self) -> list[dict]:
400
+ """Dynamically queries the ADB server for connected Android devices."""
401
+ try:
402
+ from adbutils import AdbClient
403
+
404
+ adb = AdbClient(host="localhost", port=5037)
405
+ devices = adb.device_list()
406
+ return [{"serial": d.serial, "status": "online"} for d in devices]
407
+ except Exception as e:
408
+ print(f"Warning: Failed to query device list from ADB: {e}")
409
+ return []
410
+
411
+ def load_session_goal(
412
+ self, session_id: str, step_dir: Path, original_db_path: str = None
413
+ ) -> str:
414
+ """Loads the session goal from either session.json or the original session db."""
415
+ session_json_path = step_dir.parent / "session.json"
416
+ if session_json_path.exists():
417
+ try:
418
+ with open(session_json_path, encoding="utf-8") as f:
419
+ data = json.load(f)
420
+ if isinstance(data, dict) and "initial_goal" in data and data["initial_goal"]:
421
+ return data["initial_goal"]
422
+ except Exception as e:
423
+ print(f"Warning: Failed to load goal from session.json: {e}")
424
+
425
+ if original_db_path and Path(original_db_path).exists():
426
+ try:
427
+ conn = sqlite3.connect(original_db_path)
428
+ conn.row_factory = sqlite3.Row
429
+ cursor = conn.cursor()
430
+ cursor.execute(
431
+ "SELECT initial_goal FROM sessions WHERE session_id = ?",
432
+ (str(session_id),),
433
+ )
434
+ row = cursor.fetchone()
435
+ conn.close()
436
+ if row and row["initial_goal"]:
437
+ return row["initial_goal"]
438
+ except Exception as e:
439
+ print(f"Warning: Failed to load goal from original database: {e}")
440
+
441
+ raise ValueError(
442
+ f"Could not load initial_goal for session {session_id} from"
443
+ f" session.json or original database {original_db_path}."
444
+ )
445
+
446
+ def extract_replay_data(
447
+ self,
448
+ step_dir,
449
+ original_db_path=None,
450
+ agent_name="explorer",
451
+ tool_name="ask_explorer",
452
+ ):
453
+ """Extracts final answer and chronological tool call sequence from traces.json/db."""
454
+ step_json_path = step_dir / "step.json"
455
+ if not step_json_path.exists():
456
+ raise FileNotFoundError(f"step.json not found in {step_dir}")
457
+
458
+ with open(step_json_path, encoding="utf-8") as f:
459
+ step_data = json.load(f)
460
+ step_id = step_data["step_id"]
461
+
462
+ traces_json_path = step_dir / "traces.json"
463
+ traces = []
464
+ if traces_json_path.exists():
465
+ with open(traces_json_path, encoding="utf-8") as f:
466
+ traces = json.load(f)
467
+
468
+ # If traces.json is empty/missing or lacks agent trace, check original DB
469
+ agent_trace = next(
470
+ (t for t in traces if t.get("type") == "agent" and t.get("name") == agent_name),
471
+ None,
472
+ )
473
+ if (not agent_trace or not traces) and original_db_path and Path(original_db_path).exists():
474
+ conn = sqlite3.connect(original_db_path)
475
+ conn.row_factory = sqlite3.Row
476
+ cursor = conn.cursor()
477
+ cursor.execute(
478
+ "SELECT * FROM traces WHERE step_id = ? ORDER BY timestamp ASC",
479
+ (step_id,),
480
+ )
481
+ rows = cursor.fetchall()
482
+ traces = []
483
+ for r in rows:
484
+ td = dict(r)
485
+ if td.get("payload"):
486
+ try:
487
+ td["payload"] = json.loads(td["payload"])
488
+ except (ValueError, TypeError):
489
+ # Non-JSON payload: keep the raw string.
490
+ pass
491
+ traces.append(td)
492
+ conn.close()
493
+ agent_trace = next(
494
+ (t for t in traces if t.get("type") == "agent" and t.get("name") == agent_name),
495
+ None,
496
+ )
497
+
498
+ if not agent_trace:
499
+ print(
500
+ f"Warning: {agent_name} agent trace not found. Replay will"
501
+ " fallback to direct submit_answer."
502
+ )
503
+ return (
504
+ [],
505
+ {
506
+ "candidates": [],
507
+ "fallback_message": f"No {agent_name} trace found.",
508
+ },
509
+ traces,
510
+ )
511
+
512
+ payload = agent_trace.get("payload") or {}
513
+ result_str = payload.get("result", "{}")
514
+ if isinstance(result_str, str):
515
+ try:
516
+ final_submit_args = json.loads(result_str)
517
+ except Exception:
518
+ final_submit_args = {
519
+ "candidates": [],
520
+ "fallback_message": result_str,
521
+ }
522
+ else:
523
+ final_submit_args = result_str
524
+
525
+ # Build sequence of intermediate tool calls under the agent trace
526
+ agent_trace_id = agent_trace["trace_id"]
527
+
528
+ def get_descendants(pid):
529
+ child_list = []
530
+ for t in traces:
531
+ if t.get("parent_trace_id") == pid:
532
+ child_list.append(t)
533
+ child_list.extend(get_descendants(t["trace_id"]))
534
+ return child_list
535
+
536
+ descendants = get_descendants(agent_trace_id)
537
+ descendants.sort(key=lambda x: x.get("timestamp", 0))
538
+
539
+ tool_calls_sequence = []
540
+ for t in descendants:
541
+ if t.get("type") == "tool" and t.get("name") not in (
542
+ tool_name,
543
+ "submit_answer",
544
+ ):
545
+ t_payload = t.get("payload") or {}
546
+ t_args = t_payload.get("args") or {}
547
+ tool_calls_sequence.append({"name": t.get("name"), "args": t_args})
548
+
549
+ # Preload traces up to the point when agent is called
550
+ traces_to_preload = []
551
+ if agent_trace in traces:
552
+ idx = traces.index(agent_trace)
553
+ traces_to_preload = traces[:idx]
554
+ else:
555
+ agent_ts = agent_trace.get("timestamp", 0.0)
556
+ traces_to_preload = [t for t in traces if t.get("timestamp", 0.0) < agent_ts]
557
+
558
+ return tool_calls_sequence, final_submit_args, traces_to_preload
559
+
560
+ def resolve_step_data_path(self, data: dict, path: str):
561
+ """Resolves a value from a nested dict/list using a dot-separated path (e.g.
562
+
563
+ 'action_taken.0.target_text').
564
+ """
565
+ parts = path.split(".")
566
+ curr = data
567
+ for p in parts:
568
+ if isinstance(curr, dict):
569
+ curr = curr.get(p)
570
+ elif isinstance(curr, list):
571
+ try:
572
+ idx = int(p)
573
+ if idx < len(curr):
574
+ curr = curr[idx]
575
+ else:
576
+ return None
577
+ except ValueError:
578
+ return None
579
+ else:
580
+ return None
581
+ return curr
582
+
583
+ def setup_temporary_database(
584
+ self,
585
+ temp_db_path,
586
+ temp_traces_dir,
587
+ session_id,
588
+ step_data,
589
+ pre_image_meta,
590
+ initial_goal,
591
+ traces_to_preload=None,
592
+ post_image_meta=None,
593
+ replay_id=None,
594
+ ):
595
+ """Initializes a sandbox SQLite database with preloaded step and screen metadata."""
596
+ if not initial_goal:
597
+ raise ValueError("initial_goal must be provided and cannot be empty.")
598
+
599
+ if temp_db_path.exists():
600
+ try:
601
+ temp_db_path.unlink()
602
+ except OSError:
603
+ pass
604
+
605
+ if temp_traces_dir.exists():
606
+ shutil.rmtree(temp_traces_dir)
607
+ temp_traces_dir.mkdir(parents=True, exist_ok=True)
608
+
609
+ try:
610
+ from artemis.data_engine.storage import StorageManager
611
+ from artemis.data_engine.models import (
612
+ SessionMetadata,
613
+ ImageRecord,
614
+ StepRecord,
615
+ TraceRecord,
616
+ )
617
+ except ImportError:
618
+ raise ImportError(
619
+ "Failed to import Artemis Data Engine modules. Ensure you are"
620
+ " running in the correct virtual environment."
621
+ )
622
+
623
+ storage = StorageManager(temp_db_path, temp_traces_dir)
624
+
625
+ # Preload session metadata
626
+ session_meta = SessionMetadata(
627
+ session_id=UUID(session_id) if isinstance(session_id, str) else session_id,
628
+ initial_goal=initial_goal,
629
+ status="running",
630
+ )
631
+ storage.create_session(session_meta)
632
+
633
+ if replay_id:
634
+ replay_session_meta = SessionMetadata(
635
+ session_id=UUID(replay_id) if isinstance(replay_id, str) else replay_id,
636
+ initial_goal=initial_goal,
637
+ status="running",
638
+ )
639
+ storage.create_session(replay_session_meta)
640
+
641
+ # Preload image metadata
642
+ if pre_image_meta:
643
+ image_record = ImageRecord(
644
+ image_name=pre_image_meta["image_name"],
645
+ timestamp=pre_image_meta.get("timestamp", 0.0),
646
+ ocr_result=pre_image_meta.get("ocr_result"),
647
+ ui_tree=pre_image_meta.get("ui_tree"),
648
+ extra_metadata=pre_image_meta.get("extra_metadata", {}),
649
+ )
650
+ storage.create_image(image_record)
651
+
652
+ if post_image_meta:
653
+ image_record = ImageRecord(
654
+ image_name=post_image_meta["image_name"],
655
+ timestamp=post_image_meta.get("timestamp", 0.0),
656
+ ocr_result=post_image_meta.get("ocr_result"),
657
+ ui_tree=post_image_meta.get("ui_tree"),
658
+ extra_metadata=post_image_meta.get("extra_metadata", {}),
659
+ )
660
+ storage.create_image(image_record)
661
+
662
+ # Preload step metadata
663
+ step_record = StepRecord(
664
+ step_id=step_data["step_id"],
665
+ session_id=step_data["session_id"],
666
+ step_number=step_data["step_number"],
667
+ timestamp=step_data.get("timestamp", 0.0),
668
+ pre_image_name=step_data.get("pre_image_name"),
669
+ post_image_name=step_data.get("post_image_name"),
670
+ summary=step_data.get("summary"),
671
+ action_taken=step_data.get("action_taken"),
672
+ operator_raw_thinking=step_data.get("operator_raw_thinking"),
673
+ operator_native_thinking=step_data.get("operator_native_thinking"),
674
+ last_execution_result=step_data.get("last_execution_result"),
675
+ extra_metadata=step_data.get("extra_metadata", {}),
676
+ )
677
+ storage.create_step(step_record)
678
+
679
+ # Preload trace history
680
+ if traces_to_preload:
681
+ for t in traces_to_preload:
682
+ try:
683
+ payload_val = t.get("payload")
684
+ if isinstance(payload_val, str):
685
+ try:
686
+ payload_val = json.loads(payload_val)
687
+ except Exception:
688
+ payload_val = {}
689
+ elif not isinstance(payload_val, dict):
690
+ payload_val = {}
691
+
692
+ trace_rec = TraceRecord(
693
+ trace_id=UUID(t["trace_id"])
694
+ if isinstance(t["trace_id"], str)
695
+ else t["trace_id"],
696
+ session_id=UUID(t["session_id"])
697
+ if isinstance(t["session_id"], str)
698
+ else t["session_id"],
699
+ step_id=UUID(t["step_id"]) if t.get("step_id") else None,
700
+ parent_trace_id=UUID(t["parent_trace_id"])
701
+ if t.get("parent_trace_id")
702
+ else None,
703
+ type=t["type"],
704
+ name=t["name"],
705
+ timestamp=t.get("timestamp", 0.0),
706
+ duration=t.get("duration"),
707
+ status=t.get("status", "success"),
708
+ payload=payload_val,
709
+ )
710
+ storage.create_trace(trace_rec)
711
+ except Exception as ex:
712
+ print(f"Warning: Failed to preload trace {t.get('trace_id')}: {ex}")
713
+
714
+ return storage
715
+
716
+ def get_preloaded_trace_ids_for_step(self, session_id: str, step_num: int) -> set:
717
+ traces_json_path = (
718
+ self.test_data_dir / f"{session_id}_chunked" / f"step_{step_num:02d}" / "traces.json"
719
+ )
720
+ if traces_json_path.exists():
721
+ try:
722
+ with open(traces_json_path, encoding="utf-8") as f:
723
+ traces = json.load(f)
724
+ return {str(t.get("trace_id")).lower() for t in traces if t.get("trace_id")}
725
+ except (OSError, ValueError) as e:
726
+ print(
727
+ f"Warning: Failed to read preloaded trace ids from {traces_json_path}: {e}",
728
+ file=sys.stderr,
729
+ )
730
+ return set()
731
+
732
+ def load_traces_from_db(self, db_path: Path, step_num: int, preloaded_trace_ids: set) -> list:
733
+ if not db_path.exists():
734
+ return []
735
+ try:
736
+ conn = sqlite3.connect(db_path)
737
+ conn.row_factory = sqlite3.Row
738
+ cursor = conn.cursor()
739
+ cursor.execute(
740
+ "SELECT trace_id, session_id, step_id, parent_trace_id, type,"
741
+ " name, status, timestamp, duration, payload FROM traces ORDER"
742
+ " BY timestamp ASC"
743
+ )
744
+ rows = cursor.fetchall()
745
+ conn.close()
746
+
747
+ preloaded_set = {str(tid).lower() for tid in preloaded_trace_ids if tid}
748
+ step_traces = []
749
+ for r in rows:
750
+ td = dict(r)
751
+ if td.get("payload"):
752
+ try:
753
+ td["payload"] = json.loads(td["payload"])
754
+ except (ValueError, TypeError):
755
+ # Non-JSON payload: normalized to {} just below.
756
+ pass
757
+ if not isinstance(td.get("payload"), dict):
758
+ td["payload"] = {}
759
+
760
+ tid = str(r["trace_id"]).lower()
761
+ if tid in preloaded_set:
762
+ td["is_preloaded"] = True
763
+ else:
764
+ td["is_preloaded"] = False
765
+
766
+ td["step_number"] = step_num
767
+ step_traces.append(td)
768
+ return step_traces
769
+ except Exception as e:
770
+ print(
771
+ f"Warning: Failed to load traces from database {db_path}: {e}",
772
+ file=sys.stderr,
773
+ )
774
+ return []
775
+
776
+ def normalize_trace_payload(self, t: dict) -> dict:
777
+ if t.get("type") == "tool":
778
+ payload = t.get("payload") or {}
779
+ args = payload.get("args") or {}
780
+ positional_args = payload.get("positional_args") or []
781
+
782
+ if (not args or args == {}) and positional_args:
783
+ name = t.get("name")
784
+ if name == "search_by_coords" and len(positional_args) >= 2:
785
+ try:
786
+ args = {
787
+ "nx": int(positional_args[0]),
788
+ "ny": int(positional_args[1]),
789
+ }
790
+ except ValueError:
791
+ args = {
792
+ "nx": positional_args[0],
793
+ "ny": positional_args[1],
794
+ }
795
+ elif name == "search_ui" and len(positional_args) >= 1:
796
+ args = {"search_query": positional_args[0]}
797
+ elif name == "detect_objects" and len(positional_args) >= 1:
798
+ args = {"labels": positional_args[0]}
799
+ else:
800
+ args = {"positional_args": positional_args}
801
+
802
+ payload["args"] = args
803
+ t["payload"] = payload
804
+ return t
805
+
806
+ def load_all_session_traces(
807
+ self,
808
+ temp_db_path: Path,
809
+ preloaded_trace_ids: set,
810
+ current_step_number: int,
811
+ ) -> list:
812
+ """Loads and merges all traces across all steps in the session."""
813
+ session_id = None
814
+ try:
815
+ session_id = temp_db_path.parents[1].name.split("_")[0]
816
+ except IndexError:
817
+ # Path too shallow to carry a session dir: fall back to the DB probe.
818
+ pass
819
+
820
+ if not session_id:
821
+ try:
822
+ conn = sqlite3.connect(temp_db_path)
823
+ cursor = conn.cursor()
824
+ cursor.execute("SELECT session_id FROM traces LIMIT 1")
825
+ row = cursor.fetchone()
826
+ if row:
827
+ session_id = str(row[0])
828
+ conn.close()
829
+ except sqlite3.Error:
830
+ # Unreadable sandbox DB: fall back to single-step trace loading.
831
+ pass
832
+
833
+ if not session_id:
834
+ return self.load_traces_from_db(temp_db_path, current_step_number, preloaded_trace_ids)
835
+
836
+ session_chunked_dir = self.test_data_dir / f"{session_id}_chunked"
837
+
838
+ all_traces = []
839
+ if session_chunked_dir.exists():
840
+ step_subdirs = sorted(
841
+ [
842
+ d
843
+ for d in session_chunked_dir.iterdir()
844
+ if d.is_dir() and d.name.startswith("step_")
845
+ ],
846
+ key=lambda d: d.name,
847
+ )
848
+
849
+ for subdir in step_subdirs:
850
+ try:
851
+ step_num = int(subdir.name.split("_")[1])
852
+ except (IndexError, ValueError):
853
+ continue
854
+
855
+ step_outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_num:02d}"
856
+ step_db_path = step_outputs_dir / "temp_traces" / "data_engine.db"
857
+
858
+ step_preloaded_ids = self.get_preloaded_trace_ids_for_step(session_id, step_num)
859
+
860
+ if step_db_path.exists():
861
+ step_traces = self.load_traces_from_db(
862
+ step_db_path, step_num, step_preloaded_ids
863
+ )
864
+ all_traces.extend(step_traces)
865
+ else:
866
+ traces_json_path = subdir / "traces.json"
867
+ if traces_json_path.exists():
868
+ try:
869
+ with open(traces_json_path, encoding="utf-8") as f:
870
+ step_traces = json.load(f)
871
+ for t in step_traces:
872
+ t["is_preloaded"] = True
873
+ t["step_number"] = step_num
874
+ if not isinstance(t.get("payload"), dict):
875
+ t["payload"] = {}
876
+ all_traces.append(t)
877
+ except Exception as e:
878
+ print(
879
+ f"Warning: Failed to load traces from {traces_json_path}: {e}",
880
+ file=sys.stderr,
881
+ )
882
+ else:
883
+ all_traces = self.load_traces_from_db(
884
+ temp_db_path, current_step_number, preloaded_trace_ids
885
+ )
886
+
887
+ # Align live replay traces to the appropriate preloaded step/operator trace
888
+ for step_num in range(1, 100):
889
+ current_step_id = None
890
+ current_step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_num:02d}"
891
+ if not current_step_dir.exists():
892
+ continue
893
+
894
+ step_json_path = current_step_dir / "step.json"
895
+ if step_json_path.exists():
896
+ try:
897
+ with open(step_json_path, encoding="utf-8") as f:
898
+ step_data = json.load(f)
899
+ current_step_id = step_data.get("step_id")
900
+ except (OSError, ValueError) as e:
901
+ print(
902
+ f"Warning: Failed to read step id from {step_json_path}: {e}",
903
+ file=sys.stderr,
904
+ )
905
+
906
+ current_step_id_str = str(current_step_id).lower() if current_step_id else None
907
+
908
+ # 1. Find live root trace (no parent_trace_id and not preloaded)
909
+ live_root_trace = None
910
+ for t in all_traces:
911
+ if t.get("step_number") != step_num:
912
+ continue
913
+ if not t.get("is_preloaded") and not t.get("parent_trace_id"):
914
+ live_root_trace = t
915
+ break
916
+
917
+ # 2. Find all preloaded traces for this step
918
+ step_preloaded_traces = [
919
+ t
920
+ for t in all_traces
921
+ if t.get("step_number") == step_num
922
+ and t.get("is_preloaded")
923
+ and str(t.get("step_id") or "").lower() == current_step_id_str
924
+ ]
925
+
926
+ # Sort chronologically to identify the final response of the step
927
+ step_preloaded_traces.sort(key=lambda x: x.get("timestamp", 0.0))
928
+ final_response_trace = step_preloaded_traces[-1] if step_preloaded_traces else None
929
+
930
+ # 3. Branch immediately before the final response of the step
931
+ if live_root_trace and final_response_trace:
932
+ # Resolve the tool name from the live root trace name
933
+ tool_name = "ask_explorer"
934
+ agent_name = live_root_trace.get("name")
935
+ for t_name, cfg in REPLAY_TOOLS_CONFIG.items():
936
+ if cfg.get("agent_name") == agent_name:
937
+ tool_name = t_name
938
+ break
939
+
940
+ import uuid
941
+
942
+ virtual_id = str(uuid.uuid4())
943
+
944
+ # Branch from the same parent as the final response
945
+ parent_id = final_response_trace.get("parent_trace_id")
946
+
947
+ # Set timestamp to be immediately before the final response
948
+ virtual_timestamp = final_response_trace.get("timestamp", 0.0) - 0.001
949
+
950
+ live_query = None
951
+ live_cf = None
952
+ if live_root_trace.get("payload") and live_root_trace["payload"].get("args"):
953
+ args = live_root_trace["payload"]["args"]
954
+ live_query = (
955
+ args.get("q") or args.get("query") or args.get("arg2") or args.get("goal")
956
+ )
957
+ live_cf = (
958
+ args.get("cf") or args.get("context_feedback") or args.get("arg3") or ""
959
+ )
960
+ if not live_query:
961
+ live_query = "Replay Execution"
962
+
963
+ virtual_tool_trace = {
964
+ "trace_id": virtual_id,
965
+ "session_id": session_id,
966
+ "step_id": current_step_id,
967
+ "parent_trace_id": parent_id,
968
+ "type": "tool",
969
+ "name": tool_name,
970
+ "status": live_root_trace.get("status", "success"),
971
+ "timestamp": virtual_timestamp,
972
+ "duration": live_root_trace.get("duration", 0.0),
973
+ "payload": {
974
+ "args": {
975
+ "query": live_query,
976
+ "context_feedback": live_cf,
977
+ },
978
+ "result": (live_root_trace.get("payload", {}).get("result")),
979
+ },
980
+ "is_preloaded": False,
981
+ "step_number": step_num,
982
+ }
983
+
984
+ # Link the live root trace to our new virtual tool trace
985
+ live_root_trace["parent_trace_id"] = virtual_id
986
+ all_traces.append(virtual_tool_trace)
987
+
988
+ all_traces = [self.normalize_trace_payload(t) for t in all_traces]
989
+ all_traces.sort(key=lambda t: t.get("timestamp", 0.0))
990
+ return all_traces
991
+
992
+ def enrich_all_session_traces(self, all_traces: list, session_id: str) -> list:
993
+ """Enriches all preloaded traces (across all steps) with file URLs to their corresponding screenshots."""
994
+ session_chunked_dir = self.test_data_dir / f"{session_id}_chunked"
995
+
996
+ step_dirs = {}
997
+ if session_chunked_dir.exists():
998
+ for subdir in session_chunked_dir.iterdir():
999
+ if subdir.is_dir() and subdir.name.startswith("step_"):
1000
+ step_json_path = subdir / "step.json"
1001
+ if step_json_path.exists():
1002
+ try:
1003
+ with open(step_json_path, encoding="utf-8") as f:
1004
+ step_data = json.load(f)
1005
+ sid = step_data.get("step_id")
1006
+ if sid:
1007
+ step_dirs[str(sid).lower()] = subdir
1008
+ except (OSError, ValueError):
1009
+ # Unreadable step.json: screenshots for this step
1010
+ # simply are not linked into the trace payloads.
1011
+ pass
1012
+
1013
+ for td in all_traces:
1014
+ if td.get("is_preloaded"):
1015
+ step_id = str(td.get("step_id") or "").lower()
1016
+ if step_id and step_id in step_dirs:
1017
+ step_dir = step_dirs[step_id]
1018
+ if td.get("type") == "agent":
1019
+ pre_jpg = step_dir / "pre.jpg"
1020
+ if pre_jpg.exists():
1021
+ td["payload"]["pre_screenshot_path"] = f"file://{pre_jpg.resolve()}"
1022
+ elif td.get("type") == "action":
1023
+ post_jpg = step_dir / "post.jpg"
1024
+ if post_jpg.exists():
1025
+ td["payload"]["post_screenshot_path"] = f"file://{post_jpg.resolve()}"
1026
+
1027
+ return all_traces
1028
+
1029
+ def load_steps_metadata(self, session_id: str) -> dict[int, dict]:
1030
+ """Loads metadata for all steps in the session from the chunked data."""
1031
+ session_chunked_dir = self.test_data_dir / f"{session_id}_chunked"
1032
+
1033
+ steps = {}
1034
+ if session_chunked_dir.exists():
1035
+ for subdir in session_chunked_dir.iterdir():
1036
+ if subdir.is_dir() and subdir.name.startswith("step_"):
1037
+ step_json_path = subdir / "step.json"
1038
+ if step_json_path.exists():
1039
+ try:
1040
+ with open(step_json_path, encoding="utf-8") as f:
1041
+ step_data = json.load(f)
1042
+ step_num = step_data.get("step_number")
1043
+ if step_num is not None:
1044
+ steps[int(step_num)] = step_data
1045
+ except Exception as e:
1046
+ print(f"Warning: Failed to load step data from {step_json_path}: {e}")
1047
+ return steps
1048
+
1049
+ def make_file_url(self, val):
1050
+ if isinstance(val, str) and (val.endswith(".jpg") or val.endswith(".png") or "/" in val):
1051
+ try:
1052
+ p = Path(val)
1053
+ if p.exists():
1054
+ abs_path = str(p.resolve())
1055
+ return (
1056
+ f"file://{abs_path}" if abs_path.startswith("/") else f"file:///{abs_path}"
1057
+ )
1058
+ except (OSError, ValueError):
1059
+ # Not a resolvable filesystem path: return the value verbatim.
1060
+ pass
1061
+ return val
1062
+
1063
+ def clean_payload_paths(self, obj):
1064
+ if isinstance(obj, dict):
1065
+ return {k: self.clean_payload_paths(v) for k, v in obj.items()}
1066
+ elif isinstance(obj, list):
1067
+ return [self.clean_payload_paths(v) for v in obj]
1068
+ elif isinstance(obj, str):
1069
+ return self.make_file_url(obj)
1070
+ return obj
1071
+
1072
+ def sanitize_nodes(self, nodes_list):
1073
+ sanitized = []
1074
+ for node in nodes_list:
1075
+ node["children"] = self.sanitize_nodes(node["children"])
1076
+ if node["name"] == "node":
1077
+ sanitized.extend(node["children"])
1078
+ else:
1079
+ sanitized.append(node)
1080
+ return sanitized
1081
+
1082
+ def sort_tree_nodes_chronologically(self, nodes_list: list[dict]) -> list[dict]:
1083
+ for node in nodes_list:
1084
+ if "children" in node and node["children"]:
1085
+ node["children"] = self.sort_tree_nodes_chronologically(node["children"])
1086
+ return sorted(nodes_list, key=lambda x: x.get("timestamp", 0.0) or 0.0)
1087
+
1088
+ def extract_tool_calls_from_db(self, temp_db_path: Path, preloaded_trace_ids: set) -> list:
1089
+ """Extracts tool calls executed during the current step replay from the sandbox database."""
1090
+ preloaded_set = {str(tid).lower() for tid in preloaded_trace_ids if tid}
1091
+
1092
+ if not temp_db_path.exists():
1093
+ return []
1094
+
1095
+ try:
1096
+ conn = sqlite3.connect(temp_db_path)
1097
+ conn.row_factory = sqlite3.Row
1098
+ cursor = conn.cursor()
1099
+ cursor.execute(
1100
+ "SELECT trace_id, name, status, payload FROM traces WHERE type"
1101
+ " = 'tool' ORDER BY timestamp ASC"
1102
+ )
1103
+ rows = cursor.fetchall()
1104
+ conn.close()
1105
+ except Exception as e:
1106
+ print(
1107
+ f"Warning: Failed to query tool calls from database: {e}",
1108
+ file=sys.stderr,
1109
+ )
1110
+ return []
1111
+
1112
+ tool_calls = []
1113
+ for r in rows:
1114
+ tid = str(r["trace_id"]).lower()
1115
+ if tid not in preloaded_set:
1116
+ payload = {}
1117
+ if r["payload"]:
1118
+ try:
1119
+ payload = json.loads(r["payload"])
1120
+ except (ValueError, TypeError):
1121
+ # Non-JSON payload: treated as an empty tool payload.
1122
+ pass
1123
+
1124
+ response_val = payload.get("result")
1125
+ if response_val is None:
1126
+ response_val = payload.get("error")
1127
+ else:
1128
+ if isinstance(response_val, str) and (
1129
+ response_val.startswith("{") or response_val.startswith("[")
1130
+ ):
1131
+ try:
1132
+ response_val = json.loads(response_val)
1133
+ except ValueError:
1134
+ # Looks like JSON but is not: keep the raw string.
1135
+ pass
1136
+
1137
+ args = payload.get("args", {})
1138
+ positional_args = payload.get("positional_args", [])
1139
+ if positional_args:
1140
+ if r["name"] == "search_by_coords" and len(positional_args) >= 2:
1141
+ try:
1142
+ args = {
1143
+ "nx": int(positional_args[0]),
1144
+ "ny": int(positional_args[1]),
1145
+ }
1146
+ except ValueError:
1147
+ args = {
1148
+ "nx": positional_args[0],
1149
+ "ny": positional_args[1],
1150
+ }
1151
+ elif r["name"] == "search_ui" and len(positional_args) >= 1:
1152
+ args = {"search_query": positional_args[0]}
1153
+ elif r["name"] == "detect_objects" and len(positional_args) >= 1:
1154
+ args = {"labels": positional_args[0]}
1155
+ else:
1156
+ args = {"positional_args": positional_args}
1157
+
1158
+ tool_calls.append(
1159
+ {
1160
+ "name": r["name"],
1161
+ "args": args,
1162
+ "status": r["status"],
1163
+ "response": response_val,
1164
+ }
1165
+ )
1166
+ return tool_calls
1167
+
1168
+ def extract_state_prepopulation_data(self, step_dir, step_data, pre_image_meta):
1169
+ """Dynamically extracts state prepopulation fields from chunked test step files."""
1170
+ latest_ui_hierarchy = None
1171
+ if pre_image_meta and "ui_tree" in pre_image_meta:
1172
+ latest_ui_hierarchy = pre_image_meta["ui_tree"]
1173
+
1174
+ structured_decisions = None
1175
+ traces_json_path = step_dir / "traces.json"
1176
+ if traces_json_path.exists():
1177
+ try:
1178
+ with open(traces_json_path, encoding="utf-8") as f:
1179
+ traces = json.load(f)
1180
+ operator_trace = next(
1181
+ (t for t in traces if t.get("type") == "agent" and t.get("name") == "operator"),
1182
+ None,
1183
+ )
1184
+ if operator_trace:
1185
+ payload = operator_trace.get("payload") or {}
1186
+ result_val = payload.get("result")
1187
+ if isinstance(result_val, str):
1188
+ try:
1189
+ res_dict = json.loads(result_val)
1190
+ structured_decisions = res_dict.get("structured_decisions")
1191
+ except Exception as e:
1192
+ print(f"Warning: Failed to parse operator trace result JSON: {e}")
1193
+ elif isinstance(result_val, dict):
1194
+ structured_decisions = result_val.get("structured_decisions")
1195
+ except Exception as e:
1196
+ print(f"Warning: Failed to read structured_decisions from traces.json: {e}")
1197
+ pass
1198
+
1199
+ if not structured_decisions and step_data:
1200
+ action_taken = step_data.get("action_taken")
1201
+ if action_taken:
1202
+ try:
1203
+ structured_decisions = json.dumps(action_taken)
1204
+ except Exception as e:
1205
+ print(f"Warning: Failed to serialize action_taken fallback to JSON: {e}")
1206
+ pass
1207
+
1208
+ focused_app_info = None
1209
+ if latest_ui_hierarchy:
1210
+ system_packages = {
1211
+ "com.android.systemui",
1212
+ "android",
1213
+ "com.google.android.apps.nexuslauncher",
1214
+ }
1215
+ packages = []
1216
+ for node in latest_ui_hierarchy:
1217
+ if isinstance(node, dict):
1218
+ pkg = node.get("package")
1219
+ if pkg and pkg not in system_packages and "launcher" not in pkg.lower():
1220
+ packages.append(pkg)
1221
+ if packages:
1222
+ from collections import Counter
1223
+
1224
+ focused_app_info = Counter(packages).most_common(1)[0][0]
1225
+
1226
+ device_date = None
1227
+ timestamp = None
1228
+ if step_data and "timestamp" in step_data:
1229
+ timestamp = step_data["timestamp"]
1230
+ elif pre_image_meta and "timestamp" in pre_image_meta:
1231
+ timestamp = pre_image_meta["timestamp"]
1232
+
1233
+ if timestamp is not None:
1234
+ from datetime import datetime, UTC
1235
+
1236
+ try:
1237
+ dt = datetime.fromtimestamp(timestamp, UTC)
1238
+ device_date = dt.strftime("%a %b %d %H:%M:%S UTC %Y")
1239
+ except Exception as e:
1240
+ print(
1241
+ f"Warning: Failed to parse timestamp {timestamp} into device date string: {e}"
1242
+ )
1243
+
1244
+ return (
1245
+ latest_ui_hierarchy,
1246
+ structured_decisions,
1247
+ focused_app_info,
1248
+ device_date,
1249
+ )
1250
+
1251
+ def _ensure_session_chunked(self, session_id: str) -> Path:
1252
+ """Ensures that the session traces are chunked and ready for replay."""
1253
+ session_chunked_dir = self.test_data_dir / f"{session_id}_chunked"
1254
+
1255
+ db_steps_count = 0
1256
+ if self.db_path.exists():
1257
+ try:
1258
+ conn = sqlite3.connect(self.db_path)
1259
+ cursor = conn.cursor()
1260
+ cursor.execute(
1261
+ "SELECT COUNT(*) FROM steps WHERE session_id = ?",
1262
+ (session_id,),
1263
+ )
1264
+ db_steps_count = cursor.fetchone()[0]
1265
+ conn.close()
1266
+ except Exception as e:
1267
+ print(f"Warning: Failed to query step count from DB: {e}")
1268
+
1269
+ chunked_steps_count = 0
1270
+ if session_chunked_dir.exists():
1271
+ chunked_steps_count = len(list(session_chunked_dir.glob("step_*")))
1272
+
1273
+ # Check if directory does not exist, does not contain the sentinel file, or count of chunked steps is out of sync
1274
+ needs_chunking = (
1275
+ not session_chunked_dir.exists()
1276
+ or not (session_chunked_dir / ".chunked").exists()
1277
+ or db_steps_count != chunked_steps_count
1278
+ )
1279
+
1280
+ if needs_chunking:
1281
+ try:
1282
+ print(
1283
+ f"Chunked data needs update for session {session_id} (DB"
1284
+ f" steps: {db_steps_count}, chunked steps:"
1285
+ f" {chunked_steps_count}). Chunking on-the-fly..."
1286
+ )
1287
+ self.chunk_session_traces(session_id, session_chunked_dir)
1288
+ except Exception as e:
1289
+ print(f"Failed to chunk session {session_id} on-the-fly: {e}")
1290
+ return session_chunked_dir
1291
+
1292
+ def resolve_master_video_path(self, db_recorded_path_str: str) -> Path | None:
1293
+ """Resolves the physical master video recording path on disk, searching for renamed folders."""
1294
+ db_video_path = Path(db_recorded_path_str)
1295
+
1296
+ # 1. Direct absolute path check
1297
+ if db_video_path.exists():
1298
+ return db_video_path
1299
+
1300
+ # 2. Check if relative to workspace
1301
+ workspace_rel_path = self.workspace_root / db_video_path
1302
+ if workspace_rel_path.exists():
1303
+ return workspace_rel_path.resolve()
1304
+
1305
+ # 3. Check if under traces_path
1306
+ traces_rel_path = self.traces_path / db_video_path.name
1307
+ if traces_rel_path.exists():
1308
+ return traces_rel_path.resolve()
1309
+
1310
+ # 4. Resolve folder renaming prefix
1311
+ parent_dir_name = db_video_path.parent.name
1312
+ filename = db_video_path.name
1313
+ for search_dir in [
1314
+ self.traces_path,
1315
+ self.workspace_root / "artemis-traces",
1316
+ ]:
1317
+ if parent_dir_name and search_dir.exists():
1318
+ try:
1319
+ for child in search_dir.iterdir():
1320
+ if child.is_dir() and (
1321
+ child.name.startswith(parent_dir_name) or parent_dir_name in child.name
1322
+ ):
1323
+ candidate = child / filename
1324
+ if candidate.exists():
1325
+ return candidate.resolve()
1326
+ except Exception as e:
1327
+ print(f"Warning: Error while scanning {search_dir} for renamed folder: {e}")
1328
+
1329
+ # 5. Check if under traces_path or artemis-traces with parent_dir_name
1330
+ for search_dir in [
1331
+ self.traces_path,
1332
+ self.workspace_root / "artemis-traces",
1333
+ ]:
1334
+ fallback_path = search_dir / parent_dir_name / filename
1335
+ if fallback_path.exists():
1336
+ return fallback_path.resolve()
1337
+
1338
+ return None
1339
+
1340
+ def calculate_virtual_start_time(self, session_id: str, step_number: int) -> float | None:
1341
+ """Calculates the virtual start time based on the database's final response timestamp."""
1342
+ if not self.db_path.exists():
1343
+ return None
1344
+
1345
+ try:
1346
+ conn = sqlite3.connect(self.db_path)
1347
+ cursor = conn.cursor()
1348
+
1349
+ # Fetch fallback current step timestamp
1350
+ cursor.execute(
1351
+ "SELECT timestamp FROM steps WHERE session_id = ? AND step_number = ?",
1352
+ (session_id, step_number),
1353
+ )
1354
+ row = cursor.fetchone()
1355
+ step_timestamp = row[0] if (row and row[0] is not None) else None
1356
+
1357
+ if step_number <= 1:
1358
+ # First step starts at session start
1359
+ cursor.execute(
1360
+ "SELECT start_time FROM sessions WHERE session_id = ?",
1361
+ (session_id,),
1362
+ )
1363
+ row = cursor.fetchone()
1364
+ db_session_start_time = row[0] if (row and row[0] is not None) else step_timestamp
1365
+ conn.close()
1366
+ return db_session_start_time
1367
+
1368
+ # Get step_id of previous step
1369
+ cursor.execute(
1370
+ "SELECT step_id FROM steps WHERE session_id = ? AND step_number = ?",
1371
+ (session_id, step_number - 1),
1372
+ )
1373
+ row = cursor.fetchone()
1374
+ if not row:
1375
+ conn.close()
1376
+ return step_timestamp
1377
+
1378
+ prev_step_id = row[0]
1379
+
1380
+ # Get maximum trace timestamp for that step (final response timestamp)
1381
+ cursor.execute(
1382
+ "SELECT MAX(timestamp) FROM traces WHERE step_id = ?",
1383
+ (prev_step_id,),
1384
+ )
1385
+ row = cursor.fetchone()
1386
+ conn.close()
1387
+
1388
+ if row and row[0] is not None:
1389
+ db_final_response_timestamp = row[0]
1390
+ return db_final_response_timestamp - 0.001
1391
+
1392
+ return step_timestamp
1393
+ except Exception as e:
1394
+ print(f"Warning: Failed to calculate virtual start time from database: {e}")
1395
+ return None
1396
+
1397
+ def _preemptive_clip_video(
1398
+ self,
1399
+ session_id: str,
1400
+ step_number: int,
1401
+ step_data: dict,
1402
+ temp_db_path: Path,
1403
+ outputs_dir: Path,
1404
+ replay_id: str = None,
1405
+ ):
1406
+ """Locates the master recording, calculates step time bounds, trims the video and updates the temp DB."""
1407
+ import subprocess
1408
+ import uuid
1409
+
1410
+ # 1. Query the master database's video_recordings table
1411
+ if not self.db_path.exists():
1412
+ print(
1413
+ f"Warning: Master database not found at {self.db_path}."
1414
+ " Skipping preemptive video clip."
1415
+ )
1416
+ return
1417
+
1418
+ try:
1419
+ conn_master = sqlite3.connect(self.db_path)
1420
+ cursor = conn_master.cursor()
1421
+ cursor.execute(
1422
+ "SELECT video_id, device_id, start_time, end_time,"
1423
+ " local_video_path FROM video_recordings WHERE session_id = ?",
1424
+ (session_id,),
1425
+ )
1426
+ row = cursor.fetchone()
1427
+ conn_master.close()
1428
+ except Exception as e:
1429
+ print(f"Warning: Failed to query video_recordings from master database: {e}")
1430
+ return
1431
+
1432
+ if not row:
1433
+ print(
1434
+ f"No video recording found for session {session_id} in master"
1435
+ " database. Skipping clip."
1436
+ )
1437
+ return
1438
+
1439
+ (
1440
+ video_id,
1441
+ device_id,
1442
+ video_start_time,
1443
+ video_end_time,
1444
+ db_recorded_path,
1445
+ ) = row
1446
+ if not db_recorded_path:
1447
+ print("Warning: Recorded path is empty in video_recordings. Skipping clip.")
1448
+ return
1449
+
1450
+ # 2. Locate master recording on disk (renaming logic)
1451
+ master_video_path = self.resolve_master_video_path(db_recorded_path)
1452
+ if not master_video_path:
1453
+ print(
1454
+ f"Warning: Could not locate master recording on disk for path: {db_recorded_path}"
1455
+ )
1456
+ return
1457
+
1458
+ # 3. Calculate step bounds using virtual start time
1459
+ step_start = self.calculate_virtual_start_time(session_id, step_number)
1460
+ if step_start is None:
1461
+ step_start = step_data.get("timestamp")
1462
+
1463
+ if step_start is None:
1464
+ print("Warning: Step start timestamp could not be resolved. Skipping clip.")
1465
+ return
1466
+
1467
+ # Look for next step start
1468
+ step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
1469
+ next_step_dir = step_dir.parent / f"step_{step_number + 1:02d}"
1470
+ next_step_json_path = next_step_dir / "step.json"
1471
+
1472
+ if next_step_json_path.exists():
1473
+ try:
1474
+ with open(next_step_json_path, encoding="utf-8") as f:
1475
+ next_step_data = json.load(f)
1476
+ next_step_start = next_step_data.get("timestamp", step_start + 15.0)
1477
+ except Exception as e:
1478
+ print(f"Warning: Failed to read next step json: {e}")
1479
+ next_step_start = step_start + 15.0
1480
+ else:
1481
+ next_step_start = step_start + 15.0
1482
+
1483
+ # Calculate offsets
1484
+ start_offset = step_start - video_start_time
1485
+ # clamp start_offset to be non-negative
1486
+ start_offset = max(0.0, start_offset)
1487
+ end_offset = next_step_start - video_start_time
1488
+ duration = max(0.1, end_offset - start_offset)
1489
+
1490
+ trimmed_video_path = outputs_dir / "recording_trimmed.mp4"
1491
+
1492
+ # 4. Execute FFmpeg trim
1493
+ cmd = [
1494
+ "ffmpeg",
1495
+ "-y",
1496
+ "-i",
1497
+ str(master_video_path),
1498
+ "-ss",
1499
+ f"{start_offset:.3f}",
1500
+ "-t",
1501
+ f"{duration:.3f}",
1502
+ "-c:v",
1503
+ "libx264",
1504
+ str(trimmed_video_path),
1505
+ ]
1506
+ print(
1507
+ f"Trimming video segment from {master_video_path}:"
1508
+ f" offset={start_offset:.3f}s, duration={duration:.3f}s"
1509
+ )
1510
+ try:
1511
+ res = subprocess.run(cmd, capture_output=True, text=True)
1512
+ if res.returncode != 0:
1513
+ print(
1514
+ f"Warning: ffmpeg failed with exit code {res.returncode}. stderr: {res.stderr}"
1515
+ )
1516
+ return
1517
+ except Exception as e:
1518
+ print(f"Warning: Failed to execute ffmpeg command: {e}")
1519
+ return
1520
+
1521
+ # 5. Update Sandbox Database
1522
+ try:
1523
+ conn_temp = sqlite3.connect(temp_db_path)
1524
+ conn_temp.execute("""
1525
+ CREATE TABLE IF NOT EXISTS video_recordings (
1526
+ video_id TEXT PRIMARY KEY,
1527
+ session_id TEXT,
1528
+ device_id TEXT,
1529
+ start_time REAL,
1530
+ end_time REAL,
1531
+ local_video_path TEXT,
1532
+ FOREIGN KEY(session_id) REFERENCES sessions(session_id)
1533
+ )
1534
+ """)
1535
+
1536
+ # Insert original metadata first if not present
1537
+ conn_temp.execute(
1538
+ """
1539
+ INSERT OR IGNORE INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path)
1540
+ VALUES (?, ?, ?, ?, ?, ?)
1541
+ """,
1542
+ (
1543
+ video_id,
1544
+ session_id,
1545
+ device_id,
1546
+ video_start_time,
1547
+ video_end_time,
1548
+ db_recorded_path,
1549
+ ),
1550
+ )
1551
+
1552
+ # Update values
1553
+ conn_temp.execute(
1554
+ """
1555
+ UPDATE video_recordings
1556
+ SET local_video_path = ?, start_time = ?, end_time = ?
1557
+ WHERE session_id = ?
1558
+ """,
1559
+ (
1560
+ str(trimmed_video_path),
1561
+ step_start,
1562
+ next_step_start,
1563
+ session_id,
1564
+ ),
1565
+ )
1566
+
1567
+ if replay_id:
1568
+ # Also copy / update for replay_id
1569
+ conn_temp.execute(
1570
+ """
1571
+ INSERT OR IGNORE INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path)
1572
+ VALUES (?, ?, ?, ?, ?, ?)
1573
+ """,
1574
+ (
1575
+ str(uuid.uuid4()),
1576
+ replay_id,
1577
+ device_id,
1578
+ video_start_time,
1579
+ video_end_time,
1580
+ db_recorded_path,
1581
+ ),
1582
+ )
1583
+
1584
+ conn_temp.execute(
1585
+ """
1586
+ UPDATE video_recordings
1587
+ SET local_video_path = ?, start_time = ?, end_time = ?
1588
+ WHERE session_id = ?
1589
+ """,
1590
+ (
1591
+ str(trimmed_video_path),
1592
+ step_start,
1593
+ next_step_start,
1594
+ replay_id,
1595
+ ),
1596
+ )
1597
+
1598
+ conn_temp.commit()
1599
+ conn_temp.close()
1600
+ print(
1601
+ f"Successfully updated sandbox database at {temp_db_path} with"
1602
+ " trimmed video segment."
1603
+ )
1604
+ except Exception as e:
1605
+ print(f"Warning: Failed to update sandbox SQLite database: {e}")
1606
+
1607
+ def create_ctx(
1608
+ self,
1609
+ session_id: str,
1610
+ step_number: int,
1611
+ agent_name: str = "explorer",
1612
+ replay_id: str = None,
1613
+ ) -> ArtemisContext:
1614
+ """Creates a sandboxed ArtemisContext and DataEngine for the given session and step."""
1615
+ self._ensure_session_chunked(session_id)
1616
+ step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
1617
+ if not step_dir.exists():
1618
+ raise FileNotFoundError(f"Step directory not found at {step_dir}")
1619
+
1620
+ step_json_path = step_dir / "step.json"
1621
+ pre_image_meta_path = step_dir / "pre_image_meta.json"
1622
+ post_image_meta_path = step_dir / "post_image_meta.json"
1623
+
1624
+ with open(step_json_path, encoding="utf-8") as f:
1625
+ step_data = json.load(f)
1626
+
1627
+ pre_image_meta = None
1628
+ if pre_image_meta_path.exists():
1629
+ with open(pre_image_meta_path, encoding="utf-8") as f:
1630
+ pre_image_meta = json.load(f)
1631
+
1632
+ post_image_meta = None
1633
+ if post_image_meta_path.exists():
1634
+ with open(post_image_meta_path, encoding="utf-8") as f:
1635
+ post_image_meta = json.load(f)
1636
+
1637
+ outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}"
1638
+ outputs_dir.mkdir(parents=True, exist_ok=True)
1639
+
1640
+ temp_traces_dir = outputs_dir / "temp_traces"
1641
+ temp_db_path = temp_traces_dir / "data_engine.db"
1642
+
1643
+ _, _, traces_to_preload = self.extract_replay_data(
1644
+ step_dir, str(self.db_path), agent_name=agent_name
1645
+ )
1646
+ initial_goal = self.load_session_goal(session_id, step_dir, str(self.db_path))
1647
+
1648
+ self.setup_temporary_database(
1649
+ temp_db_path,
1650
+ temp_traces_dir,
1651
+ session_id,
1652
+ step_data,
1653
+ pre_image_meta,
1654
+ initial_goal,
1655
+ traces_to_preload,
1656
+ post_image_meta=post_image_meta,
1657
+ replay_id=replay_id,
1658
+ )
1659
+
1660
+ try:
1661
+ self._preemptive_clip_video(
1662
+ session_id=session_id,
1663
+ step_number=step_number,
1664
+ step_data=step_data,
1665
+ temp_db_path=temp_db_path,
1666
+ outputs_dir=outputs_dir,
1667
+ replay_id=replay_id,
1668
+ )
1669
+ except Exception as e:
1670
+ print(f"Warning: Pre-emptive video clipping failed: {e}")
1671
+
1672
+ try:
1673
+ from artemis.context import (
1674
+ ArtemisContext,
1675
+ DeviceContext,
1676
+ DevicePlatform,
1677
+ ExecutionSetup,
1678
+ )
1679
+ from artemis.data_engine.engine import DataEngine
1680
+
1681
+ ArtemisContext.model_rebuild()
1682
+ except ImportError as import_err:
1683
+ raise ImportError(f"Failed to import Artemis core modules: {import_err}")
1684
+
1685
+ import sqlite3
1686
+
1687
+ conn = sqlite3.connect(self.db_path)
1688
+ cursor = conn.cursor()
1689
+ cursor.execute(
1690
+ "SELECT device_info FROM sessions WHERE session_id = ?",
1691
+ (session_id,),
1692
+ )
1693
+ row = cursor.fetchone()
1694
+ conn.close()
1695
+
1696
+ device_info = {}
1697
+ if row and row[0]:
1698
+ try:
1699
+ device_info = json.loads(row[0])
1700
+ except (ValueError, TypeError):
1701
+ # Malformed device_info: simulate with the defaults below.
1702
+ pass
1703
+
1704
+ sim_device_id = device_info.get("device_id", "replay-device")
1705
+ sim_w = device_info.get("device_width", 1080)
1706
+ sim_h = device_info.get("device_height", 2400)
1707
+
1708
+ device_context = DeviceContext(
1709
+ host_platform="LINUX",
1710
+ mobile_platform=DevicePlatform.ANDROID,
1711
+ device_id=sim_device_id,
1712
+ device_width=sim_w,
1713
+ device_height=sim_h,
1714
+ )
1715
+ print(f"Replay simulated device: {sim_device_id} ({sim_w}x{sim_h})")
1716
+
1717
+ from artemis.config import get_default_llm_config
1718
+
1719
+ try:
1720
+ llm_cfg = get_default_llm_config()
1721
+ except Exception as e:
1722
+ print(f"Warning: Failed to load default LLM config: {e}")
1723
+ llm_cfg = None
1724
+
1725
+ ctx = ArtemisContext(
1726
+ device=device_context,
1727
+ adb_client=None,
1728
+ ui_adb_client=None,
1729
+ execution_setup=ExecutionSetup(traces_path=temp_traces_dir),
1730
+ llm_config=llm_cfg,
1731
+ )
1732
+
1733
+ data_engine = DataEngine(ctx)
1734
+ data_engine.current_session_id = replay_id or session_id
1735
+ data_engine.current_step_id = step_data["step_id"]
1736
+ data_engine.current_step_dir = outputs_dir
1737
+ ctx.data_engine = data_engine
1738
+
1739
+ settings.DATA_ENGINE_DB_PATH = temp_db_path
1740
+ settings.TRACES_PATH = temp_traces_dir
1741
+ os.environ["DATA_ENGINE_DB_PATH"] = str(temp_db_path)
1742
+
1743
+ return ctx
1744
+
1745
+ def instantiate_state(
1746
+ self,
1747
+ session_id: str,
1748
+ step_number: int,
1749
+ ) -> State:
1750
+ """Instantiates the State object for the given session and step number."""
1751
+ self._ensure_session_chunked(session_id)
1752
+ step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
1753
+ if not step_dir.exists():
1754
+ raise FileNotFoundError(f"Step directory not found at {step_dir}")
1755
+
1756
+ step_json_path = step_dir / "step.json"
1757
+ pre_image_meta_path = step_dir / "pre_image_meta.json"
1758
+
1759
+ with open(step_json_path, encoding="utf-8") as f:
1760
+ step_data = json.load(f)
1761
+
1762
+ pre_image_meta = None
1763
+ if pre_image_meta_path.exists():
1764
+ with open(pre_image_meta_path, encoding="utf-8") as f:
1765
+ pre_image_meta = json.load(f)
1766
+
1767
+ initial_goal = self.load_session_goal(session_id, step_dir, str(self.db_path))
1768
+
1769
+ # State has no fields for the app info or device date (extra="forbid").
1770
+ ui_hier, decisions, _app_info, _dev_date = self.extract_state_prepopulation_data(
1771
+ step_dir, step_data, pre_image_meta
1772
+ )
1773
+
1774
+ try:
1775
+ from artemis.graph.state import State
1776
+ except ImportError as import_err:
1777
+ raise ImportError(f"Failed to import State module: {import_err}")
1778
+
1779
+ state = State(
1780
+ initial_goal=initial_goal,
1781
+ latest_screenshot=str(step_dir / "pre.jpg")
1782
+ if (step_dir / "pre.jpg").exists()
1783
+ else str(step_dir / "post.jpg"),
1784
+ operator_raw_data={"width": self.w, "height": self.h},
1785
+ current_step_id=step_data["step_id"],
1786
+ subagent_calls=step_data.get("subagent_calls") or [],
1787
+ latest_ui_hierarchy=ui_hier,
1788
+ structured_decisions=decisions,
1789
+ )
1790
+ return state
1791
+
1792
+ def generate_replay_id(self) -> str:
1793
+ """Generates a new unique replay ID."""
1794
+ import uuid
1795
+
1796
+ return str(uuid.uuid4())
1797
+
1798
+ async def replay_step_tool(
1799
+ self,
1800
+ session_id: str,
1801
+ step_number: int,
1802
+ override_device_id: str = None,
1803
+ user_submits: dict = None,
1804
+ tool_name: str = "ask_explorer",
1805
+ replay_id: str = None,
1806
+ ) -> list[dict]:
1807
+ """Replays a specific step tool in a sandboxed execution environment."""
1808
+ if not replay_id:
1809
+ replay_id = self.generate_replay_id()
1810
+
1811
+ replay_result = {
1812
+ "session_id": session_id,
1813
+ "step_number": step_number,
1814
+ "replay_id": replay_id,
1815
+ "success": False,
1816
+ }
1817
+ tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name)
1818
+ if not tool_cfg:
1819
+ raise ValueError(f"Unknown tool for replay: {tool_name}")
1820
+ agent_name = tool_cfg["agent_name"]
1821
+
1822
+ outputs_parent = self.test_outputs_dir
1823
+ if outputs_parent.exists():
1824
+ for item in outputs_parent.iterdir():
1825
+ if item.is_dir() and item.name.startswith(f"{session_id}_step_"):
1826
+ archive_dir = self.replay_base_dir / "data" / "older"
1827
+ archive_dir.mkdir(parents=True, exist_ok=True)
1828
+
1829
+ from datetime import datetime
1830
+
1831
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1832
+ target_name = f"{item.name}_{timestamp}"
1833
+ target_path = archive_dir / target_name
1834
+
1835
+ counter = 1
1836
+ while target_path.exists():
1837
+ target_name = f"{item.name}_{timestamp}_{counter}"
1838
+ target_path = archive_dir / target_name
1839
+ counter += 1
1840
+
1841
+ print(
1842
+ f"Archiving previous run output directory {item.name} to {target_path}..."
1843
+ )
1844
+ try:
1845
+ shutil.move(str(item), str(target_path))
1846
+ except Exception as archive_err:
1847
+ print(
1848
+ f"Warning: Failed to archive {item.name}: {archive_err}",
1849
+ file=sys.stderr,
1850
+ )
1851
+
1852
+ outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}"
1853
+ temp_db_path = outputs_dir / "temp_traces" / "data_engine.db"
1854
+
1855
+ # Load step data locally for fallback mapping resolutions
1856
+ step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
1857
+ step_json_path = step_dir / "step.json"
1858
+ with open(step_json_path, encoding="utf-8") as f:
1859
+ step_data = json.load(f)
1860
+
1861
+ # Retrieve preloaded trace IDs from chunked trace files to filter new tool calls
1862
+ _, _, traces_to_preload = self.extract_replay_data(
1863
+ step_dir, str(self.db_path), agent_name=agent_name
1864
+ )
1865
+ preloaded_trace_ids = {t.get("trace_id") for t in traces_to_preload if t.get("trace_id")}
1866
+ llm_calls_log = []
1867
+
1868
+ # Device initialization removed for step replay
1869
+
1870
+ ctx = self.create_ctx(
1871
+ session_id=session_id,
1872
+ step_number=step_number,
1873
+ agent_name=agent_name,
1874
+ replay_id=replay_id,
1875
+ )
1876
+
1877
+ state = self.instantiate_state(
1878
+ session_id=session_id,
1879
+ step_number=step_number,
1880
+ )
1881
+
1882
+ data_engine = ctx.data_engine
1883
+
1884
+ print("Executing using Live Gemini Client...")
1885
+
1886
+ from google.genai.models import AsyncModels
1887
+ from unittest.mock import patch
1888
+
1889
+ original_generate_content = AsyncModels.generate_content
1890
+
1891
+ async def wrapped_generate_content(self, *, model, contents, config=None, **kwargs):
1892
+ system_instruction = None
1893
+ if config and config.system_instruction:
1894
+ if isinstance(config.system_instruction, str):
1895
+ system_instruction = config.system_instruction
1896
+ elif hasattr(config.system_instruction, "parts"):
1897
+ parts = []
1898
+ for p in config.system_instruction.parts:
1899
+ if hasattr(p, "text") and p.text:
1900
+ parts.append(p.text)
1901
+ system_instruction = " ".join(parts)
1902
+ else:
1903
+ system_instruction = str(config.system_instruction)
1904
+
1905
+ prompt_data = {
1906
+ "model": model,
1907
+ "system_instruction": system_instruction,
1908
+ "contents": [],
1909
+ }
1910
+
1911
+ contents_list = contents if isinstance(contents, list) else [contents]
1912
+ for content in contents_list:
1913
+ parts_desc = []
1914
+ if hasattr(content, "parts") and content.parts:
1915
+ for part in content.parts:
1916
+ if hasattr(part, "text") and part.text:
1917
+ parts_desc.append({"type": "text", "text": part.text})
1918
+ elif hasattr(part, "inline_data") and part.inline_data:
1919
+ parts_desc.append(
1920
+ {
1921
+ "type": "image",
1922
+ "mime_type": part.inline_data.mime_type,
1923
+ "length": len(part.inline_data.data),
1924
+ }
1925
+ )
1926
+ elif hasattr(part, "function_call") and part.function_call:
1927
+ parts_desc.append(
1928
+ {
1929
+ "type": "function_call",
1930
+ "name": part.function_call.name,
1931
+ "args": (
1932
+ dict(part.function_call.args)
1933
+ if part.function_call.args
1934
+ else {}
1935
+ ),
1936
+ }
1937
+ )
1938
+ elif hasattr(part, "function_call") and part.function_response:
1939
+ parts_desc.append(
1940
+ {
1941
+ "type": "function_response",
1942
+ "name": part.function_response.name,
1943
+ "response": (
1944
+ dict(part.function_response.response)
1945
+ if part.function_response.response
1946
+ else {}
1947
+ ),
1948
+ }
1949
+ )
1950
+ else:
1951
+ parts_desc.append({"type": "raw", "value": str(content)})
1952
+
1953
+ prompt_data["contents"].append(
1954
+ {
1955
+ "role": getattr(content, "role", "unknown"),
1956
+ "parts": parts_desc,
1957
+ }
1958
+ )
1959
+
1960
+ record = {"prompt": prompt_data, "response": None, "error": None}
1961
+ llm_calls_log.append(record)
1962
+
1963
+ try:
1964
+ res = await original_generate_content(
1965
+ self,
1966
+ model=model,
1967
+ contents=contents,
1968
+ config=config,
1969
+ **kwargs,
1970
+ )
1971
+
1972
+ thinking_parts = []
1973
+ if hasattr(res, "candidates") and res.candidates and res.candidates[0].content:
1974
+ for part in res.candidates[0].content.parts:
1975
+ if getattr(part, "thought", False):
1976
+ thinking_parts.append(part.text)
1977
+
1978
+ resp_data = {
1979
+ "text": res.text if hasattr(res, "text") else None,
1980
+ "function_calls": [],
1981
+ "thought": ("\n".join(thinking_parts) if thinking_parts else None),
1982
+ }
1983
+ if hasattr(res, "function_calls") and res.function_calls:
1984
+ for fc in res.function_calls:
1985
+ resp_data["function_calls"].append(
1986
+ {
1987
+ "name": fc.name,
1988
+ "args": dict(fc.args) if fc.args else {},
1989
+ }
1990
+ )
1991
+ record["response"] = resp_data
1992
+ return res
1993
+ except Exception as err:
1994
+ err_str = str(err)
1995
+ record["error"] = (
1996
+ f"{err.__class__.__name__}: {err_str}" if err_str else err.__class__.__name__
1997
+ )
1998
+ raise err
1999
+
2000
+ try:
2001
+ try:
2002
+ import inspect
2003
+ import importlib
2004
+
2005
+ mod = importlib.import_module(tool_cfg["module"])
2006
+ tool_fn = getattr(mod, tool_cfg["function"])
2007
+
2008
+ user_submits = user_submits or {}
2009
+ sig = inspect.signature(tool_fn)
2010
+ tool_args = {}
2011
+ denylist = tool_cfg.get("denylist_args", [])
2012
+ fallback_mappings = tool_cfg.get("fallback_mappings", {})
2013
+
2014
+ user_submits = user_submits or {}
2015
+ sig = inspect.signature(tool_fn)
2016
+ tool_args = {}
2017
+ for name, param in sig.parameters.items():
2018
+ if name in denylist:
2019
+ if name == "ctx":
2020
+ tool_args["ctx"] = ctx
2021
+ elif name == "state":
2022
+ tool_args["state"] = state
2023
+ continue
2024
+
2025
+ if name in user_submits:
2026
+ val = user_submits[name]
2027
+ if param.annotation is bool and isinstance(val, str):
2028
+ val = val.lower() == "true"
2029
+ tool_args[name] = val
2030
+ else:
2031
+ val = ""
2032
+ if param.default is not inspect.Parameter.empty:
2033
+ val = param.default
2034
+ if name in fallback_mappings:
2035
+ fallback_path = fallback_mappings[name]
2036
+ resolved_val = self.resolve_step_data_path(step_data, fallback_path)
2037
+ if resolved_val is not None:
2038
+ val = resolved_val
2039
+ tool_args[name] = val
2040
+
2041
+ with patch.object(AsyncModels, "generate_content", wrapped_generate_content):
2042
+ outcome = await tool_fn(**tool_args)
2043
+
2044
+ replay_result["success"] = True
2045
+ replay_result["result"] = outcome
2046
+ print(f"Replay Execution complete. Outcome: {outcome}")
2047
+
2048
+ except Exception as replay_err:
2049
+ print(f"Error during step replay: {replay_err}", file=sys.stderr)
2050
+ traceback.print_exc()
2051
+
2052
+ replay_result["success"] = False
2053
+ replay_result["error"] = (
2054
+ f"{replay_err.__class__.__name__}: {str(replay_err)}"
2055
+ if str(replay_err)
2056
+ else replay_err.__class__.__name__
2057
+ )
2058
+ raise replay_err
2059
+ finally:
2060
+ await data_engine.shutdown()
2061
+
2062
+ tool_calls = []
2063
+ if temp_db_path.exists():
2064
+ try:
2065
+ tool_calls = self.extract_tool_calls_from_db(temp_db_path, preloaded_trace_ids)
2066
+ except Exception as ex:
2067
+ print(
2068
+ f"Warning: Failed to extract tool calls: {ex}",
2069
+ file=sys.stderr,
2070
+ )
2071
+
2072
+ replay_result["tool_calls"] = tool_calls
2073
+ replay_result["llm_calls"] = llm_calls_log
2074
+
2075
+ outcome_path = outputs_dir / "result.json"
2076
+ try:
2077
+ with open(outcome_path, "w", encoding="utf-8") as f:
2078
+ json.dump(replay_result, f, indent=2, ensure_ascii=False)
2079
+ except Exception as write_err:
2080
+ print(
2081
+ f"Warning: Failed to write result.json: {write_err}",
2082
+ file=sys.stderr,
2083
+ )
2084
+
2085
+ return llm_calls_log
2086
+
2087
+ def get_replay_config(self, tool_name: str = "ask_explorer") -> list[dict]:
2088
+ """Returns the parameter list and types of the selected tool logic."""
2089
+ tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name)
2090
+ if not tool_cfg:
2091
+ raise ValueError(f"Unknown tool for replay: {tool_name}")
2092
+
2093
+ try:
2094
+ import inspect
2095
+ import importlib
2096
+
2097
+ mod = importlib.import_module(tool_cfg["module"])
2098
+ tool_fn = getattr(mod, tool_cfg["function"])
2099
+ sig = inspect.signature(tool_fn)
2100
+
2101
+ denylist = tool_cfg.get("denylist_args", [])
2102
+ explorer_params = []
2103
+ for name, param in sig.parameters.items():
2104
+ if name in denylist:
2105
+ continue
2106
+ default_val = ""
2107
+ if param.default is not inspect.Parameter.empty:
2108
+ default_val = param.default
2109
+
2110
+ input_type = "text"
2111
+ if any(
2112
+ keyword in name.lower()
2113
+ for keyword in (
2114
+ "feedback",
2115
+ "context",
2116
+ "description",
2117
+ "note",
2118
+ )
2119
+ ):
2120
+ input_type = "textarea"
2121
+ elif param.annotation is bool:
2122
+ input_type = "checkbox"
2123
+ elif param.annotation in (int, float):
2124
+ input_type = "number"
2125
+
2126
+ explorer_params.append(
2127
+ {
2128
+ "name": name,
2129
+ "default": default_val,
2130
+ "input_type": input_type,
2131
+ }
2132
+ )
2133
+ return explorer_params
2134
+ except Exception as e:
2135
+ raise Exception(f"Failed to load signature for {tool_name}: {str(e)}")
2136
+
2137
+ def get_replay_tools(self) -> list[dict]:
2138
+ """Returns the list of registered tools available for replay."""
2139
+ return [
2140
+ {
2141
+ "name": name,
2142
+ "display_name": cfg["display_name"],
2143
+ "description": cfg["description"],
2144
+ }
2145
+ for name, cfg in REPLAY_TOOLS_CONFIG.items()
2146
+ ]
2147
+
2148
+ def get_replay_steps(self, session_id: str) -> list[dict]:
2149
+ """Loads and formats metadata for all chunked steps in the session."""
2150
+ session_chunked_dir = self._ensure_session_chunked(session_id)
2151
+ if not session_chunked_dir.exists():
2152
+ raise FileNotFoundError("Step replay chunked data not found for this session.")
2153
+
2154
+ try:
2155
+ steps_metadata = self.load_steps_metadata(session_id)
2156
+ step_numbers = sorted(list(steps_metadata.keys()))
2157
+
2158
+ import inspect
2159
+ import importlib
2160
+
2161
+ # Pre-load signatures and configs for all registered tools
2162
+ tool_signatures = {}
2163
+ for t_name, t_cfg in REPLAY_TOOLS_CONFIG.items():
2164
+ try:
2165
+ mod = importlib.import_module(t_cfg["module"])
2166
+ fn = getattr(mod, t_cfg["function"])
2167
+ tool_signatures[t_name] = inspect.signature(fn)
2168
+ except Exception as e:
2169
+ print(
2170
+ f"Warning: Failed to load signature for tool {t_name}: {e}",
2171
+ file=sys.stderr,
2172
+ )
2173
+
2174
+ steps_list = []
2175
+ for num in step_numbers:
2176
+ s_data = steps_metadata.get(num, {})
2177
+ sid = s_data.get("step_id")
2178
+
2179
+ pre_url = None
2180
+ post_url = None
2181
+ step_dir = session_chunked_dir / f"step_{num:02d}"
2182
+ if step_dir.exists():
2183
+ pre_jpg = step_dir / "pre.jpg"
2184
+ if pre_jpg.exists():
2185
+ pre_url = f"file://{pre_jpg.resolve()}"
2186
+ post_jpg = step_dir / "post.jpg"
2187
+ if post_jpg.exists():
2188
+ post_url = f"file://{post_jpg.resolve()}"
2189
+ if pre_url and post_url and pre_url == post_url:
2190
+ post_url = None
2191
+
2192
+ # Generate param_defaults for all registered tools
2193
+ all_tool_defaults = {}
2194
+ for t_name, sig in tool_signatures.items():
2195
+ t_cfg = REPLAY_TOOLS_CONFIG[t_name]
2196
+ denylist = t_cfg.get("denylist_args", [])
2197
+ fallback_mappings = t_cfg.get("fallback_mappings", {})
2198
+
2199
+ defaults = {}
2200
+ for name, param in sig.parameters.items():
2201
+ if name in denylist:
2202
+ continue
2203
+
2204
+ val = ""
2205
+ if param.default is not inspect.Parameter.empty:
2206
+ val = param.default
2207
+
2208
+ # Resolve fallback using mappings
2209
+ if name in fallback_mappings:
2210
+ mapping_path = fallback_mappings[name]
2211
+ parts = mapping_path.split(".")
2212
+ curr = s_data
2213
+ for part in parts:
2214
+ if isinstance(curr, dict):
2215
+ curr = curr.get(part)
2216
+ elif isinstance(curr, list):
2217
+ try:
2218
+ idx = int(part)
2219
+ if idx < len(curr):
2220
+ curr = curr[idx]
2221
+ else:
2222
+ curr = None
2223
+ except ValueError:
2224
+ curr = None
2225
+ else:
2226
+ curr = None
2227
+
2228
+ if curr is not None:
2229
+ val = curr
2230
+
2231
+ defaults[name] = val
2232
+ all_tool_defaults[t_name] = defaults
2233
+
2234
+ # Check if this step has already been replayed by scanning outputs folder
2235
+ step_outputs_dir = self.test_outputs_dir / f"{session_id}_step_{num:02d}"
2236
+ step_db_path = step_outputs_dir / "temp_traces" / "data_engine.db"
2237
+ is_replayed = step_db_path.exists()
2238
+
2239
+ steps_list.append(
2240
+ {
2241
+ "step_id": str(sid).lower() if sid else None,
2242
+ "step_number": num,
2243
+ "timestamp": s_data.get("timestamp"),
2244
+ "summary": s_data.get("summary", "No summary available."),
2245
+ "action_taken": s_data.get("action_taken", []),
2246
+ "query": "",
2247
+ "param_defaults": all_tool_defaults,
2248
+ "pre_screenshot": pre_url,
2249
+ "post_screenshot": post_url,
2250
+ "operator_raw_thinking": s_data.get("operator_raw_thinking"),
2251
+ "operator_native_thinking": s_data.get("operator_native_thinking"),
2252
+ "is_replayed": is_replayed,
2253
+ }
2254
+ )
2255
+
2256
+ return steps_list
2257
+ except Exception as e:
2258
+ traceback.print_exc()
2259
+ raise e
2260
+
2261
+ async def run_step_replay(
2262
+ self,
2263
+ session_id: str,
2264
+ step_number: int,
2265
+ device_id: str,
2266
+ user_submits: dict,
2267
+ tool_name: str = "ask_explorer",
2268
+ replay_id: str = None,
2269
+ ) -> dict:
2270
+ """Triggers sandbox execution of step replay and returns the resulting traces trees."""
2271
+ tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name)
2272
+ if not tool_cfg:
2273
+ raise ValueError(f"Unknown tool for replay: {tool_name}")
2274
+ agent_name = tool_cfg["agent_name"]
2275
+
2276
+ # 1. Run live step replay
2277
+ llm_calls_log = await self.replay_step_tool(
2278
+ session_id=session_id,
2279
+ step_number=step_number,
2280
+ override_device_id=device_id,
2281
+ user_submits=user_submits,
2282
+ tool_name=tool_name,
2283
+ replay_id=replay_id,
2284
+ )
2285
+
2286
+ # 2. Extract freshly generated traces
2287
+ outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}"
2288
+ temp_db_path = outputs_dir / "temp_traces" / "data_engine.db"
2289
+
2290
+ self._ensure_session_chunked(session_id)
2291
+ step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
2292
+ _, _, traces_to_preload = self.extract_replay_data(
2293
+ step_dir, str(self.db_path), agent_name=agent_name
2294
+ )
2295
+ preloaded_trace_ids = {t.get("trace_id") for t in traces_to_preload if t.get("trace_id")}
2296
+
2297
+ all_traces = self.load_all_session_traces(temp_db_path, preloaded_trace_ids, step_number)
2298
+
2299
+ # Align LLM calls and extract final tool result from result.json if available
2300
+ replay_outcome = None
2301
+ replay_id = None
2302
+ result_json_path = outputs_dir / "result.json"
2303
+ if result_json_path.exists():
2304
+ try:
2305
+ with open(result_json_path, encoding="utf-8") as f:
2306
+ res_data = json.load(f)
2307
+ replay_outcome = res_data.get("result")
2308
+ replay_id = res_data.get("replay_id")
2309
+ except (OSError, ValueError) as e:
2310
+ print(
2311
+ f"Warning: Failed to read replay result from {result_json_path}: {e}",
2312
+ file=sys.stderr,
2313
+ )
2314
+
2315
+ # Update virtual tool trace result with the actual formatted outcome if available
2316
+ if replay_outcome is not None:
2317
+ for node in all_traces:
2318
+ if (
2319
+ not node.get("is_preloaded")
2320
+ and node["type"] == "tool"
2321
+ and node["name"] == tool_name
2322
+ ):
2323
+ node["payload"]["result"] = replay_outcome
2324
+
2325
+ # Collect all valid LLM span names from REPLAY_TOOLS_CONFIG to align subagent calls too
2326
+ all_llm_span_names = {
2327
+ cfg.get("llm_span_name")
2328
+ for cfg in REPLAY_TOOLS_CONFIG.values()
2329
+ if cfg.get("llm_span_name")
2330
+ }
2331
+
2332
+ llm_call_index = 0
2333
+ for node in all_traces:
2334
+ if (
2335
+ not node.get("is_preloaded")
2336
+ and node["type"] == "span"
2337
+ and node["name"] in all_llm_span_names
2338
+ and llm_calls_log
2339
+ and llm_call_index < len(llm_calls_log)
2340
+ ):
2341
+ node["type"] = "llm_call"
2342
+ node["llm_call"] = llm_calls_log[llm_call_index]
2343
+ llm_call_index += 1
2344
+
2345
+ # Clean payload paths
2346
+ for node in all_traces:
2347
+ if node.get("payload"):
2348
+ node["payload"] = self.clean_payload_paths(node["payload"])
2349
+ if node.get("llm_call"):
2350
+ node["llm_call"] = self.clean_payload_paths(node["llm_call"])
2351
+
2352
+ # Filter all_traces for ONLY this step's traces
2353
+ step_traces = [t for t in all_traces if t.get("step_number") == step_number]
2354
+
2355
+ preloaded_traces = [t for t in step_traces if t.get("is_preloaded")]
2356
+ live_traces = [t for t in step_traces if not t.get("is_preloaded")]
2357
+
2358
+ # Build preloaded hierarchy
2359
+ preloaded_nodes = {
2360
+ str(t["trace_id"]).lower(): {**t, "children": []} for t in preloaded_traces
2361
+ }
2362
+ preloaded_root_nodes = []
2363
+ for node_id, node in preloaded_nodes.items():
2364
+ parent_id = node.get("parent_trace_id")
2365
+ if parent_id:
2366
+ parent_id_str = str(parent_id).lower()
2367
+ if parent_id_str in preloaded_nodes:
2368
+ preloaded_nodes[parent_id_str]["children"].append(node)
2369
+ else:
2370
+ preloaded_root_nodes.append(node)
2371
+ else:
2372
+ preloaded_root_nodes.append(node)
2373
+ preloaded_root_nodes = self.sanitize_nodes(preloaded_root_nodes)
2374
+ preloaded_root_nodes = self.sort_tree_nodes_chronologically(preloaded_root_nodes)
2375
+
2376
+ # Build live hierarchy
2377
+ live_nodes = {str(t["trace_id"]).lower(): {**t, "children": []} for t in live_traces}
2378
+
2379
+ # Add a virtual "Response" child node at the end of the virtual tool trace to show the final outcome at the bottom of the tree
2380
+ if replay_outcome is not None:
2381
+ virtual_tool_node_id = None
2382
+ for node_id, node in live_nodes.items():
2383
+ if node.get("type") == "tool" and node.get("name") == tool_name:
2384
+ virtual_tool_node_id = node_id
2385
+ break
2386
+
2387
+ if virtual_tool_node_id:
2388
+ import uuid
2389
+
2390
+ outcome_node_id = str(uuid.uuid4())
2391
+ max_ts = (
2392
+ max([t.get("timestamp", 0.0) for t in live_traces])
2393
+ if live_traces
2394
+ else live_nodes[virtual_tool_node_id].get("timestamp", 0.0)
2395
+ )
2396
+
2397
+ outcome_node = {
2398
+ "trace_id": outcome_node_id,
2399
+ "session_id": session_id,
2400
+ "step_id": live_nodes[virtual_tool_node_id].get("step_id"),
2401
+ "parent_trace_id": live_nodes[virtual_tool_node_id]["trace_id"],
2402
+ "type": "tool",
2403
+ "name": f"{tool_name} Response",
2404
+ "status": (live_nodes[virtual_tool_node_id].get("status", "success")),
2405
+ "timestamp": max_ts + 0.001,
2406
+ "duration": 0.0,
2407
+ "payload": {"result": replay_outcome},
2408
+ "is_preloaded": False,
2409
+ "step_number": step_number,
2410
+ "children": [],
2411
+ }
2412
+ live_nodes[outcome_node_id] = outcome_node
2413
+
2414
+ live_root_nodes = []
2415
+ for node_id, node in live_nodes.items():
2416
+ parent_id = node.get("parent_trace_id")
2417
+ if parent_id:
2418
+ parent_id_str = str(parent_id).lower()
2419
+ if parent_id_str in live_nodes:
2420
+ live_nodes[parent_id_str]["children"].append(node)
2421
+ else:
2422
+ live_root_nodes.append(node)
2423
+ else:
2424
+ live_root_nodes.append(node)
2425
+ live_root_nodes = self.sanitize_nodes(live_root_nodes)
2426
+ live_root_nodes = self.sort_tree_nodes_chronologically(live_root_nodes)
2427
+
2428
+ return {
2429
+ "success": True,
2430
+ "preloaded": preloaded_root_nodes,
2431
+ "live": live_root_nodes,
2432
+ "replay_id": replay_id,
2433
+ }
2434
+
2435
+ def get_step_replay_traces(
2436
+ self, session_id: str, step_number: int, tool_name: str = "ask_explorer"
2437
+ ) -> dict:
2438
+ """Retrieves previously generated step replay traces if they exist."""
2439
+ tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name)
2440
+ if not tool_cfg:
2441
+ raise ValueError(f"Unknown tool for replay: {tool_name}")
2442
+ agent_name = tool_cfg["agent_name"]
2443
+
2444
+ outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}"
2445
+ temp_db_path = outputs_dir / "temp_traces" / "data_engine.db"
2446
+
2447
+ if not temp_db_path.exists():
2448
+ return {"success": True, "live": []}
2449
+
2450
+ self._ensure_session_chunked(session_id)
2451
+ step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}"
2452
+ preloaded_trace_ids = set()
2453
+ if step_dir.exists():
2454
+ _, _, traces_to_preload = self.extract_replay_data(
2455
+ step_dir,
2456
+ str(self.db_path),
2457
+ agent_name=agent_name,
2458
+ tool_name=tool_name,
2459
+ )
2460
+ preloaded_trace_ids = {
2461
+ t.get("trace_id") for t in traces_to_preload if t.get("trace_id")
2462
+ }
2463
+
2464
+ all_traces = self.load_all_session_traces(temp_db_path, preloaded_trace_ids, step_number)
2465
+
2466
+ llm_span_name = tool_cfg.get("llm_span_name")
2467
+ if not llm_span_name:
2468
+ raise ValueError(f"Tool {tool_name} config is missing 'llm_span_name'")
2469
+
2470
+ # Align LLM calls and extract final tool result from result.json if available
2471
+ llm_calls_log = []
2472
+ replay_outcome = None
2473
+ replay_id = None
2474
+ result_json_path = outputs_dir / "result.json"
2475
+ if result_json_path.exists():
2476
+ try:
2477
+ with open(result_json_path, encoding="utf-8") as f:
2478
+ res_data = json.load(f)
2479
+ llm_calls_log = res_data.get("llm_calls") or []
2480
+ replay_outcome = res_data.get("result")
2481
+ replay_id = res_data.get("replay_id")
2482
+ except (OSError, ValueError) as e:
2483
+ print(
2484
+ f"Warning: Failed to read replay result from {result_json_path}: {e}",
2485
+ file=sys.stderr,
2486
+ )
2487
+
2488
+ # Update virtual tool trace result with the actual formatted outcome if available
2489
+ if replay_outcome is not None:
2490
+ for node in all_traces:
2491
+ if (
2492
+ not node.get("is_preloaded")
2493
+ and node["type"] == "tool"
2494
+ and node["name"] == tool_name
2495
+ ):
2496
+ node["payload"]["result"] = replay_outcome
2497
+
2498
+ # Collect all valid LLM span names from REPLAY_TOOLS_CONFIG to align subagent calls too
2499
+ all_llm_span_names = {
2500
+ cfg.get("llm_span_name")
2501
+ for cfg in REPLAY_TOOLS_CONFIG.values()
2502
+ if cfg.get("llm_span_name")
2503
+ }
2504
+
2505
+ llm_call_index = 0
2506
+ for node in all_traces:
2507
+ if (
2508
+ not node.get("is_preloaded")
2509
+ and node["type"] == "span"
2510
+ and node["name"] in all_llm_span_names
2511
+ and llm_calls_log
2512
+ and llm_call_index < len(llm_calls_log)
2513
+ ):
2514
+ node["type"] = "llm_call"
2515
+ node["llm_call"] = llm_calls_log[llm_call_index]
2516
+ llm_call_index += 1
2517
+
2518
+ # Clean payload paths
2519
+ for node in all_traces:
2520
+ if node.get("payload"):
2521
+ node["payload"] = self.clean_payload_paths(node["payload"])
2522
+ if node.get("llm_call"):
2523
+ node["llm_call"] = self.clean_payload_paths(node["llm_call"])
2524
+
2525
+ # Filter all_traces for ONLY this step's traces
2526
+ step_traces = [t for t in all_traces if t.get("step_number") == step_number]
2527
+ live_traces = [t for t in step_traces if not t.get("is_preloaded")]
2528
+
2529
+ # Build live hierarchy
2530
+ live_nodes = {str(t["trace_id"]).lower(): {**t, "children": []} for t in live_traces}
2531
+
2532
+ # Add a virtual "Response" child node at the end of the virtual tool trace to show the final outcome at the bottom of the tree
2533
+ if replay_outcome is not None:
2534
+ virtual_tool_node_id = None
2535
+ for node_id, node in live_nodes.items():
2536
+ if node.get("type") == "tool" and node.get("name") == tool_name:
2537
+ virtual_tool_node_id = node_id
2538
+ break
2539
+
2540
+ if virtual_tool_node_id:
2541
+ import uuid
2542
+
2543
+ outcome_node_id = str(uuid.uuid4())
2544
+ max_ts = (
2545
+ max([t.get("timestamp", 0.0) for t in live_traces])
2546
+ if live_traces
2547
+ else live_nodes[virtual_tool_node_id].get("timestamp", 0.0)
2548
+ )
2549
+
2550
+ outcome_node = {
2551
+ "trace_id": outcome_node_id,
2552
+ "session_id": session_id,
2553
+ "step_id": live_nodes[virtual_tool_node_id].get("step_id"),
2554
+ "parent_trace_id": live_nodes[virtual_tool_node_id]["trace_id"],
2555
+ "type": "tool",
2556
+ "name": f"{tool_name} Response",
2557
+ "status": (live_nodes[virtual_tool_node_id].get("status", "success")),
2558
+ "timestamp": max_ts + 0.001,
2559
+ "duration": 0.0,
2560
+ "payload": {"result": replay_outcome},
2561
+ "is_preloaded": False,
2562
+ "step_number": step_number,
2563
+ "children": [],
2564
+ }
2565
+ live_nodes[outcome_node_id] = outcome_node
2566
+
2567
+ live_root_nodes = []
2568
+ for node_id, node in live_nodes.items():
2569
+ parent_id = node.get("parent_trace_id")
2570
+ if parent_id:
2571
+ parent_id_str = str(parent_id).lower()
2572
+ if parent_id_str in live_nodes:
2573
+ live_nodes[parent_id_str]["children"].append(node)
2574
+ else:
2575
+ live_root_nodes.append(node)
2576
+ else:
2577
+ live_root_nodes.append(node)
2578
+ live_root_nodes = self.sanitize_nodes(live_root_nodes)
2579
+ live_root_nodes = self.sort_tree_nodes_chronologically(live_root_nodes)
2580
+
2581
+ return {
2582
+ "success": True,
2583
+ "live": live_root_nodes,
2584
+ "replay_id": replay_id,
2585
+ }