android-midscene-automation 0.2.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (525) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +159 -29
  3. package/artemis-main/.dockerignore +15 -0
  4. package/artemis-main/.env.example +34 -0
  5. package/artemis-main/CONTRIBUTING.md +35 -0
  6. package/artemis-main/Dockerfile +68 -0
  7. package/artemis-main/LICENSE +202 -0
  8. package/artemis-main/Makefile +115 -0
  9. package/artemis-main/README.md +322 -0
  10. package/artemis-main/README_CN.md +320 -0
  11. package/artemis-main/apps/__init__.py +15 -0
  12. package/artemis-main/apps/admin_console/__init__.py +19 -0
  13. package/artemis-main/apps/admin_console/core/__init__.py +13 -0
  14. package/artemis-main/apps/admin_console/core/config.py +43 -0
  15. package/artemis-main/apps/admin_console/core/security.py +178 -0
  16. package/artemis-main/apps/admin_console/core/state.py +243 -0
  17. package/artemis-main/apps/admin_console/database/__init__.py +13 -0
  18. package/artemis-main/apps/admin_console/database/connection.py +60 -0
  19. package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
  20. package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
  21. package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
  22. package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
  23. package/artemis-main/apps/admin_console/index.html +3787 -0
  24. package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
  25. package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
  26. package/artemis-main/apps/admin_console/routers/media.py +198 -0
  27. package/artemis-main/apps/admin_console/routers/replay.py +91 -0
  28. package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
  29. package/artemis-main/apps/admin_console/routers/steps.py +109 -0
  30. package/artemis-main/apps/admin_console/routers/stream.py +56 -0
  31. package/artemis-main/apps/admin_console/routers/system.py +761 -0
  32. package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
  33. package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
  34. package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
  35. package/artemis-main/apps/admin_console/server.py +508 -0
  36. package/artemis-main/apps/admin_console/services/__init__.py +13 -0
  37. package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
  38. package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
  39. package/artemis-main/apps/admin_console/services/media_service.py +623 -0
  40. package/artemis-main/apps/admin_console/services/model_service.py +148 -0
  41. package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
  42. package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
  43. package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
  44. package/artemis-main/apps/showcase_ui/README.md +31 -0
  45. package/artemis-main/apps/showcase_ui/angular.json +132 -0
  46. package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
  47. package/artemis-main/apps/showcase_ui/package.json +39 -0
  48. package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
  49. package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
  50. package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
  51. package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
  52. package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
  53. package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
  54. package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
  55. package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
  56. package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
  57. package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
  58. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
  59. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
  60. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
  61. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
  62. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
  63. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
  64. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
  65. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
  66. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
  67. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
  68. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
  69. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
  70. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
  71. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
  72. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
  73. package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
  74. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
  75. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
  76. package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
  77. package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
  78. package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
  79. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
  80. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
  81. package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
  82. package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
  83. package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
  84. package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
  85. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
  86. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
  87. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
  88. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
  89. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
  90. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
  91. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
  92. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
  93. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
  94. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
  95. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
  96. package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
  97. package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
  98. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
  99. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
  100. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
  101. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
  102. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
  103. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
  104. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
  105. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
  106. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
  107. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
  108. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
  109. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
  110. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
  111. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
  112. package/artemis-main/apps/showcase_ui/src/index.html +37 -0
  113. package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
  114. package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
  115. package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
  116. package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
  117. package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
  118. package/artemis-main/artemis/__init__.py +44 -0
  119. package/artemis-main/artemis/__main__.py +18 -0
  120. package/artemis-main/artemis/_legacy_imports.py +94 -0
  121. package/artemis-main/artemis/_version.py +30 -0
  122. package/artemis-main/artemis/agents/__init__.py +17 -0
  123. package/artemis-main/artemis/agents/checker/checker.json +9 -0
  124. package/artemis-main/artemis/agents/checker/checker.py +859 -0
  125. package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
  126. package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
  127. package/artemis-main/artemis/agents/explorer/constants.py +52 -0
  128. package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
  129. package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
  130. package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
  131. package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
  132. package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
  133. package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
  134. package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
  135. package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
  136. package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
  137. package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
  138. package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
  139. package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
  140. package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
  141. package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
  142. package/artemis-main/artemis/agents/flash/runner.py +1167 -0
  143. package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
  144. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
  145. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
  146. package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
  147. package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
  148. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
  149. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
  150. package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
  151. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
  152. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
  153. package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
  154. package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
  155. package/artemis-main/artemis/agents/operator/operator.json +3 -0
  156. package/artemis-main/artemis/agents/operator/operator.py +1394 -0
  157. package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
  158. package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
  159. package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
  160. package/artemis-main/artemis/agents/planner/planner.json +32 -0
  161. package/artemis-main/artemis/agents/planner/planner.py +436 -0
  162. package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
  163. package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
  164. package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
  165. package/artemis-main/artemis/agents/validator/categories.py +33 -0
  166. package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
  167. package/artemis-main/artemis/agents/validator/incidents.py +120 -0
  168. package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
  169. package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
  170. package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
  171. package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
  172. package/artemis-main/artemis/agents/validator/validator.py +188 -0
  173. package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
  174. package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
  175. package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
  176. package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
  177. package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
  178. package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
  179. package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
  180. package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
  181. package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
  182. package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
  183. package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
  184. package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
  185. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
  186. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
  187. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
  188. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
  189. package/artemis-main/artemis/clients/accessibility_client.py +438 -0
  190. package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
  191. package/artemis-main/artemis/config/__init__.py +309 -0
  192. package/artemis-main/artemis/config/agent.py +1098 -0
  193. package/artemis-main/artemis/config/constants.py +151 -0
  194. package/artemis-main/artemis/config/core/__init__.py +24 -0
  195. package/artemis-main/artemis/config/core/engine.py +118 -0
  196. package/artemis-main/artemis/config/core/interpolator.py +78 -0
  197. package/artemis-main/artemis/config/llm.py +267 -0
  198. package/artemis-main/artemis/config/paths.py +244 -0
  199. package/artemis-main/artemis/config/runtime.py +197 -0
  200. package/artemis-main/artemis/config/settings.py +315 -0
  201. package/artemis-main/artemis/constants.py +47 -0
  202. package/artemis-main/artemis/context.py +289 -0
  203. package/artemis-main/artemis/controllers/__init__.py +41 -0
  204. package/artemis-main/artemis/controllers/controller_factory.py +41 -0
  205. package/artemis-main/artemis/controllers/device_controller.py +29 -0
  206. package/artemis-main/artemis/controllers/types.py +37 -0
  207. package/artemis-main/artemis/controllers/unified_controller.py +649 -0
  208. package/artemis-main/artemis/core/__init__.py +21 -0
  209. package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
  210. package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
  211. package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
  212. package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
  213. package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
  214. package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
  215. package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
  216. package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
  217. package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
  218. package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
  219. package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
  220. package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
  221. package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
  222. package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
  223. package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
  224. package/artemis-main/artemis/core/tool_declaration.py +43 -0
  225. package/artemis-main/artemis/core/tool_failure.py +56 -0
  226. package/artemis-main/artemis/data_engine/context_vars.py +24 -0
  227. package/artemis-main/artemis/data_engine/engine.py +1615 -0
  228. package/artemis-main/artemis/data_engine/history_reader.py +142 -0
  229. package/artemis-main/artemis/data_engine/models.py +131 -0
  230. package/artemis-main/artemis/data_engine/storage.py +1245 -0
  231. package/artemis-main/artemis/data_engine/trace.py +727 -0
  232. package/artemis-main/artemis/drivers/__init__.py +48 -0
  233. package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
  234. package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
  235. package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
  236. package/artemis-main/artemis/drivers/android/recorder.py +57 -0
  237. package/artemis-main/artemis/drivers/base.py +290 -0
  238. package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
  239. package/artemis-main/artemis/drivers/factory.py +80 -0
  240. package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
  241. package/artemis-main/artemis/drivers/types.py +67 -0
  242. package/artemis-main/artemis/graph/checkpoints.py +1243 -0
  243. package/artemis-main/artemis/graph/graph.py +1055 -0
  244. package/artemis-main/artemis/graph/perception.py +234 -0
  245. package/artemis-main/artemis/graph/state.py +151 -0
  246. package/artemis-main/artemis/graph/visibility.py +238 -0
  247. package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
  248. package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
  249. package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
  250. package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
  251. package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
  252. package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
  253. package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
  254. package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
  255. package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
  256. package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
  257. package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
  258. package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
  259. package/artemis-main/artemis/interfaces/cli/main.py +109 -0
  260. package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
  261. package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
  262. package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
  263. package/artemis-main/artemis/llm/__init__.py +25 -0
  264. package/artemis-main/artemis/llm/fake_model.py +179 -0
  265. package/artemis-main/artemis/llm/google/__init__.py +57 -0
  266. package/artemis-main/artemis/llm/google/provider.py +185 -0
  267. package/artemis-main/artemis/llm/google/usage.py +148 -0
  268. package/artemis-main/artemis/llm/parser.py +32 -0
  269. package/artemis-main/artemis/llm/reliability.py +267 -0
  270. package/artemis-main/artemis/llm/router.py +389 -0
  271. package/artemis-main/artemis/llm/schemas.py +36 -0
  272. package/artemis-main/artemis/llm/structured.py +196 -0
  273. package/artemis-main/artemis/main.py +58 -0
  274. package/artemis-main/artemis/mcp/__init__.py +20 -0
  275. package/artemis-main/artemis/mcp/action_executor.py +760 -0
  276. package/artemis-main/artemis/mcp/action_manifest.py +320 -0
  277. package/artemis-main/artemis/mcp/action_names.py +172 -0
  278. package/artemis-main/artemis/mcp/action_server.py +202 -0
  279. package/artemis-main/artemis/mcp/action_session.py +245 -0
  280. package/artemis-main/artemis/mcp/action_specs.py +868 -0
  281. package/artemis-main/artemis/mcp/action_types.py +86 -0
  282. package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
  283. package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
  284. package/artemis-main/artemis/mcp/actuators/base.py +113 -0
  285. package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
  286. package/artemis-main/artemis/mcp/adb_server.py +490 -0
  287. package/artemis-main/artemis/mcp/observation.py +133 -0
  288. package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
  289. package/artemis-main/artemis/memory/__init__.py +90 -0
  290. package/artemis-main/artemis/memory/chunking.py +1843 -0
  291. package/artemis-main/artemis/memory/context_policy.py +226 -0
  292. package/artemis-main/artemis/memory/step_capsule.md +73 -0
  293. package/artemis-main/artemis/memory/step_memory.py +281 -0
  294. package/artemis-main/artemis/memory/transcript.py +798 -0
  295. package/artemis-main/artemis/platform/__init__.py +61 -0
  296. package/artemis-main/artemis/platform/base.py +104 -0
  297. package/artemis-main/artemis/platform/darwin.py +184 -0
  298. package/artemis-main/artemis/platform/linux.py +207 -0
  299. package/artemis-main/artemis/platform/windows.py +193 -0
  300. package/artemis-main/artemis/resources/__init__.py +46 -0
  301. package/artemis-main/artemis/runtime/__init__.py +108 -0
  302. package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
  303. package/artemis-main/artemis/runtime/awake_lease.py +283 -0
  304. package/artemis-main/artemis/runtime/awake_service.py +343 -0
  305. package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
  306. package/artemis-main/artemis/runtime/daemon_client.py +422 -0
  307. package/artemis-main/artemis/runtime/device_lock.py +1013 -0
  308. package/artemis-main/artemis/runtime/device_pool.py +478 -0
  309. package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
  310. package/artemis-main/artemis/runtime/process_probe.py +98 -0
  311. package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
  312. package/artemis-main/artemis/runtime/supervisor.py +121 -0
  313. package/artemis-main/artemis/runtime/trace_store.py +375 -0
  314. package/artemis-main/artemis/sdk/__init__.py +26 -0
  315. package/artemis-main/artemis/sdk/agent.py +485 -0
  316. package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
  317. package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
  318. package/artemis-main/artemis/sdk/constants.py +15 -0
  319. package/artemis-main/artemis/sdk/run_outcome.py +58 -0
  320. package/artemis-main/artemis/sdk/types/__init__.py +61 -0
  321. package/artemis-main/artemis/sdk/types/agent.py +89 -0
  322. package/artemis-main/artemis/sdk/utils.py +28 -0
  323. package/artemis-main/artemis/services/llm.py +1040 -0
  324. package/artemis-main/artemis/services/token_meter.py +267 -0
  325. package/artemis-main/artemis/telemetry/__init__.py +35 -0
  326. package/artemis-main/artemis/telemetry/models.py +64 -0
  327. package/artemis-main/artemis/telemetry/replay.py +34 -0
  328. package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
  329. package/artemis-main/artemis/telemetry/tracer.py +85 -0
  330. package/artemis-main/artemis/toolchain/__init__.py +52 -0
  331. package/artemis-main/artemis/toolchain/descriptors.py +92 -0
  332. package/artemis-main/artemis/toolchain/resolver.py +185 -0
  333. package/artemis-main/artemis/tools/base.py +232 -0
  334. package/artemis-main/artemis/tools/command_tool.py +1240 -0
  335. package/artemis-main/artemis/tools/committee_prompts.json +5 -0
  336. package/artemis-main/artemis/tools/committee_tool.py +422 -0
  337. package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
  338. package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
  339. package/artemis-main/artemis/tools/explorer_tool.py +581 -0
  340. package/artemis-main/artemis/tools/history/__init__.py +398 -0
  341. package/artemis-main/artemis/tools/history/replay.py +120 -0
  342. package/artemis-main/artemis/tools/history/screenshot.py +139 -0
  343. package/artemis-main/artemis/tools/history/search.py +563 -0
  344. package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
  345. package/artemis-main/artemis/tools/index.py +47 -0
  346. package/artemis-main/artemis/tools/log_tool.py +108 -0
  347. package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
  348. package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
  349. package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
  350. package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
  351. package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
  352. package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
  353. package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
  354. package/artemis-main/artemis/tools/scratchpad.py +819 -0
  355. package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
  356. package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
  357. package/artemis-main/artemis/tools/video_tool.py +261 -0
  358. package/artemis-main/artemis/tools/wait_tool.py +97 -0
  359. package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
  360. package/artemis-main/artemis/utils/coordinates.py +606 -0
  361. package/artemis-main/artemis/utils/credentials_validator.py +201 -0
  362. package/artemis-main/artemis/utils/cv_canvas.py +189 -0
  363. package/artemis-main/artemis/utils/cython_compat.py +45 -0
  364. package/artemis-main/artemis/utils/element_hit_test.py +80 -0
  365. package/artemis-main/artemis/utils/image_diff.py +335 -0
  366. package/artemis-main/artemis/utils/image_hash.py +80 -0
  367. package/artemis-main/artemis/utils/notes.py +470 -0
  368. package/artemis-main/artemis/utils/ocr_api.py +105 -0
  369. package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
  370. package/artemis-main/artemis/utils/plan_grammar.py +513 -0
  371. package/artemis-main/artemis/utils/python_executor.py +213 -0
  372. package/artemis-main/artemis/utils/startup_progress.py +60 -0
  373. package/artemis-main/artemis/utils/task_tree.py +1174 -0
  374. package/artemis-main/artemis/utils/text.py +37 -0
  375. package/artemis-main/artemis/utils/ui_filter.py +651 -0
  376. package/artemis-main/artemis/utils/video.py +685 -0
  377. package/artemis-main/artemis/utils/visualization.py +802 -0
  378. package/artemis-main/config/artemis.example.jsonc +12 -0
  379. package/artemis-main/mcp_server/README.md +111 -0
  380. package/artemis-main/mcp_server/__init__.py +21 -0
  381. package/artemis-main/mcp_server/__main__.py +20 -0
  382. package/artemis-main/mcp_server/background/__init__.py +15 -0
  383. package/artemis-main/mcp_server/background/task_runner.py +453 -0
  384. package/artemis-main/mcp_server/base.py +49 -0
  385. package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
  386. package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
  387. package/artemis-main/mcp_server/notifiers/base.py +45 -0
  388. package/artemis-main/mcp_server/notifiers/composite.py +87 -0
  389. package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
  390. package/artemis-main/mcp_server/notifiers/file.py +69 -0
  391. package/artemis-main/mcp_server/notifiers/script.py +90 -0
  392. package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
  393. package/artemis-main/mcp_server/rules.md +68 -0
  394. package/artemis-main/mcp_server/server.py +106 -0
  395. package/artemis-main/mcp_server/tools/__init__.py +29 -0
  396. package/artemis-main/mcp_server/tools/device_state.py +107 -0
  397. package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
  398. package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
  399. package/artemis-main/mcp_server/tools/task_manager.py +627 -0
  400. package/artemis-main/mcp_server/tools/task_runner.py +548 -0
  401. package/artemis-main/mcp_server/utils/__init__.py +15 -0
  402. package/artemis-main/mcp_server/utils/device_utils.py +180 -0
  403. package/artemis-main/mcp_server/utils/env_utils.py +65 -0
  404. package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
  405. package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
  406. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
  407. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
  408. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
  409. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
  410. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
  411. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
  412. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
  413. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
  414. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
  415. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
  416. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
  417. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
  418. package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
  419. package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
  420. package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
  421. package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
  422. package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
  423. package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
  424. package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
  425. package/artemis-main/packages/artemis-client/README.md +112 -0
  426. package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
  427. package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
  428. package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
  429. package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
  430. package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
  431. package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
  432. package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
  433. package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
  434. package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
  435. package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
  436. package/artemis-main/pyproject.toml +259 -0
  437. package/artemis-main/pyright-core.json +17 -0
  438. package/artemis-main/scripts/check_dependency_sources.py +68 -0
  439. package/artemis-main/scripts/install_deps.ps1 +520 -0
  440. package/artemis-main/scripts/install_deps.sh +533 -0
  441. package/artemis-main/scripts/quality_ratchet.py +97 -0
  442. package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
  443. package/artemis-main/scripts/run_edge_case_part.py +327 -0
  444. package/artemis-main/scripts/run_ui.py +242 -0
  445. package/artemis-main/scripts/start.ps1 +500 -0
  446. package/artemis-main/scripts/test_all_edge_cases.py +269 -0
  447. package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
  448. package/artemis-main/setup.py +164 -0
  449. package/artemis-main/start.bat +35 -0
  450. package/artemis-main/start.sh +423 -0
  451. package/artemis-main/third_party/mobile_use/LICENSE +190 -0
  452. package/artemis-main/third_party/mobile_use/METADATA +27 -0
  453. package/artemis-main/third_party/mobile_use/NOTICE +12 -0
  454. package/artemis-main/third_party/mobile_use/__init__.py +19 -0
  455. package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
  456. package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
  457. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
  458. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
  459. package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
  460. package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
  461. package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
  462. package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
  463. package/artemis-main/third_party/mobile_use/config/output.py +184 -0
  464. package/artemis-main/third_party/mobile_use/context.py +119 -0
  465. package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
  466. package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
  467. package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
  468. package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
  469. package/artemis-main/third_party/mobile_use/graph.py +54 -0
  470. package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
  471. package/artemis-main/third_party/mobile_use/main.py +130 -0
  472. package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
  473. package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
  474. package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
  475. package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
  476. package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
  477. package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
  478. package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
  479. package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
  480. package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
  481. package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
  482. package/artemis-main/third_party/mobile_use/testing.py +59 -0
  483. package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
  484. package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
  485. package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
  486. package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
  487. package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
  488. package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
  489. package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
  490. package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
  491. package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
  492. package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
  493. package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
  494. package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
  495. package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
  496. package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
  497. package/artemis-main/uv.lock +4908 -0
  498. package/bin/android-midscene-automation.js +1 -5
  499. package/bin/artemis-config.mjs +15 -0
  500. package/bin/artemis-embedded.py +56 -0
  501. package/bin/start-artemis.mjs +30 -0
  502. package/bin/start-dev.mjs +112 -0
  503. package/bin/test_artemis_embedded.py +44 -0
  504. package/package.json +4 -2
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +12 -0
  507. package/src/App.vue +10 -5
  508. package/src/api.ts +5 -0
  509. package/src/appium-recorder/AppiumPage.vue +1 -1
  510. package/src/appium-recorder/components/ComponentInspector.vue +1 -1
  511. package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
  512. package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
  513. package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
  514. package/src/appium-recorder/flow-appearance.ts +4 -4
  515. package/src/components/AppSidebar.vue +8 -7
  516. package/src/components/MidsceneMaintenanceNotice.vue +17 -0
  517. package/src/components/analytics/AnalysisCharts.vue +4 -4
  518. package/src/components/analytics/ReportAnalysis.vue +1 -1
  519. package/src/components/generator/TestCaseFileUpload.vue +1 -1
  520. package/src/components/help/HelpCenter.vue +56 -37
  521. package/src/pages/AiGeneratorPage.vue +1 -1
  522. package/src/pages/ArtemisPage.vue +72 -0
  523. package/src/pages/ConfigPage.vue +18 -19
  524. package/src/style.css +340 -121
  525. package/src/types.ts +1 -1
@@ -0,0 +1,1040 @@
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
+ """Universal LLM Service for Artemis.
16
+
17
+ Provides connection-pooled model instantiation, robust retries,
18
+ thought stream recording, and role-based dynamic dispatching.
19
+ """
20
+
21
+ import asyncio
22
+ from collections.abc import Awaitable, Callable, Iterator
23
+ from contextlib import contextmanager
24
+ from contextvars import ContextVar, Token
25
+ import functools
26
+ import json
27
+ import logging
28
+ from pathlib import Path
29
+ import re
30
+ import sys
31
+ import time
32
+ from typing import Any, TypeVar
33
+ from uuid import uuid4
34
+
35
+ from langchain_core.language_models.chat_models import BaseChatModel
36
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
37
+ from langchain_core.runnables import RunnableLambda
38
+ from pydantic import BaseModel
39
+
40
+ from artemis.config import (
41
+ PAUSE_FILE,
42
+ LLMWithFallback,
43
+ get_default_llm_config,
44
+ settings,
45
+ )
46
+ from artemis.context import ArtemisContext
47
+ from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler
48
+ from artemis.llm.google import is_google_chat_model, is_google_provider
49
+ from artemis.llm.reliability import (
50
+ CircuitBreaker,
51
+ FailureCategory,
52
+ LLMCallError,
53
+ LLMExhaustedError,
54
+ LLMPermanentError,
55
+ classify_failure,
56
+ retry_policy_for,
57
+ )
58
+ from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
59
+ from artemis.services.token_meter import record_llm_usage
60
+ from artemis.llm.structured import (
61
+ ParseFailure,
62
+ StructuredOutputError,
63
+ content_to_text,
64
+ parse_structured,
65
+ )
66
+ from third_party.mobile_use.llm_service import FallbackRunner, GetLLM, LLMWaitNotice
67
+ from third_party.mobile_use.utils.logger import get_logger
68
+
69
+ # Logger for internal messages
70
+ llm_logger = logging.getLogger(__name__)
71
+ # Logger for user-facing messages
72
+ user_messages_logger = get_logger(__name__)
73
+
74
+ T = TypeVar("T")
75
+
76
+ # Retained as an override seam for unit tests. At runtime the active engine must
77
+ # be resolved dynamically because DataEngine.start_session() updates the module
78
+ # global after this module has already been imported.
79
+ _CURRENT_DATA_ENGINE = None
80
+
81
+ # Provider retry logs do not carry an Artemis trace id. Keep the active model
82
+ # request in async-local state so observable SDK retries and the terminal error
83
+ # are emitted as one lifecycle, even when several model calls run concurrently.
84
+ _ACTIVE_LLM_REQUEST: ContextVar[dict[str, Any] | None] = ContextVar(
85
+ "active_llm_request", default=None
86
+ )
87
+
88
+
89
+ def _begin_llm_request(provider: str | None) -> Token:
90
+ return _ACTIVE_LLM_REQUEST.set(
91
+ {
92
+ "request_id": str(uuid4()),
93
+ "provider": provider,
94
+ "started_at": time.time(),
95
+ "retries": [],
96
+ }
97
+ )
98
+
99
+
100
+ # Whether the current async context has a configured fallback model waiting
101
+ # behind this call (set by with_fallback). When a fallback exists, exhausting
102
+ # retries hands over to it immediately instead of pausing the whole task.
103
+ _FALLBACK_AVAILABLE: ContextVar[bool] = ContextVar("llm_fallback_available", default=False)
104
+
105
+ # Shared circuit breaker keyed by "provider:model". Transient failures open
106
+ # it; while open, concurrent calls wait out the cooldown instead of hammering
107
+ # a provider that is already melting down.
108
+ _ENDPOINT_BREAKER = CircuitBreaker(threshold=3, cooldown_seconds=30.0)
109
+
110
+ # Endpoints observed to not support streaming. Detected once, loudly, then
111
+ # remembered so subsequent calls go straight to non-streaming invocation.
112
+ _NON_STREAMING_ENDPOINTS: set[str] = set()
113
+
114
+ # Total retry limit when failures switch categories within one recovery cycle.
115
+ _MAX_TOTAL_RETRY_ATTEMPTS = 8
116
+
117
+
118
+ def _get_current_data_engine():
119
+ if _CURRENT_DATA_ENGINE is not None:
120
+ return _CURRENT_DATA_ENGINE
121
+ engine_module = sys.modules.get("artemis.data_engine.engine")
122
+ return getattr(engine_module, "_CURRENT_DATA_ENGINE", None) if engine_module else None
123
+
124
+
125
+ def _record_llm_retry(
126
+ error: str,
127
+ delay: float,
128
+ *,
129
+ attempt: int | None = None,
130
+ max_retries: int | None = None,
131
+ provider: str | None = None,
132
+ source: str = "artemis",
133
+ ) -> None:
134
+ """Persist and publish one recoverable LLM retry for UI transparency."""
135
+ request = _ACTIVE_LLM_REQUEST.get()
136
+ timestamp = time.time()
137
+ request_id = request.get("request_id") if request else None
138
+ provider = provider or (request.get("provider") if request else None)
139
+
140
+ engine = _get_current_data_engine()
141
+ if not engine or not getattr(engine, "current_session_id", None):
142
+ return
143
+
144
+ error_text = str(error)
145
+ if len(error_text) > 1000:
146
+ error_text = error_text[:1000] + "... [Truncated by LLM Retry Telemetry]"
147
+
148
+ payload = {
149
+ "error": error_text,
150
+ "delay": float(delay),
151
+ "attempt": attempt,
152
+ "max_retries": max_retries,
153
+ "provider": provider,
154
+ "source": source,
155
+ "recoverable": True,
156
+ "request_id": request_id,
157
+ "scheduled_at": timestamp,
158
+ }
159
+ payload = {key: value for key, value in payload.items() if value is not None}
160
+ step_id = getattr(engine, "current_step_id", None)
161
+ parent_trace_id = CURRENT_TRACE_ID.get()
162
+ try:
163
+ trace_id = engine.record_trace(
164
+ type="llm_call",
165
+ name="llm_retry",
166
+ payload=payload,
167
+ step_id=step_id,
168
+ parent_trace_id=parent_trace_id,
169
+ status="retrying",
170
+ )
171
+ except Exception as trace_error:
172
+ llm_logger.warning("Failed to persist LLM retry trace: %s", trace_error)
173
+ trace_id = None
174
+
175
+ if request is not None:
176
+ request["retries"].append(
177
+ {
178
+ **payload,
179
+ "trace_id": str(trace_id) if trace_id else None,
180
+ "timestamp": timestamp,
181
+ }
182
+ )
183
+
184
+ try:
185
+ engine._publish(
186
+ "llm_retrying",
187
+ {
188
+ **payload,
189
+ "step_id": str(step_id) if step_id else None,
190
+ "trace_id": str(trace_id) if trace_id else None,
191
+ "timestamp": timestamp,
192
+ },
193
+ )
194
+ except Exception as publish_error:
195
+ # Retry visibility is best-effort and must never break the LLM retry itself.
196
+ llm_logger.warning("Failed to publish LLM retry event: %s", publish_error)
197
+
198
+
199
+ def _record_llm_event(name: str, payload: dict[str, Any], *, status: str = "retrying") -> None:
200
+ """Persist and publish one degradation/lifecycle event on the LLM IO path.
201
+
202
+ Every degradation (fallback, stream downgrade, stream reset, giving up)
203
+ must be observable: WARNING-level logging is handled by callers, this
204
+ records the structured trace + live event, best-effort.
205
+ """
206
+ request = _ACTIVE_LLM_REQUEST.get()
207
+ engine = _get_current_data_engine()
208
+ if not engine or not getattr(engine, "current_session_id", None):
209
+ return
210
+
211
+ full_payload = dict(payload)
212
+ if request is not None:
213
+ full_payload.setdefault("request_id", request.get("request_id"))
214
+ if request.get("provider"):
215
+ full_payload.setdefault("provider", request.get("provider"))
216
+ full_payload = {key: value for key, value in full_payload.items() if value is not None}
217
+
218
+ step_id = getattr(engine, "current_step_id", None)
219
+ trace_id = None
220
+ try:
221
+ trace_id = engine.record_trace(
222
+ type="llm_call",
223
+ name=name,
224
+ payload=full_payload,
225
+ step_id=step_id,
226
+ parent_trace_id=CURRENT_TRACE_ID.get(),
227
+ status=status,
228
+ )
229
+ except Exception as trace_error:
230
+ llm_logger.warning("Failed to persist LLM event trace %s: %s", name, trace_error)
231
+ try:
232
+ engine._publish(
233
+ name,
234
+ {
235
+ **full_payload,
236
+ "step_id": str(step_id) if step_id else None,
237
+ "trace_id": str(trace_id) if trace_id else None,
238
+ "timestamp": time.time(),
239
+ },
240
+ )
241
+ except Exception as publish_error:
242
+ llm_logger.warning("Failed to publish LLM event %s: %s", name, publish_error)
243
+
244
+
245
+ class _ProviderRetryTelemetryHandler(logging.Handler):
246
+ """Turn provider SDK retry log records into structured Artemis telemetry."""
247
+
248
+ _DELAY_PATTERN = re.compile(r"\bin\s+(?P<delay>\d+(?:\.\d+)?)\s+seconds?\b", re.IGNORECASE)
249
+ _ERROR_PATTERN = re.compile(r"\bas it raised\s+(?P<error>.+)$", re.IGNORECASE)
250
+ _RETRYABLE_MARKERS = (
251
+ "503",
252
+ "429",
253
+ "unavailable",
254
+ "resource_exhausted",
255
+ "high demand",
256
+ "quota",
257
+ "throttled",
258
+ "overloaded",
259
+ )
260
+
261
+ def emit(self, record: logging.LogRecord) -> None:
262
+ try:
263
+ request = _ACTIVE_LLM_REQUEST.get()
264
+ if not request or request.get("provider") != ModelProvider.GOOGLE.value:
265
+ return
266
+
267
+ message = record.getMessage()
268
+ lowered = message.lower()
269
+ if "retrying" not in lowered or not any(
270
+ marker in lowered for marker in self._RETRYABLE_MARKERS
271
+ ):
272
+ return
273
+
274
+ delay_match = self._DELAY_PATTERN.search(message)
275
+ error_match = self._ERROR_PATTERN.search(message)
276
+ delay = float(delay_match.group("delay")) if delay_match else 0.0
277
+ error = error_match.group("error").strip() if error_match else message
278
+ _record_llm_retry(
279
+ error,
280
+ delay,
281
+ provider=request["provider"],
282
+ source="provider_sdk",
283
+ )
284
+ except Exception:
285
+ # Telemetry must never interfere with the provider's own retry.
286
+ self.handleError(record)
287
+
288
+
289
+ def _install_provider_retry_telemetry() -> None:
290
+ provider_logger = logging.getLogger("google_genai._api_client")
291
+ if any(
292
+ isinstance(handler, _ProviderRetryTelemetryHandler) for handler in provider_logger.handlers
293
+ ):
294
+ return
295
+ provider_logger.addHandler(_ProviderRetryTelemetryHandler(level=logging.INFO))
296
+
297
+
298
+ _install_provider_retry_telemetry()
299
+
300
+
301
+ def _handle_llm_pause_and_resume(last_error: Exception) -> Path:
302
+ """Handles pausing task execution upon persistent failure until resumed."""
303
+ err_msg = str(last_error)
304
+ if len(err_msg) > 1000:
305
+ err_msg = err_msg[:1000] + "... [Truncated by LLM Wrapper]"
306
+
307
+ llm_logger.warning(f"LLM Error: {err_msg}. Pausing execution to wait for resume signal...")
308
+ pause_file = PAUSE_FILE
309
+ try:
310
+ pause_file.write_text(f"LLM Error: {err_msg}", encoding="utf-8")
311
+ except (OSError, UnicodeError):
312
+ pass
313
+
314
+ current_engine = _get_current_data_engine()
315
+ if current_engine:
316
+ step_id = getattr(current_engine, "current_step_id", None)
317
+ timestamp = time.time()
318
+ request = _ACTIVE_LLM_REQUEST.get()
319
+ failure_payload: dict[str, Any] = {"error": err_msg, "pause": True}
320
+ if request:
321
+ failure_payload.update(
322
+ {
323
+ "request_id": request["request_id"],
324
+ "provider": request.get("provider"),
325
+ "waited_seconds": max(0.0, timestamp - request["started_at"]),
326
+ # Only SDK-observed retries are included. Other providers
327
+ # may retry internally, but their attempt data is opaque.
328
+ "retries": list(request["retries"]),
329
+ }
330
+ )
331
+ failure_payload = {
332
+ key: value for key, value in failure_payload.items() if value is not None
333
+ }
334
+ try:
335
+ # Persist exhausted LLM retries as a failed call so the same error
336
+ # card is available both in the live stream and after a refresh.
337
+ current_engine.record_trace(
338
+ type="llm_call",
339
+ name="llm_pause",
340
+ payload=failure_payload,
341
+ step_id=step_id,
342
+ status="failed",
343
+ )
344
+ except Exception as trace_error:
345
+ llm_logger.warning("Failed to persist paused LLM error trace: %s", trace_error)
346
+
347
+ current_engine._publish(
348
+ "task_paused",
349
+ {
350
+ **failure_payload,
351
+ "step_id": str(step_id) if step_id else None,
352
+ "timestamp": timestamp,
353
+ },
354
+ )
355
+
356
+ return pause_file
357
+
358
+
359
+ async def _wait_for_resume(pause_file: Path) -> bool:
360
+ """Wait for the pause file to be cleared. Returns False on deadline."""
361
+ deadline = float(getattr(settings, "LLM_PAUSE_TIMEOUT_SECONDS", 0.0) or 0.0)
362
+ waited = 0.0
363
+ while pause_file.exists():
364
+ if deadline > 0 and waited >= deadline:
365
+ return False
366
+ await asyncio.sleep(1)
367
+ waited += 1
368
+ return True
369
+
370
+
371
+ async def _wait_for_breaker(key: str) -> None:
372
+ """Wait out an open circuit instead of hammering a melting provider."""
373
+ waited = 0.0
374
+ warned = False
375
+ while not _ENDPOINT_BREAKER.allow(key):
376
+ if not warned:
377
+ warned = True
378
+ llm_logger.warning(f"LLM circuit open for {key}; waiting for cooldown...")
379
+ step = min(max(_ENDPOINT_BREAKER.open_remaining(key), 0.5), 2.0)
380
+ await asyncio.sleep(step)
381
+ waited += step
382
+ if waited >= 120.0:
383
+ # Never deadlock behind a stuck half-open trial; proceed anyway.
384
+ break
385
+
386
+
387
+ async def _run_with_recovery[T](
388
+ call_once: Callable[[], Awaitable[T]],
389
+ *,
390
+ provider: str | None = None,
391
+ endpoint_key: str | None = None,
392
+ ) -> T:
393
+ """Execute one LLM call under the classified retry / pause / breaker policy.
394
+
395
+ Failure handling is decided per category (see artemis.llm.reliability):
396
+
397
+ - Retryable categories back off per policy; each retry is recorded for UI
398
+ transparency via _record_llm_retry.
399
+ - Non-retryable categories (auth, bad request) raise LLMPermanentError
400
+ immediately: retrying cannot help and pausing would hang the task.
401
+ - When retryable attempts are exhausted: if a fallback model is waiting
402
+ (with_fallback), raise LLMExhaustedError so it takes over immediately;
403
+ otherwise pause the task (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS)
404
+ and retry from scratch on resume.
405
+ """
406
+ while True:
407
+ request_token = _begin_llm_request(provider)
408
+ last_error: Exception | None = None
409
+ last_failure = None
410
+ attempts: dict[FailureCategory, int] = {}
411
+ pause_file: Path | None = None
412
+ try:
413
+ while True:
414
+ if endpoint_key:
415
+ await _wait_for_breaker(endpoint_key)
416
+ try:
417
+ result = await call_once()
418
+ except (KeyboardInterrupt, asyncio.CancelledError):
419
+ raise
420
+ except LLMCallError:
421
+ # Already classified terminal by a nested recovery layer.
422
+ raise
423
+ except Exception as e:
424
+ failure = classify_failure(e)
425
+ if failure.category is FailureCategory.CANCELLED:
426
+ raise
427
+ if endpoint_key:
428
+ _ENDPOINT_BREAKER.record_failure(endpoint_key, failure)
429
+ if not failure.retryable:
430
+ llm_logger.warning(
431
+ f"LLM call failed permanently ({failure.category.value}): {e}"
432
+ )
433
+ _record_llm_event(
434
+ "llm_gave_up",
435
+ {
436
+ "error": str(e)[:1000],
437
+ "category": failure.category.value,
438
+ "retryable": False,
439
+ },
440
+ status="failed",
441
+ )
442
+ raise LLMPermanentError(str(e), failure=failure, cause=e) from e
443
+ last_error, last_failure = e, failure
444
+ attempt = attempts.get(failure.category, 0) + 1
445
+ attempts[failure.category] = attempt
446
+ policy = retry_policy_for(failure.category)
447
+ if attempt >= policy.max_attempts:
448
+ break
449
+ total_attempts = sum(attempts.values())
450
+ if total_attempts >= _MAX_TOTAL_RETRY_ATTEMPTS:
451
+ llm_logger.warning(
452
+ "LLM retry total across failure categories reached"
453
+ f" {total_attempts}; treating as exhausted."
454
+ )
455
+ break
456
+ delay = policy.delay_for(attempt)
457
+ _record_llm_retry(
458
+ str(e),
459
+ delay,
460
+ attempt=attempt,
461
+ max_retries=policy.max_attempts,
462
+ provider=provider,
463
+ )
464
+ llm_logger.warning(
465
+ f"LLM call failed ({failure.category.value}), retrying in {delay:.2f}s..."
466
+ )
467
+ await asyncio.sleep(delay)
468
+ else:
469
+ if endpoint_key:
470
+ _ENDPOINT_BREAKER.record_success(endpoint_key)
471
+ return result
472
+
473
+ # Retryable attempts exhausted.
474
+ if _FALLBACK_AVAILABLE.get():
475
+ _record_llm_event(
476
+ "llm_gave_up",
477
+ {
478
+ "error": str(last_error)[:1000],
479
+ "category": last_failure.category.value,
480
+ "retryable": True,
481
+ "handover": "fallback",
482
+ },
483
+ status="failed",
484
+ )
485
+ raise LLMExhaustedError(
486
+ str(last_error), failure=last_failure, cause=last_error
487
+ ) from last_error
488
+ pause_file = _handle_llm_pause_and_resume(last_error)
489
+ finally:
490
+ _ACTIVE_LLM_REQUEST.reset(request_token)
491
+
492
+ if not await _wait_for_resume(pause_file):
493
+ llm_logger.error(
494
+ "LLM pause deadline"
495
+ f" ({settings.LLM_PAUSE_TIMEOUT_SECONDS:.0f}s) exceeded; giving up."
496
+ )
497
+ try:
498
+ pause_file.unlink(missing_ok=True)
499
+ except OSError:
500
+ pass
501
+ raise LLMExhaustedError(
502
+ str(last_error), failure=last_failure, cause=last_error
503
+ ) from last_error
504
+
505
+ llm_logger.info("Resume signal received, retrying LLM call...")
506
+ current_engine = _get_current_data_engine()
507
+ if current_engine:
508
+ current_engine._publish("task_resumed", {})
509
+
510
+
511
+ def _inject_parent_trace_id(trace_id, *args, **kwargs):
512
+ config = kwargs.get("config") or {}
513
+ if not kwargs.get("config") and len(args) > 1:
514
+ args_list = list(args)
515
+ if isinstance(args_list[1], dict):
516
+ config = args_list[1]
517
+ args_list[1] = config
518
+ args = tuple(args_list)
519
+
520
+ if "metadata" not in config:
521
+ config["metadata"] = {}
522
+ config["metadata"]["parent_trace_id"] = str(trace_id)
523
+ kwargs["config"] = config
524
+ return args, kwargs
525
+
526
+
527
+ class RobustChatModelWrapper:
528
+ """Universal LangChain Runnable wrapper ensuring robust execution and telemetry."""
529
+
530
+ def __init__(
531
+ self,
532
+ base_model: BaseChatModel,
533
+ ctx: ArtemisContext = None,
534
+ endpoint: ModelEndpoint | None = None,
535
+ ):
536
+ self.base_model = base_model
537
+ self.ctx = ctx
538
+ self.endpoint = endpoint
539
+
540
+ def __getattr__(self, item: str):
541
+ return getattr(self.base_model, item)
542
+
543
+ def bind_tools(self, *args, **kwargs):
544
+ # Extract tools list from positional args or keyword args
545
+ tools_list = None
546
+ other_args = list(args)
547
+ if other_args:
548
+ tools_list = other_args.pop(0)
549
+ elif "tools" in kwargs:
550
+ tools_list = kwargs.pop("tools")
551
+
552
+ processed_tools = list(tools_list) if tools_list is not None else []
553
+
554
+ # Determine if the underlying model provider is Google / Gemini
555
+ if self.endpoint is not None:
556
+ google_backed = is_google_provider(self.endpoint.provider)
557
+ else:
558
+ google_backed = is_google_chat_model(self.base_model)
559
+
560
+ has_explicit_google_search = any(
561
+ isinstance(t, dict) and "google_search" in t for t in processed_tools
562
+ )
563
+ should_ground = (
564
+ self.endpoint is not None and self.endpoint.enable_grounding
565
+ ) or has_explicit_google_search
566
+
567
+ if google_backed:
568
+ if should_ground:
569
+ # Add native Google Search Grounding tool if not already present
570
+ if not has_explicit_google_search:
571
+ processed_tools.append({"google_search": {}})
572
+
573
+ # Ensure include_server_side_tool_invocations is True in tool_config
574
+ # to satisfy Gemini API requirements when mixing built-in tools and function calling
575
+ tool_config = kwargs.get("tool_config")
576
+ if tool_config is None:
577
+ kwargs["tool_config"] = {"include_server_side_tool_invocations": True}
578
+ elif isinstance(tool_config, dict):
579
+ tool_config = dict(tool_config)
580
+ tool_config.setdefault("include_server_side_tool_invocations", True)
581
+ kwargs["tool_config"] = tool_config
582
+ else:
583
+ # If not Gemini/Google (e.g. OpenAI, Anthropic, Ollama, OpenRouter, XAI):
584
+ # Ignore grounding and filter out any Gemini-specific built-in dict tools to avoid provider errors
585
+ processed_tools = [
586
+ t for t in processed_tools if not (isinstance(t, dict) and "google_search" in t)
587
+ ]
588
+
589
+ bound = self.base_model.bind_tools(processed_tools, *other_args, **kwargs)
590
+ return RobustChatModelWrapper(bound, self.ctx, endpoint=self.endpoint)
591
+
592
+ def with_config(self, *args, **kwargs):
593
+ return RobustChatModelWrapper(
594
+ self.base_model.with_config(*args, **kwargs),
595
+ self.ctx,
596
+ endpoint=self.endpoint,
597
+ )
598
+
599
+ def with_structured_output(self, *args, **kwargs):
600
+ # Provider-native JSON parsers may see reasoning tags as invalid JSON.
601
+ # Route the Pydantic call shape used by our agents through the shared
602
+ # parser before validating, retaining native Google/schema-option paths.
603
+ schema = args[0] if args else kwargs.get("schema")
604
+ options = {k: v for k, v in kwargs.items() if k != "schema"}
605
+ if (
606
+ self.endpoint is not None
607
+ and not is_google_provider(self.endpoint.provider)
608
+ and isinstance(schema, type)
609
+ and issubclass(schema, BaseModel)
610
+ and len(args) <= 1
611
+ and not options
612
+ ):
613
+ async def structured(messages, config):
614
+ return await acomplete_structured(
615
+ self.with_config(config), messages, schema=schema,
616
+ )
617
+
618
+ return RunnableLambda(structured)
619
+ if hasattr(self.base_model, "with_structured_output"):
620
+ return RobustChatModelWrapper(
621
+ self.base_model.with_structured_output(*args, **kwargs),
622
+ self.ctx,
623
+ endpoint=self.endpoint,
624
+ )
625
+ raise AttributeError(f"{type(self.base_model)} does not support with_structured_output")
626
+
627
+ def __or__(self, other):
628
+ return RobustChatModelWrapper(
629
+ self.base_model.__or__(other), self.ctx, endpoint=self.endpoint
630
+ )
631
+
632
+ def __ror__(self, other):
633
+ return RobustChatModelWrapper(
634
+ self.base_model.__ror__(other), self.ctx, endpoint=self.endpoint
635
+ )
636
+
637
+ def _provider_value(self) -> str | None:
638
+ provider = getattr(self.endpoint, "provider", None)
639
+ if provider is None:
640
+ return None
641
+ return str(getattr(provider, "value", provider))
642
+
643
+ def _endpoint_key(self) -> str:
644
+ if self.endpoint is not None:
645
+ return f"{self._provider_value()}:{self.endpoint.model_name}"
646
+ return type(self.base_model).__name__
647
+
648
+ def _traced_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict, Any]:
649
+ trace_id = None
650
+ if self.ctx and self.ctx.data_engine:
651
+ trace_id = CURRENT_TRACE_ID.get()
652
+ if trace_id:
653
+ args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
654
+ return args, kwargs, trace_id
655
+
656
+ def _meter_usage(self, response) -> None:
657
+ """Record measured token usage for this call (M0 metering, best-effort)."""
658
+ engine = self.ctx.data_engine if self.ctx else None
659
+ if engine is None:
660
+ engine = _get_current_data_engine()
661
+ record_llm_usage(engine, response, source=self._endpoint_key())
662
+
663
+ async def ainvoke(self, *args, **kwargs):
664
+ args, kwargs, _ = self._traced_call(args, kwargs)
665
+ response = await _run_with_recovery(
666
+ functools.partial(self.base_model.ainvoke, *args, **kwargs),
667
+ provider=self._provider_value(),
668
+ endpoint_key=self._endpoint_key(),
669
+ )
670
+ self._meter_usage(response)
671
+ return response
672
+
673
+ async def complete(self, *args, **kwargs):
674
+ """Single completion entry point: returns the full final message.
675
+
676
+ Streaming is a transport detail handled internally — provider chunks
677
+ are forwarded to the live UI as deltas and accumulated here, so
678
+ downstream consumers only ever see one complete message (or a typed
679
+ LLMCallError). A mid-stream failure discards the partial output,
680
+ signals the UI to drop it, and retries the whole call per policy;
681
+ partial or duplicated chunks can never reach message history.
682
+ """
683
+ args, kwargs, _ = self._traced_call(args, kwargs)
684
+ emit_deltas = bool(self.ctx and self.ctx.data_engine)
685
+ response = await _run_with_recovery(
686
+ functools.partial(self._complete_attempt, emit_deltas, *args, **kwargs),
687
+ provider=self._provider_value(),
688
+ endpoint_key=self._endpoint_key(),
689
+ )
690
+ self._meter_usage(response)
691
+ return response
692
+
693
+ def _emit_stream_delta(self, stream_exec_id, chunk) -> None:
694
+ content = getattr(chunk, "content", None)
695
+ if not content:
696
+ return
697
+ text_to_stream = ""
698
+ thinking_to_stream = ""
699
+ if isinstance(content, str):
700
+ text_to_stream = content
701
+ elif isinstance(content, list):
702
+ for item in content:
703
+ if isinstance(item, dict):
704
+ if item.get("type") == "text":
705
+ text_to_stream += item.get("text", "")
706
+ elif item.get("type") == "thinking":
707
+ thinking_to_stream += item.get("thinking", "")
708
+ if text_to_stream:
709
+ self.ctx.data_engine.stream_output(stream_exec_id, text_to_stream, is_thinking=False)
710
+ if thinking_to_stream:
711
+ self.ctx.data_engine.stream_output(stream_exec_id, thinking_to_stream, is_thinking=True)
712
+
713
+ async def _complete_attempt(self, emit_deltas: bool, *args, **kwargs):
714
+ endpoint_key = self._endpoint_key()
715
+ if endpoint_key in _NON_STREAMING_ENDPOINTS:
716
+ return await self.base_model.ainvoke(*args, **kwargs)
717
+
718
+ stream_exec_id = uuid4()
719
+ full_response = None
720
+ try:
721
+ async for chunk in self.base_model.astream(*args, **kwargs):
722
+ if full_response is None:
723
+ full_response = chunk
724
+ else:
725
+ full_response = full_response + chunk
726
+ if emit_deltas:
727
+ self._emit_stream_delta(stream_exec_id, chunk)
728
+ except (KeyboardInterrupt, asyncio.CancelledError):
729
+ raise
730
+ except Exception as stream_error:
731
+ if full_response is not None:
732
+ # Mid-stream failure: the partial output must never reach
733
+ # message history. Tell the UI to discard it, then let the
734
+ # recovery policy retry the whole call with a fresh stream id.
735
+ llm_logger.warning(
736
+ f"LLM stream broke mid-response ({stream_error}); discarding partial output."
737
+ )
738
+ _record_llm_event(
739
+ "llm_stream_reset",
740
+ {
741
+ "stream_exec_id": str(stream_exec_id),
742
+ "action": "discard",
743
+ "reason": "mid_stream_failure",
744
+ "error": str(stream_error)[:500],
745
+ "category": classify_failure(stream_error).category.value,
746
+ "message": (
747
+ "A request error occurred during output generation, typically caused by lower API priority. Retrying automatically..."
748
+ ),
749
+ },
750
+ )
751
+ raise
752
+ if _is_stream_unsupported_error(stream_error):
753
+ _NON_STREAMING_ENDPOINTS.add(endpoint_key)
754
+ llm_logger.warning(
755
+ f"Endpoint {endpoint_key} does not support streaming"
756
+ f" ({stream_error}); switching to non-streaming calls."
757
+ )
758
+ _record_llm_event(
759
+ "llm_stream_downgrade",
760
+ {"endpoint": endpoint_key, "error": str(stream_error)[:500]},
761
+ )
762
+ return await self.base_model.ainvoke(*args, **kwargs)
763
+ raise
764
+ if full_response is None:
765
+ llm_logger.warning("LLM stream yielded no chunks; using non-streaming call.")
766
+ return await self.base_model.ainvoke(*args, **kwargs)
767
+ return full_response
768
+
769
+ async def astream(self, *args, **kwargs):
770
+ """Deprecated compatibility shim: yields exactly one final message.
771
+
772
+ The old chunk-level astream retry could re-deliver already-yielded
773
+ chunks after a mid-stream failure, corrupting accumulated message
774
+ history. Streaming now happens inside complete(); live token deltas
775
+ still reach the UI through the data engine.
776
+ """
777
+ yield await self.complete(*args, **kwargs)
778
+
779
+
780
+ def _is_stream_unsupported_error(error: BaseException) -> bool:
781
+ """Detect 'this endpoint/model cannot stream' as opposed to a transient failure.
782
+
783
+ A positive here permanently disables streaming for the endpoint in this
784
+ process, so a bare AttributeError/TypeError from unrelated code must not
785
+ qualify — the message has to actually implicate streaming.
786
+ """
787
+ if isinstance(error, NotImplementedError):
788
+ return True
789
+ message = str(error).lower()
790
+ if isinstance(error, (AttributeError, TypeError)):
791
+ return "stream" in message
792
+ return "stream" in message and any(
793
+ marker in message
794
+ for marker in ("not support", "unsupported", "not implemented", "not available")
795
+ )
796
+
797
+
798
+ async def acomplete(llm, *args, **kwargs):
799
+ """Get one complete response from any chat model object.
800
+
801
+ This is the single call-shape agents should use. For gateway-managed
802
+ models (RobustChatModelWrapper) it delegates to complete(), which owns
803
+ streaming, classified retries, and telemetry. For raw models and test
804
+ doubles it preserves the legacy accumulate-stream-else-ainvoke shape.
805
+ """
806
+ if isinstance(llm, RobustChatModelWrapper):
807
+ return await llm.complete(*args, **kwargs)
808
+
809
+ try:
810
+ full_response = None
811
+ async for chunk in llm.astream(*args, **kwargs):
812
+ if full_response is None:
813
+ full_response = chunk
814
+ else:
815
+ full_response = full_response + chunk
816
+ if full_response is not None:
817
+ return full_response
818
+ except (KeyboardInterrupt, asyncio.CancelledError):
819
+ raise
820
+ except Exception as stream_error:
821
+ llm_logger.warning(
822
+ f"astream unavailable on {type(llm).__name__} ({stream_error}); using ainvoke."
823
+ )
824
+ return await llm.ainvoke(*args, **kwargs)
825
+
826
+
827
+ async def acomplete_structured(
828
+ llm,
829
+ messages: list,
830
+ *,
831
+ schema=None,
832
+ correction_attempts: int = 1,
833
+ ):
834
+ """Complete and parse a JSON response, with one corrective re-ask.
835
+
836
+ On a parse failure the model is shown its own output's parse error and
837
+ asked to re-emit valid JSON (up to ``correction_attempts`` times) — giving
838
+ the model a chance to see its mistake instead of feeding garbage
839
+ downstream. If it still fails, raises StructuredOutputError (never
840
+ returns raw text masquerading as parsed data). Each repair round emits an
841
+ llm_parse_repair telemetry event.
842
+ """
843
+ if schema is not None:
844
+ messages = [
845
+ *messages,
846
+ HumanMessage(content=(
847
+ "Return ONLY a JSON object matching this JSON Schema. "
848
+ "Do not include reasoning, prose, or code fences.\n"
849
+ + json.dumps(schema.model_json_schema(), ensure_ascii=False)
850
+ )),
851
+ ]
852
+ response = await acomplete(llm, messages)
853
+ text = content_to_text(getattr(response, "content", ""))
854
+ parsed = parse_structured(text, schema=schema)
855
+ attempt = 0
856
+ while isinstance(parsed, ParseFailure) and attempt < correction_attempts:
857
+ attempt += 1
858
+ llm_logger.warning(
859
+ f"Structured output parse failed ({parsed.error});"
860
+ f" asking the model to correct itself (attempt {attempt})..."
861
+ )
862
+ _record_llm_event(
863
+ "llm_parse_repair",
864
+ {"error": parsed.error[:500], "attempt": attempt},
865
+ )
866
+ correction_messages = [
867
+ *messages,
868
+ response if isinstance(response, BaseMessage) else AIMessage(content=text),
869
+ HumanMessage(
870
+ content=(
871
+ "Your previous reply could not be parsed as the required"
872
+ f" JSON ({parsed.error}). Re-emit ONLY the corrected JSON"
873
+ " payload, with no surrounding prose or code fences."
874
+ )
875
+ ),
876
+ ]
877
+ response = await acomplete(llm, correction_messages)
878
+ text = content_to_text(getattr(response, "content", ""))
879
+ parsed = parse_structured(text, schema=schema)
880
+ if isinstance(parsed, ParseFailure):
881
+ _record_llm_event(
882
+ "llm_gave_up",
883
+ {
884
+ "error": f"structured output unparseable: {parsed.error[:400]}",
885
+ "category": "bad_request",
886
+ "retryable": False,
887
+ },
888
+ status="failed",
889
+ )
890
+ raise StructuredOutputError(parsed)
891
+ return parsed
892
+
893
+
894
+ # Wraps every LLM call: tells the user when a call is slow and enforces a hard
895
+ # timeout. The timeout does not count while the task is paused (PAUSE_FILE).
896
+ invoke_llm_with_timeout_message = LLMWaitNotice(
897
+ notify=user_messages_logger.info,
898
+ on_timeout=user_messages_logger.error,
899
+ hold=lambda: PAUSE_FILE.exists(),
900
+ default_hard_timeout=180,
901
+ ).__call__
902
+
903
+
904
+ # Backward compatible factory functions delegating to ModelFactory
905
+ def get_google_llm(
906
+ model_name: str = "gemini-3.8-flash",
907
+ temperature: float | None = None,
908
+ timeout: float | None = None,
909
+ thinking_budget: int | None = None,
910
+ thinking_level: str | None = "medium",
911
+ include_thoughts: bool | None = None,
912
+ enable_grounding: bool = False,
913
+ ) -> BaseChatModel:
914
+ ep = ModelEndpoint(
915
+ provider=ModelProvider.GOOGLE,
916
+ model_name=model_name,
917
+ temperature=temperature or 0.0,
918
+ timeout_seconds=timeout or 60.0,
919
+ thinking_budget=thinking_budget,
920
+ thinking_level=thinking_level,
921
+ include_thoughts=include_thoughts,
922
+ enable_grounding=enable_grounding,
923
+ )
924
+ return ModelFactory.create_model(ep)
925
+
926
+
927
+ def _resolve_endpoint(
928
+ ctx: ArtemisContext,
929
+ name: str,
930
+ is_utils: bool = False,
931
+ use_fallback: bool = False,
932
+ ) -> ModelEndpoint:
933
+ """Cleanly resolves a ModelEndpoint from context llm_config."""
934
+ if getattr(ctx, "llm_config", None) is None:
935
+ try:
936
+ ctx.llm_config = get_default_llm_config()
937
+ except Exception as exc: # pylint: disable=broad-exception-caught
938
+ # Config loading has open-ended failure modes; surface the real cause
939
+ # instead of letting the attribute access below fail with a misleading
940
+ # AttributeError on the still-missing config.
941
+ raise RuntimeError(
942
+ f"Default LLM config could not be loaded while resolving '{name}': {exc}"
943
+ ) from exc
944
+
945
+ cfg = ctx.llm_config.get_utils(name) if is_utils else ctx.llm_config.get_agent(name)
946
+
947
+ if use_fallback:
948
+ if isinstance(cfg, LLMWithFallback) or (hasattr(cfg, "fallback") and cfg.fallback):
949
+ cfg = cfg.fallback
950
+ else:
951
+ raise ValueError(f"LLM configuration for '{name}' has no fallback!")
952
+
953
+ def _get_val(obj, attr, expected_type):
954
+ val = getattr(obj, attr, None)
955
+ return val if isinstance(val, expected_type) else None
956
+
957
+ provider_val = getattr(cfg, "provider", "google")
958
+ model_val = getattr(cfg, "model", "gemini-2.5-flash")
959
+
960
+ return ModelEndpoint(
961
+ provider=ModelProvider.from_string(provider_val),
962
+ model_name=str(model_val),
963
+ temperature=_get_val(cfg, "temperature", (int, float)) or 0.0,
964
+ timeout_seconds=_get_val(cfg, "timeout", (int, float)) or 60.0,
965
+ thinking_budget=_get_val(cfg, "thinking_budget", int),
966
+ thinking_level=_get_val(cfg, "thinking_level", str),
967
+ reasoning_effort=_get_val(cfg, "reasoning_effort", str),
968
+ include_thoughts=_get_val(cfg, "include_thoughts", bool),
969
+ enable_grounding=_get_val(cfg, "enable_grounding", bool) or False,
970
+ )
971
+
972
+
973
+ def _get_llm(
974
+ ctx: ArtemisContext,
975
+ name: str,
976
+ is_utils: bool = False,
977
+ use_fallback: bool = False,
978
+ temperature: float | None = None,
979
+ ) -> BaseChatModel:
980
+ """Resolves and instantiates the appropriate LLM wrapper for the given agent role."""
981
+ endpoint = _resolve_endpoint(ctx, name, is_utils=is_utils, use_fallback=use_fallback)
982
+ if temperature is not None:
983
+ endpoint = endpoint.model_copy(update={"temperature": temperature})
984
+ raw_model = ModelFactory.get_model(endpoint)
985
+
986
+ handler = DataEngineCallbackHandler(ctx)
987
+ bound_model = raw_model.with_config(callbacks=[handler])
988
+
989
+ return RobustChatModelWrapper(bound_model, ctx, endpoint=endpoint) # type: ignore
990
+
991
+
992
+ get_llm: GetLLM = _get_llm
993
+
994
+
995
+ class _ObservedFallback(FallbackRunner):
996
+ """Fall back only on failures another endpoint could fix, and record it.
997
+
998
+ Errors are classified first; a bad request, for example, is re-raised
999
+ because a weaker model would only hide the bug. Each switch is logged at
1000
+ WARNING and recorded as an ``llm_fallback`` event. While the main call
1001
+ runs, the retry layer knows a fallback exists and hands over as soon as
1002
+ retries run out instead of pausing the task.
1003
+ """
1004
+
1005
+ @contextmanager
1006
+ def main_scope(self) -> Iterator[None]:
1007
+ # Reset before the fallback call starts: nothing is behind the
1008
+ # fallback, so if it runs out of retries the task should pause.
1009
+ token = _FALLBACK_AVAILABLE.set(True)
1010
+ try:
1011
+ yield
1012
+ finally:
1013
+ _FALLBACK_AVAILABLE.reset(token)
1014
+
1015
+ def fallback_reason(self, error: Exception) -> tuple[str, str | None] | None:
1016
+ if isinstance(error, LLMCallError):
1017
+ reason, failure = "terminal_error", error.failure
1018
+ else:
1019
+ reason, failure = "error", classify_failure(error)
1020
+ if failure.category is FailureCategory.CANCELLED:
1021
+ return None
1022
+ return (reason, failure.category.value) if failure.should_fallback else None
1023
+
1024
+ def on_fallback(self, reason: str, category: str | None, error: str | None) -> None:
1025
+ llm_logger.warning(
1026
+ f"❗ Main LLM inference failed ({reason}"
1027
+ f"{f': {error}' if error else ''}). Falling back..."
1028
+ )
1029
+ _record_llm_event(
1030
+ "llm_fallback",
1031
+ {
1032
+ "reason": reason,
1033
+ "category": category,
1034
+ "error": error[:500] if error else None,
1035
+ },
1036
+ )
1037
+
1038
+
1039
+ # Runs main_call; switches to fallback_call when another endpoint could succeed.
1040
+ with_fallback = _ObservedFallback().run