synth-ai 0.2.8.dev2__py3-none-any.whl → 0.4.3__py3-none-any.whl

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 (740) hide show
  1. synth_ai/__init__.py +44 -24
  2. synth_ai/__main__.py +30 -3
  3. synth_ai/cli/__init__.py +103 -48
  4. synth_ai/cli/__main__.py +42 -0
  5. synth_ai/cli/_internal/__init__.py +5 -0
  6. synth_ai/cli/_internal/modal_wrapper.py +31 -0
  7. synth_ai/cli/_internal/storage.py +20 -0
  8. synth_ai/cli/_internal/typer_patch.py +47 -0
  9. synth_ai/cli/_internal/validate_task_app.py +29 -0
  10. synth_ai/cli/agents/__init__.py +17 -0
  11. synth_ai/cli/agents/claude.py +77 -0
  12. synth_ai/cli/agents/codex.py +265 -0
  13. synth_ai/cli/agents/opencode.py +253 -0
  14. synth_ai/cli/commands/__init__.py +18 -0
  15. synth_ai/cli/commands/artifacts/__init__.py +13 -0
  16. synth_ai/cli/commands/artifacts/client.py +119 -0
  17. synth_ai/cli/commands/artifacts/config.py +57 -0
  18. synth_ai/cli/commands/artifacts/core.py +24 -0
  19. synth_ai/cli/commands/artifacts/download.py +188 -0
  20. synth_ai/cli/commands/artifacts/export.py +186 -0
  21. synth_ai/cli/commands/artifacts/list.py +156 -0
  22. synth_ai/cli/commands/artifacts/parsing.py +250 -0
  23. synth_ai/cli/commands/artifacts/show.py +336 -0
  24. synth_ai/cli/commands/demo/__init__.py +3 -0
  25. synth_ai/cli/commands/demo/core.py +153 -0
  26. synth_ai/cli/commands/eval/__init__.py +10 -0
  27. synth_ai/cli/commands/eval/config.py +338 -0
  28. synth_ai/cli/commands/eval/core.py +256 -0
  29. synth_ai/cli/commands/eval/runner.py +704 -0
  30. synth_ai/cli/commands/eval/validation.py +60 -0
  31. synth_ai/cli/commands/filter/__init__.py +12 -0
  32. synth_ai/cli/commands/filter/core.py +424 -0
  33. synth_ai/cli/commands/filter/errors.py +55 -0
  34. synth_ai/cli/commands/filter/validation.py +77 -0
  35. synth_ai/cli/commands/help/__init__.py +185 -0
  36. synth_ai/cli/commands/help/core.py +72 -0
  37. synth_ai/cli/commands/scan/__init__.py +19 -0
  38. synth_ai/cli/commands/scan/cloudflare_scanner.py +403 -0
  39. synth_ai/cli/commands/scan/core.py +344 -0
  40. synth_ai/cli/commands/scan/health_checker.py +242 -0
  41. synth_ai/cli/commands/scan/local_scanner.py +278 -0
  42. synth_ai/cli/commands/scan/models.py +83 -0
  43. synth_ai/cli/commands/smoke/__init__.py +7 -0
  44. synth_ai/cli/commands/smoke/core.py +1428 -0
  45. synth_ai/cli/commands/status/__init__.py +3 -0
  46. synth_ai/cli/commands/status/client.py +91 -0
  47. synth_ai/cli/commands/status/config.py +12 -0
  48. synth_ai/cli/commands/status/errors.py +11 -0
  49. synth_ai/cli/commands/status/subcommands/__init__.py +3 -0
  50. synth_ai/cli/commands/status/subcommands/config.py +13 -0
  51. synth_ai/cli/commands/status/subcommands/files.py +34 -0
  52. synth_ai/cli/commands/status/subcommands/jobs.py +51 -0
  53. synth_ai/cli/commands/status/subcommands/models.py +35 -0
  54. synth_ai/cli/commands/status/subcommands/runs.py +34 -0
  55. synth_ai/cli/commands/status/subcommands/session.py +77 -0
  56. synth_ai/cli/commands/status/subcommands/summary.py +39 -0
  57. synth_ai/cli/commands/status/subcommands/utils.py +41 -0
  58. synth_ai/cli/commands/status/utils.py +23 -0
  59. synth_ai/cli/commands/train/__init__.py +53 -0
  60. synth_ai/cli/commands/train/core.py +22 -0
  61. synth_ai/cli/commands/train/errors.py +117 -0
  62. synth_ai/cli/commands/train/judge_schemas.py +201 -0
  63. synth_ai/cli/commands/train/judge_validation.py +305 -0
  64. synth_ai/cli/commands/train/prompt_learning_validation.py +633 -0
  65. synth_ai/cli/commands/train/validation.py +392 -0
  66. synth_ai/cli/demo_apps/__init__.py +10 -0
  67. synth_ai/cli/demo_apps/core/__init__.py +28 -0
  68. synth_ai/{demos → cli/demo_apps}/core/cli.py +783 -441
  69. synth_ai/cli/demo_apps/crafter/__init__.py +1 -0
  70. synth_ai/cli/demo_apps/crafter/crafter_fft_4b.toml +55 -0
  71. synth_ai/cli/demo_apps/crafter/grpo_crafter_task_app.py +186 -0
  72. synth_ai/cli/demo_apps/crafter/rl_from_base_qwen4b.toml +74 -0
  73. synth_ai/cli/demo_apps/demo_registry.py +176 -0
  74. synth_ai/cli/demo_apps/demo_task_apps/__init__.py +7 -0
  75. synth_ai/{demos → cli/demo_apps}/demo_task_apps/core.py +75 -37
  76. synth_ai/cli/demo_apps/demo_task_apps/crafter/__init__.py +1 -0
  77. synth_ai/cli/demo_apps/demo_task_apps/crafter/configs/crafter_fft_4b.toml +53 -0
  78. synth_ai/cli/demo_apps/demo_task_apps/crafter/configs/rl_from_base_qwen4b.toml +73 -0
  79. synth_ai/cli/demo_apps/demo_task_apps/crafter/grpo_crafter_task_app.py +185 -0
  80. synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/_common.py +1 -2
  81. synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/app.py +2 -1
  82. synth_ai/cli/demo_apps/demo_task_apps/math/config.toml +73 -0
  83. synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/deploy_modal.py +3 -6
  84. synth_ai/cli/demo_apps/demo_task_apps/math/modal_task_app.py +738 -0
  85. synth_ai/cli/demo_apps/demo_task_apps/math/task_app_entry.py +39 -0
  86. synth_ai/cli/demo_apps/math/__init__.py +1 -0
  87. synth_ai/cli/demo_apps/math/_common.py +16 -0
  88. synth_ai/cli/demo_apps/math/app.py +38 -0
  89. synth_ai/cli/demo_apps/math/config.toml +75 -0
  90. synth_ai/cli/demo_apps/math/deploy_modal.py +54 -0
  91. synth_ai/cli/demo_apps/math/modal_task_app.py +698 -0
  92. synth_ai/cli/demo_apps/math/task_app_entry.py +53 -0
  93. synth_ai/cli/demo_apps/mipro/main.py +271 -0
  94. synth_ai/cli/demo_apps/mipro/task_app.py +922 -0
  95. synth_ai/cli/demo_apps/mipro/train_cfg.toml +92 -0
  96. synth_ai/cli/demos/__init__.py +12 -0
  97. synth_ai/cli/demos/demo.py +32 -0
  98. synth_ai/cli/demos/rl_demo.py +254 -0
  99. synth_ai/cli/deploy.py +216 -0
  100. synth_ai/cli/infra/__init__.py +14 -0
  101. synth_ai/cli/{balance.py → infra/balance.py} +16 -4
  102. synth_ai/cli/infra/mcp.py +35 -0
  103. synth_ai/cli/infra/modal_app.py +36 -0
  104. synth_ai/cli/infra/setup.py +69 -0
  105. synth_ai/cli/infra/status.py +16 -0
  106. synth_ai/cli/infra/turso.py +77 -0
  107. synth_ai/cli/lib/__init__.py +10 -0
  108. synth_ai/cli/lib/agents.py +76 -0
  109. synth_ai/cli/lib/apps/modal_app.py +101 -0
  110. synth_ai/cli/lib/apps/task_app.py +642 -0
  111. synth_ai/cli/lib/bin.py +39 -0
  112. synth_ai/cli/lib/env.py +375 -0
  113. synth_ai/cli/lib/errors.py +85 -0
  114. synth_ai/cli/lib/modal.py +315 -0
  115. synth_ai/cli/lib/plotting.py +126 -0
  116. synth_ai/cli/lib/prompt_args.py +39 -0
  117. synth_ai/cli/lib/prompts.py +284 -0
  118. synth_ai/cli/lib/sqld.py +122 -0
  119. synth_ai/cli/lib/task_app_discovery.py +884 -0
  120. synth_ai/cli/lib/task_app_env.py +295 -0
  121. synth_ai/cli/lib/train_cfgs.py +300 -0
  122. synth_ai/cli/lib/tunnel_records.py +207 -0
  123. synth_ai/cli/local/__init__.py +14 -0
  124. synth_ai/cli/local/experiment_queue/__init__.py +72 -0
  125. synth_ai/cli/local/experiment_queue/api_schemas.py +221 -0
  126. synth_ai/cli/local/experiment_queue/celery_app.py +208 -0
  127. synth_ai/cli/local/experiment_queue/config.py +128 -0
  128. synth_ai/cli/local/experiment_queue/config_utils.py +272 -0
  129. synth_ai/cli/local/experiment_queue/database.py +175 -0
  130. synth_ai/cli/local/experiment_queue/dispatcher.py +119 -0
  131. synth_ai/cli/local/experiment_queue/models.py +231 -0
  132. synth_ai/cli/local/experiment_queue/progress_info.py +160 -0
  133. synth_ai/cli/local/experiment_queue/results.py +373 -0
  134. synth_ai/cli/local/experiment_queue/schemas.py +131 -0
  135. synth_ai/cli/local/experiment_queue/service.py +344 -0
  136. synth_ai/cli/local/experiment_queue/status.py +372 -0
  137. synth_ai/cli/local/experiment_queue/status_tracker.py +360 -0
  138. synth_ai/cli/local/experiment_queue/tasks.py +1984 -0
  139. synth_ai/cli/local/experiment_queue/trace_storage.py +65 -0
  140. synth_ai/cli/local/experiment_queue/validation.py +157 -0
  141. synth_ai/cli/local/session/__init__.py +92 -0
  142. synth_ai/cli/local/session/client.py +383 -0
  143. synth_ai/cli/local/session/constants.py +63 -0
  144. synth_ai/cli/local/session/exceptions.py +105 -0
  145. synth_ai/cli/local/session/manager.py +139 -0
  146. synth_ai/cli/local/session/models.py +89 -0
  147. synth_ai/cli/local/session/query.py +110 -0
  148. synth_ai/cli/root.py +150 -108
  149. synth_ai/cli/task_apps/__init__.py +37 -0
  150. synth_ai/cli/task_apps/commands.py +3145 -0
  151. synth_ai/cli/task_apps/deploy.py +7 -0
  152. synth_ai/cli/task_apps/list.py +26 -0
  153. synth_ai/cli/task_apps/main.py +36 -0
  154. synth_ai/cli/task_apps/modal_serve.py +11 -0
  155. synth_ai/cli/task_apps/serve.py +11 -0
  156. synth_ai/cli/training/__init__.py +8 -0
  157. synth_ai/cli/training/train.py +5 -0
  158. synth_ai/cli/training/train_cfg.py +34 -0
  159. synth_ai/cli/{watch.py → training/watch.py} +13 -18
  160. synth_ai/cli/turso.py +52 -0
  161. synth_ai/cli/utils/__init__.py +8 -0
  162. synth_ai/cli/utils/experiments.py +235 -0
  163. synth_ai/cli/utils/queue.py +504 -0
  164. synth_ai/cli/{recent.py → utils/recent.py} +13 -7
  165. synth_ai/cli/{traces.py → utils/traces.py} +9 -5
  166. synth_ai/contracts/__init__.py +67 -0
  167. synth_ai/core/__init__.py +100 -0
  168. synth_ai/core/_utils/__init__.py +54 -0
  169. synth_ai/core/_utils/base_url.py +10 -0
  170. synth_ai/core/_utils/http.py +10 -0
  171. synth_ai/core/_utils/prompts.py +14 -0
  172. synth_ai/core/_utils/task_app_state.py +12 -0
  173. synth_ai/core/_utils/user_config.py +10 -0
  174. synth_ai/core/apps/common.py +116 -0
  175. synth_ai/core/auth.py +95 -0
  176. synth_ai/core/cfgs.py +240 -0
  177. synth_ai/core/config/__init__.py +16 -0
  178. synth_ai/core/config/base.py +168 -0
  179. synth_ai/core/config/resolver.py +89 -0
  180. synth_ai/core/env.py +231 -0
  181. synth_ai/core/errors.py +126 -0
  182. synth_ai/core/http.py +230 -0
  183. synth_ai/core/integrations/__init__.py +11 -0
  184. synth_ai/core/integrations/cloudflare.py +1710 -0
  185. synth_ai/core/integrations/mcp/__init__.py +6 -0
  186. synth_ai/core/integrations/mcp/__main__.py +8 -0
  187. synth_ai/core/integrations/mcp/claude.py +36 -0
  188. synth_ai/core/integrations/mcp/main.py +254 -0
  189. synth_ai/core/integrations/mcp/setup.py +100 -0
  190. synth_ai/core/integrations/modal.py +277 -0
  191. synth_ai/core/json.py +72 -0
  192. synth_ai/core/log_filter.py +99 -0
  193. synth_ai/core/logging.py +82 -0
  194. synth_ai/core/paths.py +107 -0
  195. synth_ai/core/pricing.py +109 -0
  196. synth_ai/core/process.py +233 -0
  197. synth_ai/core/ssl.py +25 -0
  198. synth_ai/core/storage/__init__.py +71 -0
  199. synth_ai/core/task_app_state.py +318 -0
  200. synth_ai/core/telemetry.py +282 -0
  201. synth_ai/{tracing_v3 → core/tracing_v3}/__init__.py +5 -1
  202. synth_ai/{tracing_v3 → core/tracing_v3}/abstractions.py +21 -4
  203. synth_ai/core/tracing_v3/config.py +229 -0
  204. synth_ai/core/tracing_v3/constants.py +21 -0
  205. synth_ai/{tracing_v3 → core/tracing_v3}/db_config.py +42 -29
  206. synth_ai/{tracing_v3 → core/tracing_v3}/decorators.py +80 -45
  207. synth_ai/{tracing_v3 → core/tracing_v3}/examples/basic_usage.py +15 -9
  208. synth_ai/{tracing_v3 → core/tracing_v3}/hooks.py +6 -4
  209. synth_ai/{tracing_v3 → core/tracing_v3}/llm_call_record_helpers.py +161 -61
  210. synth_ai/{tracing_v3 → core/tracing_v3}/migration_helper.py +1 -2
  211. synth_ai/{tracing_v3 → core/tracing_v3}/replica_sync.py +12 -7
  212. synth_ai/core/tracing_v3/serialization.py +130 -0
  213. synth_ai/{tracing_v3 → core/tracing_v3}/session_tracer.py +88 -21
  214. synth_ai/{tracing_v3 → core/tracing_v3}/storage/base.py +99 -12
  215. synth_ai/core/tracing_v3/storage/config.py +109 -0
  216. synth_ai/{tracing_v3 → core/tracing_v3}/storage/factory.py +11 -9
  217. synth_ai/{tracing_v3 → core/tracing_v3}/storage/utils.py +15 -11
  218. synth_ai/core/tracing_v3/trace_utils.py +326 -0
  219. synth_ai/core/tracing_v3/turso/__init__.py +12 -0
  220. synth_ai/core/tracing_v3/turso/daemon.py +278 -0
  221. synth_ai/{tracing_v3 → core/tracing_v3}/turso/models.py +7 -3
  222. synth_ai/core/tracing_v3/turso/native_manager.py +1385 -0
  223. synth_ai/{tracing_v3 → core/tracing_v3}/utils.py +5 -4
  224. synth_ai/core/urls.py +18 -0
  225. synth_ai/core/user_config.py +137 -0
  226. synth_ai/core/uvicorn.py +222 -0
  227. synth_ai/data/__init__.py +83 -0
  228. synth_ai/data/enums.py +123 -0
  229. synth_ai/data/rewards.py +152 -0
  230. synth_ai/data/traces.py +35 -0
  231. synth_ai/products/__init__.py +6 -0
  232. synth_ai/products/graph_evolve/__init__.py +46 -0
  233. synth_ai/products/graph_evolve/client.py +226 -0
  234. synth_ai/products/graph_evolve/config.py +591 -0
  235. synth_ai/products/graph_evolve/converters/__init__.py +42 -0
  236. synth_ai/products/graph_evolve/converters/openai_sft.py +484 -0
  237. synth_ai/products/graph_evolve/examples/hotpotqa/config.toml +109 -0
  238. synth_ai/products/graph_evolve/run.py +222 -0
  239. synth_ai/products/graph_gepa/__init__.py +23 -0
  240. synth_ai/products/graph_gepa/converters/__init__.py +19 -0
  241. synth_ai/products/graph_gepa/converters/openai_sft.py +29 -0
  242. synth_ai/sdk/__init__.py +123 -0
  243. synth_ai/sdk/api/__init__.py +1 -0
  244. synth_ai/sdk/api/models/supported.py +514 -0
  245. synth_ai/sdk/api/research_agent/__init__.py +296 -0
  246. synth_ai/sdk/api/train/__init__.py +85 -0
  247. synth_ai/sdk/api/train/builders.py +895 -0
  248. synth_ai/sdk/api/train/cli.py +2199 -0
  249. synth_ai/sdk/api/train/config_finder.py +267 -0
  250. synth_ai/sdk/api/train/configs/__init__.py +65 -0
  251. synth_ai/sdk/api/train/configs/prompt_learning.py +1706 -0
  252. synth_ai/sdk/api/train/configs/rl.py +187 -0
  253. synth_ai/sdk/api/train/configs/sft.py +99 -0
  254. synth_ai/sdk/api/train/configs/shared.py +81 -0
  255. synth_ai/sdk/api/train/context_learning.py +312 -0
  256. synth_ai/sdk/api/train/env_resolver.py +418 -0
  257. synth_ai/sdk/api/train/graph_validators.py +216 -0
  258. synth_ai/sdk/api/train/graphgen.py +984 -0
  259. synth_ai/sdk/api/train/graphgen_models.py +823 -0
  260. synth_ai/sdk/api/train/graphgen_validators.py +109 -0
  261. synth_ai/sdk/api/train/local_api.py +10 -0
  262. synth_ai/sdk/api/train/pollers.py +124 -0
  263. synth_ai/sdk/api/train/progress/__init__.py +97 -0
  264. synth_ai/sdk/api/train/progress/dataclasses.py +569 -0
  265. synth_ai/sdk/api/train/progress/events.py +326 -0
  266. synth_ai/sdk/api/train/progress/results.py +428 -0
  267. synth_ai/sdk/api/train/progress/tracker.py +641 -0
  268. synth_ai/sdk/api/train/prompt_learning.py +469 -0
  269. synth_ai/sdk/api/train/rl.py +441 -0
  270. synth_ai/sdk/api/train/sft.py +396 -0
  271. synth_ai/sdk/api/train/summary.py +522 -0
  272. synth_ai/sdk/api/train/supported_algos.py +147 -0
  273. synth_ai/sdk/api/train/task_app.py +351 -0
  274. synth_ai/sdk/api/train/utils.py +279 -0
  275. synth_ai/sdk/api/train/validators.py +2424 -0
  276. synth_ai/sdk/graphs/__init__.py +15 -0
  277. synth_ai/sdk/graphs/completions.py +570 -0
  278. synth_ai/{inference → sdk/inference}/__init__.py +0 -1
  279. synth_ai/sdk/inference/client.py +128 -0
  280. synth_ai/sdk/jobs/__init__.py +16 -0
  281. synth_ai/sdk/jobs/client.py +371 -0
  282. synth_ai/sdk/judging/__init__.py +14 -0
  283. synth_ai/sdk/judging/base.py +24 -0
  284. synth_ai/sdk/judging/client.py +40 -0
  285. synth_ai/sdk/judging/schemas.py +222 -0
  286. synth_ai/sdk/judging/types.py +42 -0
  287. synth_ai/sdk/learning/__init__.py +99 -0
  288. synth_ai/sdk/learning/algorithms.py +14 -0
  289. synth_ai/{learning → sdk/learning}/client.py +121 -30
  290. synth_ai/sdk/learning/config.py +5 -0
  291. synth_ai/{learning → sdk/learning}/constants.py +0 -2
  292. synth_ai/sdk/learning/context_learning_client.py +531 -0
  293. synth_ai/sdk/learning/context_learning_types.py +292 -0
  294. synth_ai/sdk/learning/ft_client.py +7 -0
  295. synth_ai/{learning → sdk/learning}/health.py +15 -9
  296. synth_ai/{learning → sdk/learning}/jobs.py +44 -47
  297. synth_ai/sdk/learning/prompt_extraction.py +334 -0
  298. synth_ai/sdk/learning/prompt_learning_client.py +455 -0
  299. synth_ai/sdk/learning/prompt_learning_types.py +186 -0
  300. synth_ai/{rl → sdk/learning/rl}/__init__.py +13 -8
  301. synth_ai/{learning/rl_client.py → sdk/learning/rl/client.py} +89 -77
  302. synth_ai/sdk/learning/rl/config.py +31 -0
  303. synth_ai/{rl → sdk/learning/rl}/contracts.py +5 -14
  304. synth_ai/{rl → sdk/learning/rl}/env_keys.py +45 -16
  305. synth_ai/sdk/learning/rl/secrets.py +13 -0
  306. synth_ai/sdk/learning/rl_client.py +5 -0
  307. synth_ai/sdk/learning/sft/__init__.py +29 -0
  308. synth_ai/sdk/learning/sft/client.py +95 -0
  309. synth_ai/sdk/learning/sft/config.py +270 -0
  310. synth_ai/sdk/learning/sft/data.py +698 -0
  311. synth_ai/sdk/learning/sse.py +57 -0
  312. synth_ai/sdk/learning/validators.py +52 -0
  313. synth_ai/sdk/localapi/__init__.py +40 -0
  314. synth_ai/sdk/localapi/apps/__init__.py +28 -0
  315. synth_ai/sdk/localapi/client.py +10 -0
  316. synth_ai/sdk/localapi/contracts.py +10 -0
  317. synth_ai/sdk/localapi/helpers.py +519 -0
  318. synth_ai/sdk/localapi/rollouts.py +87 -0
  319. synth_ai/sdk/localapi/server.py +29 -0
  320. synth_ai/sdk/localapi/template.py +70 -0
  321. synth_ai/sdk/streaming/__init__.py +35 -0
  322. synth_ai/sdk/streaming/config.py +94 -0
  323. synth_ai/sdk/streaming/handlers.py +1997 -0
  324. synth_ai/sdk/streaming/streamer.py +713 -0
  325. synth_ai/sdk/streaming/types.py +112 -0
  326. synth_ai/sdk/task/__init__.py +164 -0
  327. synth_ai/sdk/task/apps/__init__.py +169 -0
  328. synth_ai/sdk/task/auth.py +165 -0
  329. synth_ai/sdk/task/client.py +175 -0
  330. synth_ai/sdk/task/config.py +257 -0
  331. synth_ai/sdk/task/contracts.py +219 -0
  332. synth_ai/sdk/task/datasets.py +108 -0
  333. synth_ai/sdk/task/errors.py +50 -0
  334. synth_ai/sdk/task/health.py +34 -0
  335. synth_ai/sdk/task/in_process.py +1190 -0
  336. synth_ai/sdk/task/in_process_runner.py +314 -0
  337. synth_ai/sdk/task/inference_api.py +299 -0
  338. synth_ai/sdk/task/json.py +111 -0
  339. synth_ai/sdk/task/proxy.py +287 -0
  340. synth_ai/sdk/task/rubrics/__init__.py +55 -0
  341. synth_ai/sdk/task/rubrics/loaders.py +156 -0
  342. synth_ai/sdk/task/rubrics/models.py +57 -0
  343. synth_ai/sdk/task/rubrics/scoring.py +116 -0
  344. synth_ai/sdk/task/rubrics/strict.py +149 -0
  345. synth_ai/sdk/task/rubrics.py +219 -0
  346. synth_ai/sdk/task/server.py +631 -0
  347. synth_ai/sdk/task/trace_correlation_helpers.py +539 -0
  348. synth_ai/sdk/task/tracing_utils.py +95 -0
  349. synth_ai/sdk/task/validators.py +441 -0
  350. synth_ai/sdk/task/vendors.py +59 -0
  351. synth_ai/sdk/training/__init__.py +102 -0
  352. synth_ai/sdk/tunnels/__init__.py +83 -0
  353. synth_ai/sdk/tunnels/cleanup.py +83 -0
  354. synth_ai/sdk/tunnels/ports.py +120 -0
  355. synth_ai/utils/__init__.py +213 -0
  356. synth_ai-0.4.3.dist-info/METADATA +262 -0
  357. synth_ai-0.4.3.dist-info/RECORD +370 -0
  358. {synth_ai-0.2.8.dev2.dist-info → synth_ai-0.4.3.dist-info}/entry_points.txt +0 -1
  359. synth_ai/cli/calc.py +0 -69
  360. synth_ai/cli/demo.py +0 -144
  361. synth_ai/cli/legacy_root_backup.py +0 -470
  362. synth_ai/cli/man.py +0 -106
  363. synth_ai/cli/rl_demo.py +0 -202
  364. synth_ai/cli/status.py +0 -133
  365. synth_ai/config/base_url.py +0 -107
  366. synth_ai/core/experiment.py +0 -15
  367. synth_ai/core/system.py +0 -15
  368. synth_ai/demos/core/__init__.py +0 -1
  369. synth_ai/demos/demo_task_apps/__init__.py +0 -1
  370. synth_ai/demos/demo_task_apps/math/config.toml +0 -129
  371. synth_ai/demos/demo_task_apps/math/deploy_task_app.sh +0 -22
  372. synth_ai/demos/demo_task_apps/math/modal_task_app.py +0 -415
  373. synth_ai/environments/__init__.py +0 -31
  374. synth_ai/environments/environment/__init__.py +0 -1
  375. synth_ai/environments/environment/artifacts/__init__.py +0 -1
  376. synth_ai/environments/environment/artifacts/base.py +0 -52
  377. synth_ai/environments/environment/core.py +0 -67
  378. synth_ai/environments/environment/db/__init__.py +0 -1
  379. synth_ai/environments/environment/db/sqlite.py +0 -45
  380. synth_ai/environments/environment/registry.py +0 -233
  381. synth_ai/environments/environment/resources/sqlite.py +0 -45
  382. synth_ai/environments/environment/results.py +0 -1
  383. synth_ai/environments/environment/rewards/__init__.py +0 -1
  384. synth_ai/environments/environment/rewards/core.py +0 -29
  385. synth_ai/environments/environment/shared_engine.py +0 -26
  386. synth_ai/environments/environment/tools/__init__.py +0 -200
  387. synth_ai/environments/examples/__init__.py +0 -1
  388. synth_ai/environments/examples/bandit/__init__.py +0 -33
  389. synth_ai/environments/examples/bandit/engine.py +0 -294
  390. synth_ai/environments/examples/bandit/environment.py +0 -194
  391. synth_ai/environments/examples/bandit/taskset.py +0 -200
  392. synth_ai/environments/examples/crafter_classic/__init__.py +0 -8
  393. synth_ai/environments/examples/crafter_classic/agent_demos/analyze_semantic_words_markdown.py +0 -250
  394. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_comprehensive_evaluation.py +0 -59
  395. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_browser.py +0 -152
  396. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_config.toml +0 -24
  397. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_evaluation_framework.py +0 -1194
  398. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/crafter_synth_config.toml +0 -56
  399. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_config_modal.toml +0 -32
  400. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_turso.py +0 -738
  401. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/kick_off_ft_modal.py +0 -384
  402. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_action_results.py +0 -53
  403. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_agent_actions.py +0 -178
  404. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_latest_run.py +0 -222
  405. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_lm_traces.py +0 -183
  406. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_no_rewards.py +0 -210
  407. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/analyze_trace_issue.py +0 -206
  408. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_db_schema.py +0 -49
  409. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/check_latest_results.py +0 -64
  410. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/debug_agent_responses.py +0 -88
  411. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/old/quick_trace_check.py +0 -77
  412. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/compare_experiments.py +0 -324
  413. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_turso.py +0 -580
  414. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/kick_off_ft_oai.py +0 -362
  415. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/multi_model_config.toml +0 -49
  416. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_enhanced_hooks.py +0 -332
  417. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_events.py +0 -97
  418. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/analyze_hook_results.py +0 -217
  419. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_hook_storage.py +0 -87
  420. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/check_seeds.py +0 -88
  421. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/compare_seed_performance.py +0 -195
  422. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/custom_eval_pipelines.py +0 -400
  423. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/plot_hook_frequency.py +0 -195
  424. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/old/seed_analysis_summary.py +0 -56
  425. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v3.py +0 -858
  426. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_quick_evaluation.py +0 -52
  427. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_react_agent.py +0 -874
  428. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +0 -1412
  429. synth_ai/environments/examples/crafter_classic/agent_demos/example_v3_usage.py +0 -216
  430. synth_ai/environments/examples/crafter_classic/agent_demos/old/compare_traces.py +0 -296
  431. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_comprehensive_evaluation.py +0 -58
  432. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_env_serialization.py +0 -464
  433. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_evaluation_browser.py +0 -152
  434. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_quick_evaluation.py +0 -51
  435. synth_ai/environments/examples/crafter_classic/agent_demos/old/crafter_trace_evaluation.py +0 -1412
  436. synth_ai/environments/examples/crafter_classic/agent_demos/old/debug_player_loss.py +0 -112
  437. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_service.py +0 -203
  438. synth_ai/environments/examples/crafter_classic/agent_demos/old/diagnose_slowness.py +0 -305
  439. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_by_difficulty.py +0 -126
  440. synth_ai/environments/examples/crafter_classic/agent_demos/old/eval_example.py +0 -94
  441. synth_ai/environments/examples/crafter_classic/agent_demos/old/explore_saved_states.py +0 -142
  442. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft.py +0 -26
  443. synth_ai/environments/examples/crafter_classic/agent_demos/old/filter_traces_sft_OLD.py +0 -984
  444. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_gemini.py +0 -724
  445. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_data_modal.py +0 -386
  446. synth_ai/environments/examples/crafter_classic/agent_demos/old/generate_ft_metadata.py +0 -205
  447. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_gemini.py +0 -150
  448. synth_ai/environments/examples/crafter_classic/agent_demos/old/kick_off_ft_modal.py +0 -283
  449. synth_ai/environments/examples/crafter_classic/agent_demos/old/prepare_vertex_ft.py +0 -280
  450. synth_ai/environments/examples/crafter_classic/agent_demos/old/profile_env_slowness.py +0 -456
  451. synth_ai/environments/examples/crafter_classic/agent_demos/old/replicate_issue.py +0 -166
  452. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_and_eval.py +0 -102
  453. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_comparison.py +0 -128
  454. synth_ai/environments/examples/crafter_classic/agent_demos/old/run_qwen_rollouts.py +0 -655
  455. synth_ai/environments/examples/crafter_classic/agent_demos/old/trace_eval_OLD.py +0 -202
  456. synth_ai/environments/examples/crafter_classic/agent_demos/old/validate_openai_format.py +0 -166
  457. synth_ai/environments/examples/crafter_classic/config_logging.py +0 -111
  458. synth_ai/environments/examples/crafter_classic/debug_translation.py +0 -0
  459. synth_ai/environments/examples/crafter_classic/engine.py +0 -579
  460. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +0 -64
  461. synth_ai/environments/examples/crafter_classic/engine_helpers/action_map.py +0 -6
  462. synth_ai/environments/examples/crafter_classic/engine_helpers/serialization.py +0 -75
  463. synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +0 -267
  464. synth_ai/environments/examples/crafter_classic/environment.py +0 -404
  465. synth_ai/environments/examples/crafter_classic/taskset.py +0 -233
  466. synth_ai/environments/examples/crafter_classic/trace_hooks_v3.py +0 -228
  467. synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +0 -299
  468. synth_ai/environments/examples/crafter_custom/__init__.py +0 -4
  469. synth_ai/environments/examples/crafter_custom/agent_demos/__init__.py +0 -1
  470. synth_ai/environments/examples/crafter_custom/agent_demos/trace_eval.py +0 -202
  471. synth_ai/environments/examples/crafter_custom/crafter/__init__.py +0 -7
  472. synth_ai/environments/examples/crafter_custom/crafter/config.py +0 -182
  473. synth_ai/environments/examples/crafter_custom/crafter/constants.py +0 -8
  474. synth_ai/environments/examples/crafter_custom/crafter/engine.py +0 -269
  475. synth_ai/environments/examples/crafter_custom/crafter/env.py +0 -262
  476. synth_ai/environments/examples/crafter_custom/crafter/objects.py +0 -417
  477. synth_ai/environments/examples/crafter_custom/crafter/recorder.py +0 -187
  478. synth_ai/environments/examples/crafter_custom/crafter/worldgen.py +0 -118
  479. synth_ai/environments/examples/crafter_custom/dataset_builder.py +0 -373
  480. synth_ai/environments/examples/crafter_custom/environment.py +0 -312
  481. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_issue.py +0 -159
  482. synth_ai/environments/examples/crafter_custom/old/analyze_diamond_spawning.py +0 -158
  483. synth_ai/environments/examples/crafter_custom/old/compare_worlds.py +0 -71
  484. synth_ai/environments/examples/crafter_custom/old/dataset_stats.py +0 -105
  485. synth_ai/environments/examples/crafter_custom/old/diamond_spawning_summary.py +0 -119
  486. synth_ai/environments/examples/crafter_custom/old/example_dataset_usage.py +0 -52
  487. synth_ai/environments/examples/crafter_custom/run_dataset.py +0 -305
  488. synth_ai/environments/examples/enron/art_helpers/email_search_tools.py +0 -156
  489. synth_ai/environments/examples/enron/art_helpers/local_email_db.py +0 -281
  490. synth_ai/environments/examples/enron/art_helpers/types_enron.py +0 -25
  491. synth_ai/environments/examples/enron/engine.py +0 -295
  492. synth_ai/environments/examples/enron/environment.py +0 -166
  493. synth_ai/environments/examples/enron/taskset.py +0 -112
  494. synth_ai/environments/examples/enron/units/keyword_stats.py +0 -112
  495. synth_ai/environments/examples/minigrid/__init__.py +0 -48
  496. synth_ai/environments/examples/minigrid/agent_demos/minigrid_evaluation_framework.py +0 -1188
  497. synth_ai/environments/examples/minigrid/agent_demos/minigrid_quick_evaluation.py +0 -48
  498. synth_ai/environments/examples/minigrid/agent_demos/minigrid_react_agent.py +0 -562
  499. synth_ai/environments/examples/minigrid/agent_demos/minigrid_trace_evaluation.py +0 -221
  500. synth_ai/environments/examples/minigrid/engine.py +0 -589
  501. synth_ai/environments/examples/minigrid/environment.py +0 -274
  502. synth_ai/environments/examples/minigrid/environment_mapping.py +0 -242
  503. synth_ai/environments/examples/minigrid/puzzle_loader.py +0 -417
  504. synth_ai/environments/examples/minigrid/taskset.py +0 -583
  505. synth_ai/environments/examples/nethack/__init__.py +0 -7
  506. synth_ai/environments/examples/nethack/achievements.py +0 -337
  507. synth_ai/environments/examples/nethack/agent_demos/nethack_evaluation_framework.py +0 -981
  508. synth_ai/environments/examples/nethack/agent_demos/nethack_quick_evaluation.py +0 -74
  509. synth_ai/environments/examples/nethack/agent_demos/nethack_react_agent.py +0 -831
  510. synth_ai/environments/examples/nethack/engine.py +0 -739
  511. synth_ai/environments/examples/nethack/environment.py +0 -256
  512. synth_ai/environments/examples/nethack/helpers/__init__.py +0 -41
  513. synth_ai/environments/examples/nethack/helpers/action_mapping.py +0 -301
  514. synth_ai/environments/examples/nethack/helpers/nle_wrapper.py +0 -402
  515. synth_ai/environments/examples/nethack/helpers/observation_utils.py +0 -433
  516. synth_ai/environments/examples/nethack/helpers/recording_wrapper.py +0 -200
  517. synth_ai/environments/examples/nethack/helpers/trajectory_recorder.py +0 -269
  518. synth_ai/environments/examples/nethack/helpers/visualization/replay_viewer.py +0 -308
  519. synth_ai/environments/examples/nethack/helpers/visualization/visualizer.py +0 -431
  520. synth_ai/environments/examples/nethack/taskset.py +0 -323
  521. synth_ai/environments/examples/red/__init__.py +0 -7
  522. synth_ai/environments/examples/red/agent_demos/__init__.py +0 -1
  523. synth_ai/environments/examples/red/config_logging.py +0 -110
  524. synth_ai/environments/examples/red/engine.py +0 -694
  525. synth_ai/environments/examples/red/engine_helpers/__init__.py +0 -1
  526. synth_ai/environments/examples/red/engine_helpers/memory_map.py +0 -28
  527. synth_ai/environments/examples/red/engine_helpers/reward_components.py +0 -276
  528. synth_ai/environments/examples/red/engine_helpers/reward_library/__init__.py +0 -142
  529. synth_ai/environments/examples/red/engine_helpers/reward_library/adaptive_rewards.py +0 -57
  530. synth_ai/environments/examples/red/engine_helpers/reward_library/battle_rewards.py +0 -284
  531. synth_ai/environments/examples/red/engine_helpers/reward_library/composite_rewards.py +0 -150
  532. synth_ai/environments/examples/red/engine_helpers/reward_library/economy_rewards.py +0 -138
  533. synth_ai/environments/examples/red/engine_helpers/reward_library/efficiency_rewards.py +0 -57
  534. synth_ai/environments/examples/red/engine_helpers/reward_library/exploration_rewards.py +0 -331
  535. synth_ai/environments/examples/red/engine_helpers/reward_library/novelty_rewards.py +0 -121
  536. synth_ai/environments/examples/red/engine_helpers/reward_library/pallet_town_rewards.py +0 -559
  537. synth_ai/environments/examples/red/engine_helpers/reward_library/pokemon_rewards.py +0 -313
  538. synth_ai/environments/examples/red/engine_helpers/reward_library/social_rewards.py +0 -148
  539. synth_ai/environments/examples/red/engine_helpers/reward_library/story_rewards.py +0 -247
  540. synth_ai/environments/examples/red/engine_helpers/screen_analysis.py +0 -368
  541. synth_ai/environments/examples/red/engine_helpers/state_extraction.py +0 -140
  542. synth_ai/environments/examples/red/environment.py +0 -238
  543. synth_ai/environments/examples/red/taskset.py +0 -79
  544. synth_ai/environments/examples/red/units/__init__.py +0 -1
  545. synth_ai/environments/examples/sokoban/__init__.py +0 -1
  546. synth_ai/environments/examples/sokoban/agent_demos/sokoban_full_eval.py +0 -899
  547. synth_ai/environments/examples/sokoban/engine.py +0 -678
  548. synth_ai/environments/examples/sokoban/engine_helpers/__init__.py +0 -1
  549. synth_ai/environments/examples/sokoban/engine_helpers/room_utils.py +0 -657
  550. synth_ai/environments/examples/sokoban/engine_helpers/vendored/__init__.py +0 -18
  551. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/__init__.py +0 -3
  552. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/boxoban_env.py +0 -131
  553. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/render_utils.py +0 -370
  554. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/room_utils.py +0 -332
  555. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env.py +0 -306
  556. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_fixed_targets.py +0 -67
  557. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_pull.py +0 -115
  558. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_two_player.py +0 -123
  559. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/sokoban_env_variations.py +0 -394
  560. synth_ai/environments/examples/sokoban/environment.py +0 -229
  561. synth_ai/environments/examples/sokoban/generate_verified_puzzles.py +0 -440
  562. synth_ai/environments/examples/sokoban/puzzle_loader.py +0 -312
  563. synth_ai/environments/examples/sokoban/taskset.py +0 -428
  564. synth_ai/environments/examples/sokoban/units/astar_common.py +0 -95
  565. synth_ai/environments/examples/tictactoe/__init__.py +0 -1
  566. synth_ai/environments/examples/tictactoe/engine.py +0 -368
  567. synth_ai/environments/examples/tictactoe/environment.py +0 -240
  568. synth_ai/environments/examples/tictactoe/taskset.py +0 -215
  569. synth_ai/environments/examples/verilog/__init__.py +0 -10
  570. synth_ai/environments/examples/verilog/engine.py +0 -329
  571. synth_ai/environments/examples/verilog/environment.py +0 -350
  572. synth_ai/environments/examples/verilog/taskset.py +0 -420
  573. synth_ai/environments/examples/wordle/__init__.py +0 -29
  574. synth_ai/environments/examples/wordle/engine.py +0 -398
  575. synth_ai/environments/examples/wordle/environment.py +0 -159
  576. synth_ai/environments/examples/wordle/helpers/generate_instances_wordfreq.py +0 -75
  577. synth_ai/environments/examples/wordle/taskset.py +0 -230
  578. synth_ai/environments/reproducibility/core.py +0 -42
  579. synth_ai/environments/reproducibility/helpers.py +0 -0
  580. synth_ai/environments/reproducibility/tree.py +0 -364
  581. synth_ai/environments/service/app.py +0 -98
  582. synth_ai/environments/service/core_routes.py +0 -1020
  583. synth_ai/environments/service/external_registry.py +0 -56
  584. synth_ai/environments/service/registry.py +0 -9
  585. synth_ai/environments/stateful/__init__.py +0 -1
  586. synth_ai/environments/stateful/core.py +0 -163
  587. synth_ai/environments/stateful/engine.py +0 -21
  588. synth_ai/environments/stateful/state.py +0 -7
  589. synth_ai/environments/tasks/api.py +0 -19
  590. synth_ai/environments/tasks/core.py +0 -80
  591. synth_ai/environments/tasks/filters.py +0 -41
  592. synth_ai/environments/tasks/utils.py +0 -91
  593. synth_ai/environments/v0_observability/history.py +0 -3
  594. synth_ai/environments/v0_observability/log.py +0 -2
  595. synth_ai/evals/base.py +0 -15
  596. synth_ai/experimental/synth_oss.py +0 -446
  597. synth_ai/handshake.py +0 -63
  598. synth_ai/http.py +0 -26
  599. synth_ai/http_client.py +0 -104
  600. synth_ai/inference/client.py +0 -20
  601. synth_ai/install_sqld.sh +0 -40
  602. synth_ai/jobs/client.py +0 -246
  603. synth_ai/learning/__init__.py +0 -24
  604. synth_ai/learning/config.py +0 -43
  605. synth_ai/learning/filtering.py +0 -0
  606. synth_ai/learning/ft_client.py +0 -59
  607. synth_ai/learning/offline/dpo.py +0 -0
  608. synth_ai/learning/offline/providers.py +0 -7
  609. synth_ai/learning/offline/sft.py +0 -0
  610. synth_ai/learning/offline/shared.py +0 -0
  611. synth_ai/learning/online/grpo.py +0 -0
  612. synth_ai/learning/online/irft.py +0 -0
  613. synth_ai/learning/prompts/banking77_injection_eval.py +0 -168
  614. synth_ai/learning/prompts/gepa.py +0 -0
  615. synth_ai/learning/prompts/hello_world_in_context_injection_ex.py +0 -213
  616. synth_ai/learning/prompts/mipro.py +0 -289
  617. synth_ai/learning/prompts/random_search.py +0 -246
  618. synth_ai/learning/prompts/run_mipro_banking77.py +0 -172
  619. synth_ai/learning/prompts/run_random_search_banking77.py +0 -324
  620. synth_ai/learning/sse.py +0 -58
  621. synth_ai/learning/validators.py +0 -48
  622. synth_ai/lm/__init__.py +0 -51
  623. synth_ai/lm/caching/constants.py +0 -6
  624. synth_ai/lm/caching/dbs.py +0 -0
  625. synth_ai/lm/caching/ephemeral.py +0 -102
  626. synth_ai/lm/caching/handler.py +0 -137
  627. synth_ai/lm/caching/initialize.py +0 -11
  628. synth_ai/lm/caching/persistent.py +0 -114
  629. synth_ai/lm/config.py +0 -110
  630. synth_ai/lm/constants.py +0 -32
  631. synth_ai/lm/core/__init__.py +0 -8
  632. synth_ai/lm/core/all.py +0 -73
  633. synth_ai/lm/core/exceptions.py +0 -7
  634. synth_ai/lm/core/main.py +0 -319
  635. synth_ai/lm/core/main_v3.py +0 -594
  636. synth_ai/lm/core/synth_models.py +0 -48
  637. synth_ai/lm/core/vendor_clients.py +0 -188
  638. synth_ai/lm/cost/__init__.py +0 -0
  639. synth_ai/lm/cost/monitor.py +0 -1
  640. synth_ai/lm/cost/statefulness.py +0 -1
  641. synth_ai/lm/injection.py +0 -80
  642. synth_ai/lm/overrides.py +0 -206
  643. synth_ai/lm/provider_support/__init__.py +0 -8
  644. synth_ai/lm/provider_support/anthropic.py +0 -972
  645. synth_ai/lm/provider_support/openai.py +0 -1139
  646. synth_ai/lm/provider_support/suppress_logging.py +0 -31
  647. synth_ai/lm/structured_outputs/__init__.py +0 -0
  648. synth_ai/lm/structured_outputs/handler.py +0 -440
  649. synth_ai/lm/structured_outputs/inject.py +0 -297
  650. synth_ai/lm/structured_outputs/rehabilitate.py +0 -185
  651. synth_ai/lm/tools/__init__.py +0 -3
  652. synth_ai/lm/tools/base.py +0 -172
  653. synth_ai/lm/unified_interface.py +0 -202
  654. synth_ai/lm/vendors/__init__.py +0 -0
  655. synth_ai/lm/vendors/base.py +0 -81
  656. synth_ai/lm/vendors/core/__init__.py +0 -0
  657. synth_ai/lm/vendors/core/anthropic_api.py +0 -387
  658. synth_ai/lm/vendors/core/gemini_api.py +0 -292
  659. synth_ai/lm/vendors/core/mistral_api.py +0 -322
  660. synth_ai/lm/vendors/core/openai_api.py +0 -225
  661. synth_ai/lm/vendors/core/synth_dev_api.py +0 -0
  662. synth_ai/lm/vendors/local/__init__.py +0 -0
  663. synth_ai/lm/vendors/local/ollama.py +0 -0
  664. synth_ai/lm/vendors/openai_standard.py +0 -780
  665. synth_ai/lm/vendors/openai_standard_responses.py +0 -256
  666. synth_ai/lm/vendors/retries.py +0 -22
  667. synth_ai/lm/vendors/supported/__init__.py +0 -0
  668. synth_ai/lm/vendors/supported/custom_endpoint.py +0 -417
  669. synth_ai/lm/vendors/supported/deepseek.py +0 -69
  670. synth_ai/lm/vendors/supported/grok.py +0 -75
  671. synth_ai/lm/vendors/supported/groq.py +0 -16
  672. synth_ai/lm/vendors/supported/ollama.py +0 -15
  673. synth_ai/lm/vendors/supported/openrouter.py +0 -74
  674. synth_ai/lm/vendors/supported/together.py +0 -11
  675. synth_ai/lm/vendors/synth_client.py +0 -808
  676. synth_ai/lm/warmup.py +0 -186
  677. synth_ai/rl/secrets.py +0 -19
  678. synth_ai/scripts/verify_rewards.py +0 -100
  679. synth_ai/task/__init__.py +0 -10
  680. synth_ai/task/contracts.py +0 -120
  681. synth_ai/task/health.py +0 -28
  682. synth_ai/task/validators.py +0 -12
  683. synth_ai/tracing/__init__.py +0 -30
  684. synth_ai/tracing_v1/__init__.py +0 -33
  685. synth_ai/tracing_v3/config.py +0 -84
  686. synth_ai/tracing_v3/storage/config.py +0 -62
  687. synth_ai/tracing_v3/turso/__init__.py +0 -25
  688. synth_ai/tracing_v3/turso/daemon.py +0 -144
  689. synth_ai/tracing_v3/turso/manager.py +0 -760
  690. synth_ai/v0/tracing/__init__.py +0 -0
  691. synth_ai/v0/tracing/abstractions.py +0 -224
  692. synth_ai/v0/tracing/base_client.py +0 -91
  693. synth_ai/v0/tracing/client_manager.py +0 -131
  694. synth_ai/v0/tracing/config.py +0 -142
  695. synth_ai/v0/tracing/context.py +0 -146
  696. synth_ai/v0/tracing/decorators.py +0 -682
  697. synth_ai/v0/tracing/events/__init__.py +0 -0
  698. synth_ai/v0/tracing/events/manage.py +0 -147
  699. synth_ai/v0/tracing/events/scope.py +0 -86
  700. synth_ai/v0/tracing/events/store.py +0 -228
  701. synth_ai/v0/tracing/immediate_client.py +0 -151
  702. synth_ai/v0/tracing/local.py +0 -18
  703. synth_ai/v0/tracing/log_client_base.py +0 -73
  704. synth_ai/v0/tracing/retry_queue.py +0 -186
  705. synth_ai/v0/tracing/trackers.py +0 -515
  706. synth_ai/v0/tracing/upload.py +0 -512
  707. synth_ai/v0/tracing/utils.py +0 -9
  708. synth_ai/v0/tracing_v1/__init__.py +0 -16
  709. synth_ai/v0/tracing_v1/abstractions.py +0 -224
  710. synth_ai/v0/tracing_v1/base_client.py +0 -91
  711. synth_ai/v0/tracing_v1/client_manager.py +0 -131
  712. synth_ai/v0/tracing_v1/config.py +0 -142
  713. synth_ai/v0/tracing_v1/context.py +0 -146
  714. synth_ai/v0/tracing_v1/decorators.py +0 -703
  715. synth_ai/v0/tracing_v1/events/__init__.py +0 -0
  716. synth_ai/v0/tracing_v1/events/manage.py +0 -147
  717. synth_ai/v0/tracing_v1/events/scope.py +0 -86
  718. synth_ai/v0/tracing_v1/events/store.py +0 -228
  719. synth_ai/v0/tracing_v1/immediate_client.py +0 -151
  720. synth_ai/v0/tracing_v1/local.py +0 -18
  721. synth_ai/v0/tracing_v1/log_client_base.py +0 -73
  722. synth_ai/v0/tracing_v1/retry_queue.py +0 -186
  723. synth_ai/v0/tracing_v1/trackers.py +0 -515
  724. synth_ai/v0/tracing_v1/upload.py +0 -527
  725. synth_ai/v0/tracing_v1/utils.py +0 -9
  726. synth_ai/zyk/__init__.py +0 -30
  727. synth_ai-0.2.8.dev2.dist-info/METADATA +0 -129
  728. synth_ai-0.2.8.dev2.dist-info/RECORD +0 -420
  729. /synth_ai/{demos → cli/demo_apps}/demo_task_apps/math/__init__.py +0 -0
  730. /synth_ai/{lm/caching → core/apps}/__init__.py +0 -0
  731. /synth_ai/{tracing_v3 → core/tracing_v3}/lm_call_record_abstractions.py +0 -0
  732. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/__init__.py +0 -0
  733. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/exceptions.py +0 -0
  734. /synth_ai/{tracing_v3 → core/tracing_v3}/storage/types.py +0 -0
  735. /synth_ai/{compound/cais.py → py.typed} +0 -0
  736. /synth_ai/{learning → sdk/learning}/core.py +0 -0
  737. /synth_ai/{learning → sdk/learning}/gateway.py +0 -0
  738. {synth_ai-0.2.8.dev2.dist-info → synth_ai-0.4.3.dist-info}/WHEEL +0 -0
  739. {synth_ai-0.2.8.dev2.dist-info → synth_ai-0.4.3.dist-info}/licenses/LICENSE +0 -0
  740. {synth_ai-0.2.8.dev2.dist-info → synth_ai-0.4.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,3145 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import ast
5
+ import asyncio
6
+ import contextlib
7
+ import functools
8
+ import hashlib
9
+ import importlib
10
+ import importlib.util
11
+ import inspect
12
+ import os
13
+ import shlex
14
+ import shutil
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import textwrap
20
+ import time
21
+ import types
22
+ from collections.abc import Callable, Iterable, Iterator, Sequence
23
+ from dataclasses import dataclass
24
+ from datetime import UTC, datetime
25
+ from pathlib import Path
26
+ from typing import Any, cast
27
+
28
+ try: # Python 3.11+
29
+ import tomllib as _toml
30
+ except Exception: # pragma: no cover - fallback
31
+ _toml = None # type: ignore
32
+
33
+ import click
34
+ from click.exceptions import Abort
35
+
36
+ from synth_ai.cli.commands.filter import core as filter_core
37
+
38
+ # Tracing imports - make conditional for optional dependencies
39
+ try:
40
+ from synth_ai.core.tracing_v3 import ( # type: ignore[import-untyped]
41
+ BaseEvent,
42
+ EnvironmentEvent,
43
+ RuntimeEvent,
44
+ SessionEventMarkovBlanketMessage,
45
+ SessionMessageContent,
46
+ SessionTimeStep,
47
+ SessionTracer,
48
+ TimeRecord,
49
+ )
50
+ from synth_ai.core.tracing_v3 import ( # type: ignore[import-untyped]
51
+ SessionTrace as V3SessionTrace,
52
+ )
53
+ _TRACING_AVAILABLE = True
54
+ except (ImportError, ModuleNotFoundError, TypeError):
55
+ # Tracing system not available (missing optional dependencies)
56
+ BaseEvent = EnvironmentEvent = RuntimeEvent = None # type: ignore
57
+ SessionEventMarkovBlanketMessage = SessionMessageContent = None # type: ignore
58
+ SessionTimeStep = SessionTracer = TimeRecord = None # type: ignore
59
+ V3SessionTrace = None # type: ignore
60
+ _TRACING_AVAILABLE = False
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Dynamic imports to avoid hard dependencies during type checking.
64
+ # ---------------------------------------------------------------------------
65
+ ModalDeploymentConfigType = TaskAppConfigType = TaskAppEntryType = Any
66
+
67
+ try: # Resolve base URL defaults lazily
68
+ _config_module = cast(
69
+ Any, importlib.import_module("synth_ai.core.env")
70
+ )
71
+ PROD_BASE_URL_DEFAULT = cast(str, _config_module.PROD_BASE_URL_DEFAULT)
72
+ except Exception: # pragma: no cover - fallback
73
+ PROD_BASE_URL_DEFAULT = "https://api.usesynth.ai"
74
+
75
+ try:
76
+ _task_apps_module = cast(Any, importlib.import_module("synth_ai.sdk.task.apps"))
77
+ ModalDeploymentConfig = cast(
78
+ type[ModalDeploymentConfigType], _task_apps_module.ModalDeploymentConfig
79
+ )
80
+ TaskAppConfig = cast(type[TaskAppConfigType], _task_apps_module.TaskAppConfig)
81
+ TaskAppEntry = cast(type[TaskAppEntryType], _task_apps_module.TaskAppEntry)
82
+ registry = _task_apps_module.registry
83
+ except Exception as exc: # pragma: no cover - critical dependency
84
+ raise RuntimeError("Unable to load task app registry") from exc
85
+
86
+ try:
87
+ _task_server_module = cast(Any, importlib.import_module("synth_ai.sdk.task.server"))
88
+ create_task_app = cast(Callable[..., Any], _task_server_module.create_task_app)
89
+ run_task_app = cast(Callable[..., Any], _task_server_module.run_task_app)
90
+ except Exception as exc: # pragma: no cover - critical dependency
91
+ raise RuntimeError("Unable to load task app server utilities") from exc
92
+
93
+
94
+ def _load_demo_directory() -> Path | None:
95
+ """Return the demo task apps directory if available."""
96
+
97
+ try:
98
+ module = cast(
99
+ Any, importlib.import_module("synth_ai.cli.demo_apps.demo_task_apps.core")
100
+ )
101
+ loader = cast(Callable[[], str | Path | None], module.load_demo_dir)
102
+ demo_dir = loader()
103
+ if isinstance(demo_dir, str | Path):
104
+ demo_path = Path(demo_dir)
105
+ if demo_path.exists():
106
+ return demo_path.resolve()
107
+ except Exception:
108
+ return None
109
+ return None
110
+
111
+
112
+ def _maybe_import(name: str) -> Any:
113
+ """Safely import a module by name and return it, or None on failure."""
114
+
115
+ try:
116
+ return importlib.import_module(name)
117
+ except Exception:
118
+ return None
119
+
120
+ REPO_ROOT = Path(__file__).resolve().parents[2]
121
+
122
+ DEFAULT_IGNORE_DIRS = {
123
+ ".git",
124
+ "__pycache__",
125
+ "node_modules",
126
+ "venv",
127
+ ".venv",
128
+ "build",
129
+ "dist",
130
+ ".mypy_cache",
131
+ ".pytest_cache",
132
+ }
133
+
134
+ DEFAULT_SEARCH_RELATIVE = (
135
+ Path("."),
136
+ Path("examples"),
137
+ Path("synth_ai"),
138
+ )
139
+
140
+
141
+ def _pearson(xs: Sequence[float], ys: Sequence[float]) -> float | None:
142
+ if len(xs) != len(ys) or len(xs) < 2:
143
+ return None
144
+ mean_x = sum(xs) / len(xs)
145
+ mean_y = sum(ys) / len(ys)
146
+ num = 0.0
147
+ denom_x = 0.0
148
+ denom_y = 0.0
149
+ for x, y in zip(xs, ys, strict=False):
150
+ dx = x - mean_x
151
+ dy = y - mean_y
152
+ num += dx * dy
153
+ denom_x += dx * dx
154
+ denom_y += dy * dy
155
+ if denom_x <= 0 or denom_y <= 0:
156
+ return None
157
+ return num / (denom_x ** 0.5 * denom_y ** 0.5)
158
+
159
+
160
+ @dataclass
161
+ class AppChoice:
162
+ app_id: str
163
+ label: str
164
+ path: Path
165
+ source: str
166
+ description: str | None = None
167
+ aliases: tuple[str, ...] = ()
168
+ entry: TaskAppEntryType | None = None
169
+ entry_loader: Callable[[], TaskAppEntryType] | None = None
170
+ modal_script: Path | None = None
171
+ lineno: int | None = None
172
+
173
+ def ensure_entry(self) -> TaskAppEntryType:
174
+ if self.entry is not None:
175
+ return self.entry
176
+ if self.entry_loader is None:
177
+ raise click.ClickException(f"Unable to load task app '{self.app_id}' from {self.path}")
178
+ entry = self.entry_loader()
179
+ self.entry = entry
180
+ return entry
181
+
182
+
183
+ @dataclass
184
+ class JudgeSpec:
185
+ name: str
186
+ fn: Callable[..., Any]
187
+ kwargs: dict[str, Any]
188
+
189
+
190
+ def _parse_datetime_for_trace(value: Any) -> datetime | None:
191
+ if isinstance(value, datetime):
192
+ return value if value.tzinfo else value.replace(tzinfo=UTC)
193
+ if isinstance(value, str):
194
+ value = value.replace("Z", "+00:00")
195
+ try:
196
+ dt = datetime.fromisoformat(value)
197
+ except ValueError:
198
+ try:
199
+ dt = datetime.fromtimestamp(float(value), tz=UTC)
200
+ except Exception:
201
+ return None
202
+ return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
203
+ if isinstance(value, int | float):
204
+ return datetime.fromtimestamp(float(value), tz=UTC)
205
+ return None
206
+
207
+
208
+ def _time_record_from_dict(payload: dict[str, Any] | None) -> TimeRecord:
209
+ payload = payload or {}
210
+ event_time = payload.get("event_time")
211
+ if event_time is None:
212
+ event_time = float(time.time())
213
+ elif not isinstance(event_time, int | float):
214
+ try:
215
+ event_time = float(event_time)
216
+ except Exception:
217
+ event_time = float(time.time())
218
+ message_time = payload.get("message_time")
219
+ if message_time is not None:
220
+ try:
221
+ message_time = int(message_time)
222
+ except Exception:
223
+ message_time = None
224
+ return TimeRecord(event_time=event_time, message_time=message_time)
225
+
226
+
227
+ def _event_from_dict(payload: dict[str, Any]) -> BaseEvent:
228
+ system_instance_id = payload.get("system_instance_id", "")
229
+ time_record = _time_record_from_dict(payload.get("time_record"))
230
+ metadata = payload.get("metadata") or {}
231
+ event_metadata = payload.get("event_metadata")
232
+
233
+ if "actions" in payload:
234
+ return RuntimeEvent(
235
+ system_instance_id=system_instance_id,
236
+ time_record=time_record,
237
+ metadata=metadata,
238
+ event_metadata=event_metadata,
239
+ actions=payload.get("actions") or [],
240
+ )
241
+ if any(key in payload for key in ("reward", "terminated", "truncated")):
242
+ return EnvironmentEvent(
243
+ system_instance_id=system_instance_id,
244
+ time_record=time_record,
245
+ metadata=metadata,
246
+ event_metadata=event_metadata,
247
+ reward=float(payload.get("reward", 0.0) or 0.0),
248
+ terminated=bool(payload.get("terminated", False)),
249
+ truncated=bool(payload.get("truncated", False)),
250
+ system_state_before=payload.get("system_state_before"),
251
+ system_state_after=payload.get("system_state_after"),
252
+ )
253
+ # Check for LM CAIS event fields
254
+ if any(key in payload for key in ("model_name", "provider", "call_records")):
255
+ from synth_ai.core.tracing_v3.abstractions import LMCAISEvent
256
+ # Note: call_records are left as dicts - the storage layer will handle serialization
257
+ call_records = payload.get("call_records") or []
258
+ return LMCAISEvent(
259
+ system_instance_id=system_instance_id,
260
+ time_record=time_record,
261
+ metadata=metadata,
262
+ event_metadata=event_metadata,
263
+ model_name=payload.get("model_name", ""),
264
+ provider=payload.get("provider", ""),
265
+ input_tokens=payload.get("input_tokens"),
266
+ output_tokens=payload.get("output_tokens"),
267
+ total_tokens=payload.get("total_tokens"),
268
+ cost_usd=payload.get("cost_usd"),
269
+ latency_ms=payload.get("latency_ms"),
270
+ span_id=payload.get("span_id"),
271
+ trace_id=payload.get("trace_id"),
272
+ call_records=call_records,
273
+ )
274
+ return BaseEvent(
275
+ system_instance_id=system_instance_id,
276
+ time_record=time_record,
277
+ metadata=metadata,
278
+ event_metadata=event_metadata,
279
+ )
280
+
281
+
282
+ def _markov_message_from_dict(payload: dict[str, Any]) -> SessionEventMarkovBlanketMessage:
283
+ content_payload = payload.get("content") or {}
284
+ content = SessionMessageContent(
285
+ text=content_payload.get("text"),
286
+ json_payload=content_payload.get("json_payload"),
287
+ )
288
+ raw_type = (payload.get("message_type") or "").lower()
289
+ original_type = payload.get("message_type") or raw_type
290
+
291
+ if raw_type in ("observation", "policy_system_prompt"):
292
+ normalized_type = "system"
293
+ elif raw_type in ("action", "policy_tool_call"):
294
+ normalized_type = "assistant"
295
+ elif raw_type in {"user", "assistant", "system", "tool_use", "tool_result"}:
296
+ normalized_type = raw_type
297
+ else:
298
+ normalized_type = "system"
299
+
300
+ metadata = dict(payload.get("metadata") or {})
301
+ metadata["original_message_type"] = original_type
302
+
303
+ return SessionEventMarkovBlanketMessage(
304
+ content=content,
305
+ message_type=normalized_type,
306
+ time_record=_time_record_from_dict(payload.get("time_record")),
307
+ metadata=metadata,
308
+ )
309
+
310
+
311
+ def _step_from_dict(payload: dict[str, Any]) -> SessionTimeStep:
312
+ events = [
313
+ _event_from_dict(event)
314
+ for event in payload.get("events", [])
315
+ if isinstance(event, dict)
316
+ ]
317
+ messages = [
318
+ _markov_message_from_dict(msg)
319
+ for msg in payload.get("markov_blanket_messages", [])
320
+ if isinstance(msg, dict)
321
+ ]
322
+ timestamp = _parse_datetime_for_trace(payload.get("timestamp")) or datetime.now(UTC)
323
+ completed_at = _parse_datetime_for_trace(payload.get("completed_at"))
324
+ return SessionTimeStep(
325
+ step_id=payload.get("step_id", ""),
326
+ step_index=int(payload.get("step_index", 0) or 0),
327
+ timestamp=timestamp,
328
+ turn_number=payload.get("turn_number"),
329
+ events=events,
330
+ markov_blanket_messages=messages,
331
+ step_metadata=payload.get("step_metadata") or {},
332
+ completed_at=completed_at,
333
+ )
334
+
335
+
336
+ def _session_trace_from_dict(payload: dict[str, Any]) -> V3SessionTrace | None:
337
+ if not isinstance(payload, dict):
338
+ return None
339
+ steps = [
340
+ _step_from_dict(step)
341
+ for step in payload.get("session_time_steps", [])
342
+ if isinstance(step, dict)
343
+ ]
344
+ events = [
345
+ _event_from_dict(event)
346
+ for event in payload.get("event_history", [])
347
+ if isinstance(event, dict)
348
+ ]
349
+ markov_history = [
350
+ _markov_message_from_dict(msg)
351
+ for msg in payload.get("markov_blanket_message_history", [])
352
+ if isinstance(msg, dict)
353
+ ]
354
+ created_at = _parse_datetime_for_trace(payload.get("created_at")) or datetime.now(UTC)
355
+ metadata = payload.get("metadata") or {}
356
+ session_metadata = payload.get("session_metadata")
357
+ return V3SessionTrace(
358
+ session_id=payload.get("session_id", ""),
359
+ created_at=created_at,
360
+ session_time_steps=steps,
361
+ event_history=events,
362
+ markov_blanket_message_history=markov_history,
363
+ metadata=metadata,
364
+ session_metadata=session_metadata,
365
+ )
366
+
367
+
368
+ async def _store_trace(
369
+ tracer: SessionTracer | None,
370
+ trace_namespace: dict[str, Any] | None,
371
+ extra_metadata: dict[str, Any] | None = None,
372
+ ):
373
+ import logging
374
+ _logger = logging.getLogger(__name__)
375
+
376
+ _logger.info(f"[STORE_TRACE_DEBUG] Called with tracer={tracer is not None}, trace_namespace={trace_namespace is not None}")
377
+
378
+ if tracer is None or not isinstance(trace_namespace, dict):
379
+ _logger.warning(f"[STORE_TRACE_DEBUG] Early return: tracer={tracer is not None}, trace_namespace type={type(trace_namespace)}")
380
+ return
381
+
382
+ _logger.info(f"[STORE_TRACE_DEBUG] trace_namespace keys: {list(trace_namespace.keys())}")
383
+
384
+ # Handle both formats:
385
+ # - With session_trace key: {"session_trace": {...}}
386
+ # - Without session_trace key (trace itself is the session): {"session_id": ..., "markov_blanket_message_history": ...}
387
+ session_payload = trace_namespace.get("session_trace")
388
+ if not isinstance(session_payload, dict):
389
+ # If no session_trace key, assume "full" format where trace itself is the session_trace
390
+ if "session_id" in trace_namespace:
391
+ session_payload = trace_namespace
392
+ _logger.info("[STORE_TRACE_DEBUG] Using trace_namespace directly as session_payload (no session_trace key)")
393
+ else:
394
+ _logger.warning(f"[STORE_TRACE_DEBUG] No session_trace found or wrong type: {type(session_payload)}")
395
+ return
396
+
397
+ _logger.info(f"[STORE_TRACE_DEBUG] session_payload keys: {list(session_payload.keys())}")
398
+ msg_count = len(session_payload.get("markov_blanket_message_history", []))
399
+ _logger.info(f"[STORE_TRACE_DEBUG] Found {msg_count} messages in session_payload")
400
+
401
+ trace_obj = _session_trace_from_dict(session_payload)
402
+ if trace_obj is None:
403
+ _logger.warning("[STORE_TRACE_DEBUG] _session_trace_from_dict returned None")
404
+ return
405
+
406
+ _logger.info(f"[STORE_TRACE_DEBUG] Created SessionTrace object with {len(trace_obj.markov_blanket_message_history)} messages")
407
+
408
+ if tracer.db is None:
409
+ await tracer.initialize()
410
+ meta = dict(trace_obj.metadata or {})
411
+ if extra_metadata:
412
+ meta.update(extra_metadata)
413
+ trace_obj.metadata = meta
414
+
415
+ _logger.info(f"[STORE_TRACE_DEBUG] Calling insert_session_trace for session_id={trace_obj.session_id}")
416
+ await tracer.db.insert_session_trace(trace_obj) # type: ignore[attr-defined]
417
+ _logger.info("[STORE_TRACE_DEBUG] Successfully inserted trace")
418
+
419
+ def _temporary_sys_path(paths: Sequence[Path]):
420
+ """Context manager to prepend entries to sys.path temporarily."""
421
+
422
+ @contextlib.contextmanager
423
+ def _manager() -> Iterator[None]:
424
+ added: list[str] = []
425
+ for p in paths:
426
+ try:
427
+ resolved = str(p.resolve())
428
+ except Exception:
429
+ continue
430
+ if resolved in sys.path:
431
+ continue
432
+ sys.path.insert(0, resolved)
433
+ added.append(resolved)
434
+ try:
435
+ yield None
436
+ finally:
437
+ for entry in added:
438
+ with contextlib.suppress(ValueError):
439
+ sys.path.remove(entry)
440
+
441
+ return _manager()
442
+
443
+
444
+ def _possible_module_names(
445
+ path: Path, module_search_roots: Sequence[Path]
446
+ ) -> list[tuple[str, Path]]:
447
+ """Return potential module names based on candidate roots."""
448
+
449
+ candidates: list[tuple[str, Path]] = []
450
+ for root in module_search_roots:
451
+ try:
452
+ resolved_root = root.resolve()
453
+ except Exception:
454
+ continue
455
+ if not resolved_root.exists() or not path.is_relative_to(resolved_root):
456
+ continue
457
+ relative = path.relative_to(resolved_root)
458
+ stem = relative.with_suffix("")
459
+ parts = list(stem.parts)
460
+ if not parts:
461
+ continue
462
+ module_name = ".".join(parts)
463
+ if module_name:
464
+ candidates.append((module_name, resolved_root))
465
+ return candidates
466
+
467
+
468
+ def _ensure_parent_namespace(module_name: str, search_root: Path) -> None:
469
+ """Ensure namespace packages exist for dotted module names."""
470
+
471
+ parts = module_name.split(".")
472
+ for depth in range(1, len(parts)):
473
+ parent_name = ".".join(parts[:depth])
474
+ if parent_name in sys.modules:
475
+ continue
476
+ parent_module = types.ModuleType(parent_name)
477
+ candidate_dir = search_root.joinpath(*parts[:depth])
478
+ try:
479
+ resolved = candidate_dir.resolve()
480
+ except Exception:
481
+ resolved = search_root.resolve()
482
+ parent_module.__path__ = [str(resolved)] # type: ignore[attr-defined]
483
+ sys.modules[parent_name] = parent_module
484
+
485
+
486
+ def _should_ignore_path(path: Path) -> bool:
487
+ return any(part in DEFAULT_IGNORE_DIRS for part in path.parts)
488
+
489
+
490
+ def _candidate_search_roots() -> list[Path]:
491
+ """Only search for task apps in the current working directory and subdirectories."""
492
+ roots: list[Path] = []
493
+
494
+ demo_path = _load_demo_directory()
495
+ if demo_path is not None and demo_path.is_dir():
496
+ roots.append(demo_path)
497
+
498
+ # Allow explicit search paths via environment variable
499
+ env_paths = os.environ.get("SYNTH_TASK_APP_SEARCH_PATH")
500
+ if env_paths:
501
+ for chunk in env_paths.split(os.pathsep):
502
+ if chunk:
503
+ roots.append(Path(chunk).expanduser())
504
+
505
+ # Always include current working directory
506
+ cwd = Path.cwd().resolve()
507
+ roots.append(cwd)
508
+
509
+ for rel in DEFAULT_SEARCH_RELATIVE:
510
+ try:
511
+ candidate = (cwd / rel).resolve()
512
+ except Exception:
513
+ continue
514
+ roots.append(candidate)
515
+
516
+ # Remove duplicates while preserving order
517
+ seen: set[Path] = set()
518
+ ordered: list[Path] = []
519
+ for root in roots:
520
+ try:
521
+ resolved = root.resolve()
522
+ except Exception:
523
+ continue
524
+ if resolved in seen or not resolved.exists():
525
+ continue
526
+ seen.add(resolved)
527
+ ordered.append(resolved)
528
+ return ordered
529
+
530
+
531
+ class _TaskAppConfigVisitor(ast.NodeVisitor):
532
+ def __init__(self) -> None:
533
+ self.matches: list[tuple[str, int]] = []
534
+
535
+ def visit_Call(self, node: ast.Call) -> None: # noqa: D401
536
+ if _is_task_app_config_call(node):
537
+ app_id = _extract_app_id(node)
538
+ if app_id:
539
+ self.matches.append((app_id, getattr(node, "lineno", 0)))
540
+ elif _is_register_task_app_call(node):
541
+ app_id = _extract_register_app_id(node)
542
+ if app_id:
543
+ self.matches.append((app_id, getattr(node, "lineno", 0)))
544
+ self.generic_visit(node)
545
+
546
+
547
+ def _is_task_app_config_call(node: ast.Call) -> bool:
548
+ func = node.func
549
+ return (isinstance(func, ast.Name) and func.id == "TaskAppConfig") or (
550
+ isinstance(func, ast.Attribute) and func.attr == "TaskAppConfig"
551
+ )
552
+
553
+
554
+ def _extract_app_id(node: ast.Call) -> str | None:
555
+ for kw in node.keywords:
556
+ if (
557
+ kw.arg == "app_id"
558
+ and isinstance(kw.value, ast.Constant)
559
+ and isinstance(kw.value.value, str)
560
+ ):
561
+ return kw.value.value
562
+ if node.args:
563
+ first = node.args[0]
564
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
565
+ return first.value
566
+ return None
567
+
568
+
569
+ def _is_register_task_app_call(node: ast.Call) -> bool:
570
+ func = node.func
571
+ return (isinstance(func, ast.Name) and func.id in {"register_task_app", "register_local_api"}) or (
572
+ isinstance(func, ast.Attribute) and func.attr in {"register_task_app", "register_local_api"}
573
+ )
574
+
575
+
576
+ def _extract_register_app_id(node: ast.Call) -> str | None:
577
+ # Look for entry=TaskAppEntry(app_id="...") or entry=LocalAPIEntry(api_id="...")
578
+ for kw in node.keywords:
579
+ if kw.arg == "entry" and isinstance(kw.value, ast.Call):
580
+ entry_call = kw.value
581
+ if isinstance(entry_call.func, ast.Name) and entry_call.func.id in {"TaskAppEntry", "LocalAPIEntry"}:
582
+ for entry_kw in entry_call.keywords:
583
+ if (
584
+ entry_kw.arg in {"app_id", "api_id"}
585
+ and isinstance(entry_kw.value, ast.Constant)
586
+ and isinstance(entry_kw.value.value, str)
587
+ ):
588
+ return entry_kw.value.value
589
+ return None
590
+
591
+
592
+ class _ModalAppVisitor(ast.NodeVisitor):
593
+ def __init__(self) -> None:
594
+ self.app_aliases: set[str] = set()
595
+ self.modal_aliases: set[str] = set()
596
+ self.matches: list[tuple[str, int]] = []
597
+
598
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: D401
599
+ if node.module == "modal":
600
+ for alias in node.names:
601
+ if alias.name == "App":
602
+ self.app_aliases.add(alias.asname or alias.name)
603
+ self.generic_visit(node)
604
+
605
+ def visit_Import(self, node: ast.Import) -> None: # noqa: D401
606
+ for alias in node.names:
607
+ if alias.name == "modal":
608
+ self.modal_aliases.add(alias.asname or alias.name)
609
+ self.generic_visit(node)
610
+
611
+ def visit_Call(self, node: ast.Call) -> None: # noqa: D401
612
+ func = node.func
613
+ if isinstance(func, ast.Name) and func.id in self.app_aliases:
614
+ name = _extract_modal_app_name(node)
615
+ if name:
616
+ self.matches.append((name, getattr(node, "lineno", 0)))
617
+ elif isinstance(func, ast.Attribute):
618
+ if (
619
+ isinstance(func.value, ast.Name)
620
+ and func.value.id in self.modal_aliases
621
+ and func.attr == "App"
622
+ ):
623
+ name = _extract_modal_app_name(node)
624
+ if name:
625
+ self.matches.append((name, getattr(node, "lineno", 0)))
626
+ self.generic_visit(node)
627
+
628
+
629
+ def _extract_modal_app_name(node: ast.Call) -> str | None:
630
+ for kw in node.keywords:
631
+ if (
632
+ kw.arg in {"name", "app_name"}
633
+ and isinstance(kw.value, ast.Constant)
634
+ and isinstance(kw.value.value, str)
635
+ ):
636
+ return kw.value.value
637
+ if node.args:
638
+ first = node.args[0]
639
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
640
+ return first.value
641
+ return None
642
+
643
+
644
+ def _collect_task_app_choices() -> list[AppChoice]:
645
+ # Clear registry to avoid duplicate registration errors
646
+ registry.clear()
647
+
648
+ choices: list[AppChoice] = []
649
+ with contextlib.suppress(Exception):
650
+ _maybe_import("synth_ai.cli.demo_apps.demo_task_apps")
651
+ # Only use discovered task apps, not registered ones (since we moved them to examples)
652
+ choices.extend(_collect_scanned_task_configs())
653
+ choices.extend(_collect_modal_scripts())
654
+
655
+ unique: dict[tuple[str, Path], AppChoice] = {}
656
+ ordered: list[AppChoice] = []
657
+ for choice in choices:
658
+ key = (choice.app_id, choice.path.resolve())
659
+ if key in unique:
660
+ existing = unique[key]
661
+ if existing.source == "registered" and choice.source != "registered":
662
+ continue
663
+ if choice.source == "registered" and existing.source != "registered":
664
+ unique[key] = choice
665
+ idx = ordered.index(existing)
666
+ ordered[idx] = choice
667
+ continue
668
+ unique[key] = choice
669
+ ordered.append(choice)
670
+ ordered.sort(key=_app_choice_sort_key)
671
+ return ordered
672
+
673
+
674
+ def _collect_registered_choices() -> list[AppChoice]:
675
+ result: list[AppChoice] = []
676
+ for entry in registry.list(): # type: ignore[attr-defined]
677
+ module_name = entry.config_factory.__module__
678
+ module = sys.modules.get(module_name)
679
+ if module is None:
680
+ module = importlib.import_module(module_name)
681
+ module_file = getattr(module, "__file__", None)
682
+ path = Path(module_file).resolve() if module_file else REPO_ROOT
683
+ result.append(
684
+ AppChoice(
685
+ app_id=entry.app_id,
686
+ label=entry.app_id,
687
+ path=path,
688
+ source="registered",
689
+ description=entry.description,
690
+ aliases=tuple(entry.aliases),
691
+ entry=entry,
692
+ )
693
+ )
694
+ return result
695
+
696
+
697
+ def _collect_scanned_task_configs() -> list[AppChoice]:
698
+ results: list[AppChoice] = []
699
+ seen: set[tuple[str, Path]] = set()
700
+ for root in _candidate_search_roots():
701
+ try:
702
+ root_resolved = root.resolve()
703
+ except Exception:
704
+ continue
705
+ if not root.exists() or not root.is_dir():
706
+ continue
707
+ for path in root.rglob("*.py"):
708
+ if not path.is_file():
709
+ continue
710
+ if _should_ignore_path(path):
711
+ continue
712
+ try:
713
+ source = path.read_text(encoding="utf-8")
714
+ except Exception:
715
+ continue
716
+ try:
717
+ tree = ast.parse(source, filename=str(path))
718
+ except SyntaxError:
719
+ continue
720
+ visitor = _TaskAppConfigVisitor()
721
+ visitor.visit(tree)
722
+ for app_id, lineno in visitor.matches:
723
+ key = (app_id, path.resolve())
724
+ if key in seen:
725
+ continue
726
+ seen.add(key)
727
+ results.append(
728
+ AppChoice(
729
+ app_id=app_id,
730
+ label=app_id,
731
+ path=path.resolve(),
732
+ source="discovered",
733
+ description=f"TaskAppConfig in {path.name} (line {lineno})",
734
+ entry_loader=lambda p=path.resolve(),
735
+ a=app_id,
736
+ roots=(root_resolved,): _load_entry_from_path(
737
+ p, a, module_search_roots=roots
738
+ ),
739
+ lineno=lineno,
740
+ )
741
+ )
742
+ return results
743
+
744
+
745
+ def _collect_modal_scripts() -> list[AppChoice]:
746
+ results: list[AppChoice] = []
747
+ seen: set[tuple[str, Path]] = set()
748
+ for root in _candidate_search_roots():
749
+ if not root.exists() or not root.is_dir():
750
+ continue
751
+ for path in root.rglob("*.py"):
752
+ if not path.is_file():
753
+ continue
754
+ if _should_ignore_path(path):
755
+ continue
756
+ try:
757
+ source = path.read_text(encoding="utf-8")
758
+ except Exception:
759
+ continue
760
+ try:
761
+ tree = ast.parse(source, filename=str(path))
762
+ except SyntaxError:
763
+ continue
764
+ visitor = _ModalAppVisitor()
765
+ visitor.visit(tree)
766
+ for app_name, lineno in visitor.matches:
767
+ key = (app_name, path.resolve())
768
+ if key in seen:
769
+ continue
770
+ seen.add(key)
771
+ results.append(
772
+ AppChoice(
773
+ app_id=app_name,
774
+ label=app_name,
775
+ path=path.resolve(),
776
+ source="modal-script",
777
+ description=f"Modal App '{app_name}' in {path.name} (line {lineno})",
778
+ modal_script=path.resolve(),
779
+ lineno=lineno,
780
+ )
781
+ )
782
+ return results
783
+
784
+
785
+ def _app_choice_sort_key(choice: AppChoice) -> tuple[int, int, int, int, int, str, str]:
786
+ """Ranking heuristic so wrapper-style task apps surface first."""
787
+
788
+ # Prioritize apps in the current working directory (demo or otherwise)
789
+ cwd_rank = 1
790
+ try:
791
+ cwd = Path.cwd().resolve()
792
+ if choice.path.is_relative_to(cwd):
793
+ # Check if this is directly in CWD (not in subdirectories like examples/)
794
+ try:
795
+ rel_path = choice.path.relative_to(cwd)
796
+ # If it's in the immediate directory or one level deep, prioritize it
797
+ if len(rel_path.parts) <= 2:
798
+ cwd_rank = 0
799
+ except Exception:
800
+ pass
801
+ except Exception:
802
+ pass
803
+
804
+ # Further prioritize apps in the demo directory if one is set
805
+ demo_rank = 1
806
+ demo_dir = _load_demo_directory()
807
+ if demo_dir and choice.path.is_relative_to(demo_dir):
808
+ demo_rank = 0
809
+
810
+ modal_rank = 1 if choice.modal_script else 0
811
+
812
+ name = choice.path.name.lower()
813
+ file_rank = 3
814
+ if name.endswith("_task_app.py") or name.endswith("task_app.py"):
815
+ file_rank = 0
816
+ elif name.endswith("_app.py") or "task_app" in name:
817
+ file_rank = 1
818
+ elif name.endswith(".py"):
819
+ file_rank = 2
820
+
821
+ directory_rank = 0 if choice.path.parent.name.lower() in {"task_app", "task_apps"} else 1
822
+
823
+ return (
824
+ demo_rank,
825
+ cwd_rank,
826
+ modal_rank,
827
+ file_rank,
828
+ directory_rank,
829
+ choice.app_id,
830
+ str(choice.path),
831
+ )
832
+
833
+
834
+ def _choice_matches_identifier(choice: AppChoice, identifier: str) -> bool:
835
+ ident = identifier.strip()
836
+ if not ident:
837
+ return False
838
+ return ident == choice.app_id or ident == choice.label or ident in choice.aliases
839
+
840
+
841
+ def _choice_has_modal_support(choice: AppChoice) -> bool:
842
+ if choice.modal_script:
843
+ return True
844
+ try:
845
+ entry = choice.ensure_entry()
846
+ except click.ClickException:
847
+ # If we can't load the entry, try to detect Modal support via AST parsing
848
+ return _has_modal_support_in_file(choice.path)
849
+ return entry.modal is not None # type: ignore[attr-defined]
850
+
851
+
852
+ def _has_modal_support_in_file(path: Path) -> bool:
853
+ """Detect if a file has Modal deployment support by parsing the AST."""
854
+ try:
855
+ source = path.read_text(encoding="utf-8")
856
+ tree = ast.parse(source, filename=str(path))
857
+
858
+ # Look for ModalDeploymentConfig in register_task_app calls
859
+ for node in ast.walk(tree):
860
+ if isinstance(node, ast.Call) and _is_register_task_app_call(node):
861
+ # Check if the entry has modal=ModalDeploymentConfig(...)
862
+ for kw in node.keywords:
863
+ if kw.arg == "entry" and isinstance(kw.value, ast.Call):
864
+ entry_call = kw.value
865
+ if (
866
+ isinstance(entry_call.func, ast.Name)
867
+ and entry_call.func.id in {"TaskAppEntry", "LocalAPIEntry"}
868
+ ):
869
+ for entry_kw in entry_call.keywords:
870
+ if entry_kw.arg == "modal" and isinstance(entry_kw.value, ast.Call):
871
+ modal_call = entry_kw.value
872
+ if (
873
+ isinstance(modal_call.func, ast.Name)
874
+ and modal_call.func.id == "ModalDeploymentConfig"
875
+ ):
876
+ return True
877
+ except Exception:
878
+ pass
879
+ return False
880
+
881
+
882
+ def _extract_modal_config_from_file(path: Path) -> ModalDeploymentConfigType | None:
883
+ """Extract ModalDeploymentConfig from a file by parsing the AST."""
884
+ try:
885
+ source = path.read_text(encoding="utf-8")
886
+ tree = ast.parse(source, filename=str(path))
887
+
888
+ # Look for ModalDeploymentConfig in register_task_app calls
889
+ for node in ast.walk(tree):
890
+ if isinstance(node, ast.Call) and _is_register_task_app_call(node):
891
+ # Check if the entry has modal=ModalDeploymentConfig(...)
892
+ for kw in node.keywords:
893
+ if kw.arg == "entry" and isinstance(kw.value, ast.Call):
894
+ entry_call = kw.value
895
+ if (
896
+ isinstance(entry_call.func, ast.Name)
897
+ and entry_call.func.id in {"TaskAppEntry", "LocalAPIEntry"}
898
+ ):
899
+ for entry_kw in entry_call.keywords:
900
+ if entry_kw.arg == "modal" and isinstance(entry_kw.value, ast.Call):
901
+ modal_call = entry_kw.value
902
+ if (
903
+ isinstance(modal_call.func, ast.Name)
904
+ and modal_call.func.id == "ModalDeploymentConfig"
905
+ ):
906
+ # Extract the arguments to ModalDeploymentConfig
907
+ return _build_modal_config_from_ast(modal_call)
908
+ except Exception:
909
+ pass
910
+ return None
911
+
912
+
913
+ def _build_modal_config_from_ast(modal_call: ast.Call) -> ModalDeploymentConfigType | None:
914
+ """Build a ModalDeploymentConfig from an AST Call node."""
915
+ try:
916
+ # Extract keyword arguments
917
+ kwargs = {}
918
+ for kw in modal_call.keywords:
919
+ if kw.arg and isinstance(kw.value, ast.Constant):
920
+ kwargs[kw.arg] = kw.value.value
921
+ elif kw.arg == "pip_packages" and isinstance(kw.value, ast.List | ast.Tuple):
922
+ # Handle pip_packages list/tuple
923
+ packages: list[str] = []
924
+ value_node = kw.value
925
+ if isinstance(value_node, ast.List | ast.Tuple):
926
+ for elt in value_node.elts:
927
+ if isinstance(elt, ast.Constant):
928
+ packages.append(str(elt.value))
929
+ kwargs[kw.arg] = tuple(packages)
930
+ elif kw.arg == "extra_local_dirs" and isinstance(kw.value, ast.List | ast.Tuple):
931
+ # Handle extra_local_dirs list/tuple of tuples
932
+ dirs = []
933
+ value_node = kw.value
934
+ if isinstance(value_node, ast.List | ast.Tuple):
935
+ for elt in value_node.elts:
936
+ if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
937
+ src = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
938
+ dst = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
939
+ if src and dst:
940
+ dirs.append((src, dst))
941
+ kwargs[kw.arg] = tuple(dirs)
942
+ elif kw.arg == "secret_names" and isinstance(kw.value, ast.List | ast.Tuple):
943
+ # Handle secret_names list/tuple
944
+ secrets = []
945
+ value_node = kw.value
946
+ if isinstance(value_node, ast.List | ast.Tuple):
947
+ for elt in value_node.elts:
948
+ if isinstance(elt, ast.Constant):
949
+ secrets.append(elt.value)
950
+ kwargs[kw.arg] = tuple(secrets)
951
+ elif kw.arg == "volume_mounts" and isinstance(kw.value, ast.List | ast.Tuple):
952
+ # Handle volume_mounts list/tuple of tuples
953
+ mounts = []
954
+ value_node = kw.value
955
+ if isinstance(value_node, ast.List | ast.Tuple):
956
+ for elt in value_node.elts:
957
+ if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
958
+ name = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
959
+ mount = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
960
+ if name and mount:
961
+ mounts.append((name, mount))
962
+ kwargs[kw.arg] = tuple(mounts)
963
+
964
+ return ModalDeploymentConfig(**kwargs)
965
+ except Exception:
966
+ return None
967
+
968
+
969
+ def _choice_has_local_support(choice: AppChoice) -> bool:
970
+ if choice.modal_script:
971
+ return False
972
+ try:
973
+ choice.ensure_entry()
974
+ except click.ClickException:
975
+ return False
976
+ return True
977
+
978
+
979
+ def _format_choice(choice: AppChoice, index: int | None = None) -> str:
980
+ prefix = f"[{index}] " if index is not None else ""
981
+ # Get file modification timestamp
982
+ try:
983
+ from datetime import datetime
984
+
985
+ mtime = choice.path.stat().st_mtime
986
+ modified_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
987
+ details = f"Modified: {modified_str}"
988
+ except Exception:
989
+ # Fallback if timestamp unavailable
990
+ details = choice.description or "No timestamp available"
991
+ # Format: single line with timestamp
992
+ main_line = f"{prefix}{choice.app_id} ({choice.source}) – {details}"
993
+ return main_line
994
+
995
+
996
+ def _prompt_user_for_choice(choices: list[AppChoice]) -> AppChoice:
997
+ click.echo("Select a task app:")
998
+ for idx, choice in enumerate(choices, start=1):
999
+ click.echo(_format_choice(choice, idx))
1000
+ try:
1001
+ response = click.prompt("Enter choice", default="1", type=str).strip() or "1"
1002
+ except (Abort, EOFError, KeyboardInterrupt) as exc:
1003
+ raise click.ClickException("Task app selection cancelled by user") from exc
1004
+ if not response.isdigit():
1005
+ raise click.ClickException("Selection must be a number")
1006
+ index = int(response)
1007
+ if not 1 <= index <= len(choices):
1008
+ raise click.ClickException("Selection out of range")
1009
+ return choices[index - 1]
1010
+
1011
+
1012
+ def _select_app_choice(app_id: str | None, purpose: str) -> AppChoice:
1013
+ choices = _collect_task_app_choices()
1014
+ if purpose in {"serve", "eval"}:
1015
+ filtered = [c for c in choices if not c.modal_script]
1016
+ elif purpose in {"deploy", "modal-serve"}:
1017
+ filtered = []
1018
+ for choice in choices:
1019
+ if choice.modal_script or _choice_has_modal_support(choice):
1020
+ filtered.append(choice)
1021
+ else:
1022
+ filtered = choices
1023
+
1024
+ filtered.sort(key=_app_choice_sort_key)
1025
+
1026
+ if not filtered:
1027
+ raise click.ClickException("No task apps discovered for this command.")
1028
+
1029
+ if app_id:
1030
+ matches = [c for c in filtered if _choice_matches_identifier(c, app_id)]
1031
+ if not matches:
1032
+ available = ", ".join(sorted({c.app_id for c in filtered}))
1033
+ raise click.ClickException(f"Task app '{app_id}' not found. Available: {available}")
1034
+ exact_matches = [c for c in matches if c.app_id == app_id]
1035
+ if len(exact_matches) == 1:
1036
+ return exact_matches[0]
1037
+ if len(matches) == 1:
1038
+ return matches[0]
1039
+ # Prefer entries with modal support when required
1040
+ if purpose in {"deploy", "modal-serve"}:
1041
+ modal_matches = [c for c in matches if _choice_has_modal_support(c)]
1042
+ if len(modal_matches) == 1:
1043
+ return modal_matches[0]
1044
+ if modal_matches:
1045
+ matches = modal_matches
1046
+ return _prompt_user_for_choice(matches)
1047
+
1048
+ if len(filtered) == 1:
1049
+ choice = filtered[0]
1050
+ click.echo(_format_choice(choice))
1051
+ return choice
1052
+
1053
+ return _prompt_user_for_choice(filtered)
1054
+
1055
+
1056
+ def _import_task_app_module(
1057
+ resolved: Path,
1058
+ module_name: str,
1059
+ *,
1060
+ namespace_root: Path | None,
1061
+ sys_path_roots: Sequence[Path],
1062
+ ensure_namespace: bool = True,
1063
+ ) -> types.ModuleType:
1064
+ spec = importlib.util.spec_from_file_location(module_name, str(resolved))
1065
+ if spec is None or spec.loader is None:
1066
+ raise click.ClickException(f"Unable to load Python module from {resolved}")
1067
+
1068
+ module = importlib.util.module_from_spec(spec)
1069
+ sys.modules[module_name] = module
1070
+
1071
+ with _temporary_sys_path(sys_path_roots):
1072
+ if ensure_namespace and namespace_root is not None and "." in module_name:
1073
+ _ensure_parent_namespace(module_name, namespace_root)
1074
+
1075
+ # Clear registry before importing to avoid duplicate registration errors
1076
+ registry.clear()
1077
+
1078
+ try:
1079
+ spec.loader.exec_module(module)
1080
+ except Exception:
1081
+ # Remove partially-imported module to avoid reuse
1082
+ sys.modules.pop(module_name, None)
1083
+ raise
1084
+
1085
+ return module
1086
+
1087
+
1088
+ @contextlib.contextmanager
1089
+ def _safe_import_context() -> Iterator[None]:
1090
+ """Guard module imports against argparse/uvicorn side effects."""
1091
+
1092
+ original_argv = sys.argv[:]
1093
+ sys.argv = [original_argv[0]] if original_argv else ["python"]
1094
+
1095
+ parser_cls = argparse.ArgumentParser
1096
+ old_parse_args = parser_cls.parse_args
1097
+
1098
+ def _parse_noargs(self, args=None, namespace=None): # type: ignore[override]
1099
+ if args is None:
1100
+ args = []
1101
+ if namespace is None:
1102
+ namespace = argparse.Namespace()
1103
+ try:
1104
+ return old_parse_args(self, args, namespace)
1105
+ except SystemExit:
1106
+ return namespace
1107
+
1108
+ parser_cls.parse_args = _parse_noargs # type: ignore[assignment]
1109
+
1110
+ uvicorn_run = None
1111
+ run_task_app_orig = None
1112
+ try:
1113
+ import uvicorn # type: ignore
1114
+
1115
+ uvicorn_run = uvicorn.run
1116
+ uvicorn.run = lambda *args, **kwargs: None # type: ignore[assignment]
1117
+ except Exception:
1118
+ uvicorn_run = None
1119
+
1120
+ try:
1121
+ _task_server_patch = cast( # type: ignore[redundant-cast]
1122
+ Any, importlib.import_module("synth_ai.sdk.task.server")
1123
+ )
1124
+ run_task_app_orig = cast(Callable[..., Any], _task_server_patch.run_task_app)
1125
+ _task_server_patch.run_task_app = ( # type: ignore[assignment]
1126
+ lambda *args, **kwargs: None
1127
+ )
1128
+ except Exception:
1129
+ run_task_app_orig = None
1130
+
1131
+ try:
1132
+ yield
1133
+ finally:
1134
+ sys.argv = original_argv
1135
+ parser_cls.parse_args = old_parse_args # type: ignore[assignment]
1136
+ if uvicorn_run is not None:
1137
+ try:
1138
+ import uvicorn # type: ignore
1139
+
1140
+ uvicorn.run = uvicorn_run # type: ignore[assignment]
1141
+ except Exception:
1142
+ pass
1143
+ if run_task_app_orig is not None:
1144
+ try:
1145
+ _task_server_patch = cast(
1146
+ Any, importlib.import_module("synth_ai.sdk.task.server")
1147
+ )
1148
+ _task_server_patch.run_task_app = run_task_app_orig # type: ignore[assignment]
1149
+ except Exception:
1150
+ pass
1151
+
1152
+
1153
+ def _load_entry_from_path(
1154
+ path: Path, app_id: str, module_search_roots: Sequence[Path] | None = None
1155
+ ) -> TaskAppEntryType:
1156
+ resolved = path.resolve()
1157
+ search_roots: list[Path] = []
1158
+ seen_roots: set[Path] = set()
1159
+
1160
+ def _append_root(candidate: Path) -> None:
1161
+ try:
1162
+ resolved_root = candidate.resolve()
1163
+ except Exception:
1164
+ return
1165
+ if resolved_root in seen_roots:
1166
+ return
1167
+ seen_roots.add(resolved_root)
1168
+ search_roots.append(resolved_root)
1169
+
1170
+ for root in module_search_roots or []:
1171
+ _append_root(root)
1172
+ _append_root(resolved.parent)
1173
+ _append_root(REPO_ROOT)
1174
+
1175
+ last_error: Exception | None = None
1176
+ module: types.ModuleType | None = None
1177
+
1178
+ for module_name, namespace_root in _possible_module_names(resolved, search_roots):
1179
+ try:
1180
+ with _safe_import_context():
1181
+ module = _import_task_app_module(
1182
+ resolved,
1183
+ module_name,
1184
+ namespace_root=namespace_root,
1185
+ sys_path_roots=search_roots,
1186
+ ensure_namespace=True,
1187
+ )
1188
+ break
1189
+ except Exception as exc: # pragma: no cover - best-effort fallbacks
1190
+ last_error = exc
1191
+ continue
1192
+
1193
+ if module is None:
1194
+ hashed_name = f"_synth_task_app_{hashlib.md5(str(resolved).encode(), usedforsecurity=False).hexdigest()}"
1195
+ try:
1196
+ with _safe_import_context():
1197
+ module = _import_task_app_module(
1198
+ resolved,
1199
+ hashed_name,
1200
+ namespace_root=None,
1201
+ sys_path_roots=search_roots,
1202
+ ensure_namespace=False,
1203
+ )
1204
+ except Exception as exc: # pragma: no cover - propagate meaningful error
1205
+ detail = last_error or exc
1206
+ raise click.ClickException(f"Failed to import {resolved}: {detail}") from detail
1207
+
1208
+ config_obj: TaskAppConfigType | None = None
1209
+ factory_callable: Callable[[], TaskAppConfigType] | None = None
1210
+
1211
+ for attr_name in dir(module):
1212
+ try:
1213
+ attr = getattr(module, attr_name)
1214
+ except Exception:
1215
+ continue
1216
+ if isinstance(attr, TaskAppConfig) and attr.app_id == app_id:
1217
+ config_obj = attr
1218
+
1219
+ def _return_config(cfg: TaskAppConfigType = attr) -> TaskAppConfigType:
1220
+ return cfg
1221
+
1222
+ factory_callable = _return_config
1223
+ break
1224
+
1225
+ if factory_callable is None:
1226
+ for attr_name in dir(module):
1227
+ if attr_name.startswith("_"):
1228
+ continue
1229
+ try:
1230
+ attr = getattr(module, attr_name)
1231
+ except Exception:
1232
+ continue
1233
+ if not callable(attr):
1234
+ continue
1235
+ try:
1236
+ sig = inspect.signature(attr)
1237
+ except (TypeError, ValueError):
1238
+ continue
1239
+ has_required = False
1240
+ for param in sig.parameters.values():
1241
+ if (
1242
+ param.kind
1243
+ in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
1244
+ and param.default is inspect._empty
1245
+ ):
1246
+ has_required = True
1247
+ break
1248
+ if has_required:
1249
+ continue
1250
+ try:
1251
+ with _safe_import_context():
1252
+ result = attr()
1253
+ except SystemExit:
1254
+ continue
1255
+ except Exception:
1256
+ continue
1257
+ if isinstance(result, TaskAppConfig) and result.app_id == app_id:
1258
+ # Bind attr to a local and close over it without exposing parameters
1259
+ bound_func: Callable[[], TaskAppConfig] = cast(Callable[[], TaskAppConfig], attr) # type: ignore[assignment]
1260
+
1261
+ def _factory_noargs(
1262
+ func: Callable[[], TaskAppConfigType] = bound_func,
1263
+ ) -> TaskAppConfigType:
1264
+ return func()
1265
+
1266
+ factory_callable = _factory_noargs
1267
+ config_obj = result
1268
+ break
1269
+
1270
+ # If no TaskAppConfig found directly, check if it was registered via register_task_app
1271
+ if factory_callable is None or config_obj is None:
1272
+ try:
1273
+ # Check if the app was registered in the registry
1274
+ entry = registry.get(app_id)
1275
+ return entry
1276
+ except KeyError as exc:
1277
+ raise click.ClickException(
1278
+ f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
1279
+ ) from exc
1280
+
1281
+ modal_cfg: ModalDeploymentConfigType | None = None
1282
+ for attr_name in dir(module):
1283
+ try:
1284
+ attr = getattr(module, attr_name)
1285
+ except Exception:
1286
+ continue
1287
+ if isinstance(attr, ModalDeploymentConfig):
1288
+ modal_cfg = attr
1289
+ break
1290
+
1291
+ # If no ModalDeploymentConfig found, try to detect it via AST parsing
1292
+ if modal_cfg is None:
1293
+ modal_cfg = _extract_modal_config_from_file(resolved)
1294
+
1295
+ description = inspect.getdoc(module) or f"Discovered task app in {resolved.name}"
1296
+ env_files: Iterable[str] = getattr(module, "ENV_FILES", ()) # type: ignore[arg-type]
1297
+
1298
+ entry = TaskAppEntry(
1299
+ app_id=app_id,
1300
+ description=description,
1301
+ config_factory=factory_callable,
1302
+ aliases=(),
1303
+ env_files=tuple(str(Path(p)) for p in env_files if p),
1304
+ modal=modal_cfg,
1305
+ )
1306
+ return entry
1307
+
1308
+
1309
+ def _resolve_env_paths_for_script(script_path: Path, explicit: Sequence[str]) -> list[Path]:
1310
+ if explicit:
1311
+ resolved: list[Path] = []
1312
+ for candidate in explicit:
1313
+ p = Path(candidate).expanduser()
1314
+ if not p.exists():
1315
+ raise click.ClickException(f"Env file not found: {p}")
1316
+ resolved.append(p)
1317
+ return resolved
1318
+
1319
+ # Always prompt for env file selection instead of auto-loading defaults
1320
+ script_dir = script_path.parent.resolve()
1321
+ cwd = Path.cwd()
1322
+
1323
+ # Look for env files in current working directory first, then repo root
1324
+ env_candidates = []
1325
+
1326
+ # Add CWD env files first (prioritized)
1327
+ cwd_env_files = sorted(cwd.glob("**/*.env"))
1328
+ env_candidates.extend(cwd_env_files)
1329
+
1330
+ # Add repo root env files
1331
+ repo_env_files = sorted(REPO_ROOT.glob("**/*.env"))
1332
+ # Avoid duplicates
1333
+ for repo_file in repo_env_files:
1334
+ if repo_file not in env_candidates:
1335
+ env_candidates.append(repo_file)
1336
+
1337
+ if not env_candidates:
1338
+ created = _interactive_create_env(script_dir)
1339
+ if created is None:
1340
+ raise click.ClickException("Env file required (--env-file) for this task app")
1341
+ return [created]
1342
+
1343
+ click.echo("Select env file to load:")
1344
+ for idx, path in enumerate(env_candidates, start=1):
1345
+ click.echo(f" {idx}) {path.resolve()}")
1346
+ choice = click.prompt("Enter choice", type=click.IntRange(1, len(env_candidates)), default=1)
1347
+ return [env_candidates[choice - 1]]
1348
+
1349
+
1350
+ def _path_is_within(child: Path, parent: Path) -> bool:
1351
+ try:
1352
+ child.resolve().relative_to(parent.resolve())
1353
+ return True
1354
+ except Exception:
1355
+ return False
1356
+
1357
+
1358
+ @functools.lru_cache(maxsize=16)
1359
+ def _is_modal_shim(path_str: str) -> bool:
1360
+ """Return True if the candidate CLI path refers to the synth-ai shim."""
1361
+
1362
+ path = Path(path_str)
1363
+ try:
1364
+ resolved = path.resolve(strict=True)
1365
+ except Exception:
1366
+ resolved = path
1367
+
1368
+ if not resolved.exists() or resolved.is_dir():
1369
+ return False
1370
+
1371
+ snippet = ""
1372
+ try:
1373
+ snippet = resolved.read_bytes()[:4096].decode("utf-8", errors="ignore")
1374
+ except Exception:
1375
+ snippet = ""
1376
+
1377
+ shim_markers = (
1378
+ "synth_ai.cli._modal_wrapper",
1379
+ "from modal.__main__ import main",
1380
+ "import modal.__main__",
1381
+ "run_module('modal.__main__'",
1382
+ )
1383
+ if snippet and any(marker in snippet for marker in shim_markers):
1384
+ return True
1385
+
1386
+ try:
1387
+ size = resolved.stat().st_size
1388
+ except Exception:
1389
+ size = None
1390
+
1391
+ if (
1392
+ size is not None
1393
+ and size < 2048
1394
+ and "python" in (snippet.splitlines() or [""])[0]
1395
+ and (
1396
+ "modal.__main__" in snippet
1397
+ or "modal.__main__" in snippet.replace(" ", "")
1398
+ )
1399
+ ):
1400
+ return True
1401
+
1402
+ virtual_env = os.environ.get("VIRTUAL_ENV")
1403
+ if virtual_env and _path_is_within(resolved, Path(virtual_env)):
1404
+ return True
1405
+
1406
+ if _path_is_within(resolved, REPO_ROOT):
1407
+ return True
1408
+
1409
+ uv_tools_dir = Path.home() / ".local" / "share" / "uv" / "tools"
1410
+ return uv_tools_dir.exists() and _path_is_within(resolved, uv_tools_dir)
1411
+
1412
+
1413
+ def _find_modal_executable(modal_cli: str) -> tuple[str | None, str | None]:
1414
+ """Return the first non-shim executable and the first shim discovered on PATH."""
1415
+
1416
+ if not modal_cli:
1417
+ modal_cli = "modal"
1418
+
1419
+ candidate_path = Path(modal_cli).expanduser()
1420
+ if candidate_path.is_absolute() or len(candidate_path.parts) > 1:
1421
+ resolved_candidate = candidate_path
1422
+ if not resolved_candidate.is_absolute():
1423
+ resolved_candidate = (Path.cwd() / resolved_candidate).resolve()
1424
+ else:
1425
+ resolved_candidate = resolved_candidate.resolve()
1426
+ if not resolved_candidate.exists():
1427
+ raise click.ClickException(f"--modal-cli path does not exist: {resolved_candidate}")
1428
+ if not os.access(resolved_candidate, os.X_OK):
1429
+ raise click.ClickException(f"--modal-cli is not executable: {resolved_candidate}")
1430
+ return str(resolved_candidate), None
1431
+
1432
+ path_env = os.environ.get("PATH", "")
1433
+ if not path_env:
1434
+ return None, None
1435
+
1436
+ seen_dirs: set[str] = set()
1437
+ seen_candidates: set[str] = set()
1438
+ shim_path: str | None = None
1439
+
1440
+ for raw_entry in path_env.split(os.pathsep):
1441
+ if not raw_entry:
1442
+ continue
1443
+ try:
1444
+ resolved_entry = str(Path(raw_entry).resolve())
1445
+ except Exception:
1446
+ resolved_entry = os.path.normpath(raw_entry)
1447
+ if resolved_entry in seen_dirs:
1448
+ continue
1449
+ seen_dirs.add(resolved_entry)
1450
+
1451
+ candidate = shutil.which(modal_cli, path=raw_entry)
1452
+ if candidate is None:
1453
+ continue
1454
+ if candidate in seen_candidates:
1455
+ continue
1456
+ seen_candidates.add(candidate)
1457
+
1458
+ if _is_modal_shim(candidate):
1459
+ if shim_path is None:
1460
+ shim_path = candidate
1461
+ continue
1462
+ return candidate, shim_path
1463
+
1464
+ return None, shim_path
1465
+
1466
+
1467
+ def _modal_command_prefix(modal_cli: str) -> list[str]:
1468
+ """Resolve a command prefix for invoking the Modal CLI within the active environment."""
1469
+
1470
+ force_wrapper_env = os.environ.get("SYNTH_FORCE_MODAL_WRAPPER", "").strip().lower()
1471
+ if force_wrapper_env in {"1", "true", "yes"}:
1472
+ click.secho(
1473
+ "[modal-prefix] SYNTH_FORCE_MODAL_WRAPPER=1 -> using in-process wrapper",
1474
+ fg="yellow",
1475
+ )
1476
+ return [sys.executable, "-m", "synth_ai.cli._modal_wrapper"]
1477
+
1478
+ lookup = modal_cli or "modal"
1479
+ spec = importlib.util.find_spec("modal") if lookup == "modal" else None
1480
+
1481
+ preferred, shim_candidate = _find_modal_executable(lookup)
1482
+ if preferred is not None:
1483
+ detail = f"[modal-prefix] modal_cli={lookup} selected={preferred}"
1484
+ if lookup == "modal":
1485
+ detail += f" spec={'yes' if spec else 'no'}"
1486
+ click.secho(detail, fg="cyan")
1487
+ return [preferred]
1488
+
1489
+ if lookup != "modal":
1490
+ raise click.ClickException(f"Modal CLI not found (looked for '{lookup}')")
1491
+
1492
+ if spec is not None:
1493
+ warning = "[modal-prefix] Using synth-ai modal shim; pass --modal-cli /path/to/modal to override."
1494
+ if shim_candidate is not None:
1495
+ warning = (
1496
+ f"[modal-prefix] Using synth-ai modal shim at {shim_candidate}; "
1497
+ "pass --modal-cli /path/to/modal to override."
1498
+ )
1499
+ click.secho(warning, fg="yellow")
1500
+ click.secho(
1501
+ "[modal-prefix] modal_cli=modal selected=module-wrapper spec=yes",
1502
+ fg="yellow",
1503
+ )
1504
+ return [sys.executable, "-m", "synth_ai.cli._modal_wrapper"]
1505
+
1506
+ if shim_candidate is not None:
1507
+ raise click.ClickException(
1508
+ "Modal CLI resolution found the synth-ai shim but the 'modal' package "
1509
+ "is not importable in this environment. Install the official Modal CLI "
1510
+ "or pass --modal-cli with its path."
1511
+ )
1512
+
1513
+ raise click.ClickException(
1514
+ "Modal CLI not found. Install the 'modal' package in this environment or pass "
1515
+ "--modal-cli with an explicit path."
1516
+ )
1517
+
1518
+
1519
+ def _build_modal_app_wrapper(original_script: Path) -> tuple[Path, Path]:
1520
+ source_dir = original_script.parent.resolve()
1521
+ repo_root = REPO_ROOT
1522
+ temp_root = Path(tempfile.mkdtemp(prefix="synth_modal_app_"))
1523
+
1524
+ wrapper_source = textwrap.dedent(
1525
+ f"""
1526
+ from importlib import util as _util
1527
+ from pathlib import Path as _Path
1528
+ import sys as _sys
1529
+
1530
+ _source_dir = _Path({str(source_dir)!r}).resolve()
1531
+ _module_path = _source_dir / {original_script.name!r}
1532
+ _package_name = _source_dir.name
1533
+ _repo_root = _Path({str(repo_root)!r}).resolve()
1534
+ _synth_dir = _repo_root / "synth_ai"
1535
+
1536
+ for _path in (str(_source_dir), str(_source_dir.parent), str(_repo_root)):
1537
+ if _path not in _sys.path:
1538
+ _sys.path.insert(0, _path)
1539
+
1540
+ _spec = _util.spec_from_file_location("_synth_modal_target", str(_module_path))
1541
+ if _spec is None or _spec.loader is None:
1542
+ raise SystemExit("Unable to load modal task app from {original_script}")
1543
+ _module = _util.module_from_spec(_spec)
1544
+ _sys.modules.setdefault("_synth_modal_target", _module)
1545
+ _spec.loader.exec_module(_module)
1546
+
1547
+ try:
1548
+ from modal import App as _ModalApp
1549
+ from modal import Image as _ModalImage
1550
+ except Exception:
1551
+ _ModalApp = None # type: ignore[assignment]
1552
+ _ModalImage = None # type: ignore[assignment]
1553
+
1554
+ def _apply_local_mounts(image):
1555
+ if _ModalImage is None or not isinstance(image, _ModalImage):
1556
+ return image
1557
+ mounts = [
1558
+ (str(_source_dir), f"/root/{{_package_name}}"),
1559
+ (str(_synth_dir), "/root/synth_ai"),
1560
+ ]
1561
+ for local_path, remote_path in mounts:
1562
+ try:
1563
+ image = image.add_local_dir(local_path, remote_path=remote_path)
1564
+ except Exception:
1565
+ pass
1566
+ return image
1567
+
1568
+ if hasattr(_module, "image"):
1569
+ _module.image = _apply_local_mounts(getattr(_module, "image"))
1570
+
1571
+ _candidate = getattr(_module, "app", None)
1572
+ if _ModalApp is None or not isinstance(_candidate, _ModalApp):
1573
+ candidate_modal_app = getattr(_module, "modal_app", None)
1574
+ if _ModalApp is not None and isinstance(candidate_modal_app, _ModalApp):
1575
+ _candidate = candidate_modal_app
1576
+ setattr(_module, "app", _candidate)
1577
+
1578
+ if _ModalApp is not None and not isinstance(_candidate, _ModalApp):
1579
+ raise SystemExit(
1580
+ "Modal task app must expose an 'app = modal.App(...)' (or modal_app) attribute."
1581
+ )
1582
+
1583
+ for remote_path in ("/root/synth_ai", f"/root/{{_package_name}}"):
1584
+ if remote_path not in _sys.path:
1585
+ _sys.path.insert(0, remote_path)
1586
+
1587
+ globals().update({{k: v for k, v in vars(_module).items() if not k.startswith("__")}})
1588
+ app = getattr(_module, "app")
1589
+ """
1590
+ ).strip()
1591
+
1592
+ wrapper_path = temp_root / "__modal_wrapper__.py"
1593
+ wrapper_path.write_text(wrapper_source + "\n", encoding="utf-8")
1594
+ return wrapper_path, temp_root
1595
+
1596
+
1597
+
1598
+ def _run_modal_script(
1599
+ script_path: Path,
1600
+ modal_cli: str,
1601
+ command: str,
1602
+ env_paths: Sequence[Path],
1603
+ *,
1604
+ modal_name: str | None = None,
1605
+ dry_run: bool = False,
1606
+ ) -> None:
1607
+ env_paths_list = [Path(p).resolve() for p in env_paths]
1608
+ path_strings = [str(p) for p in env_paths_list]
1609
+ _load_env_files_into_process(path_strings)
1610
+ _ensure_env_values(env_paths_list, script_path.parent)
1611
+ _load_env_values(env_paths_list)
1612
+ # Ensure ENVIRONMENT_API_KEY is uploaded to backend for this org (matches registry path behavior)
1613
+ try:
1614
+ _preflight_env_key(env_paths_list, crash_on_failure=True)
1615
+ except Exception as _pf_err:
1616
+ raise click.ClickException(str(_pf_err)) from _pf_err
1617
+
1618
+ proc_env = os.environ.copy()
1619
+ pythonpath_entries: list[str] = []
1620
+ script_dir = script_path.parent.resolve()
1621
+ pythonpath_entries.append(str(script_dir))
1622
+ if (script_dir / "__init__.py").exists():
1623
+ # Script lives inside a package; ensure the parent package directory is importable.
1624
+ pythonpath_entries.append(str(script_dir.parent.resolve()))
1625
+ pythonpath_entries.append(str(REPO_ROOT))
1626
+ existing_pp = proc_env.get("PYTHONPATH")
1627
+ if existing_pp:
1628
+ pythonpath_entries.append(existing_pp)
1629
+ unique_paths = list(dict.fromkeys(pythonpath_entries))
1630
+ proc_env["PYTHONPATH"] = os.pathsep.join(unique_paths)
1631
+
1632
+ wrapper_info: tuple[Path, Path] | None = None
1633
+ target_script = script_path
1634
+ if command in {"serve", "deploy"}:
1635
+ wrapper_path, temp_root = _build_modal_app_wrapper(script_path)
1636
+ wrapper_info = (wrapper_path, temp_root)
1637
+ target_script = wrapper_path
1638
+
1639
+ # Ensure the wrapper has access to the Synth AI source for intra-repo imports
1640
+ if "PYTHONPATH" in proc_env:
1641
+ proc_env["PYTHONPATH"] = os.pathsep.join(
1642
+ [str(REPO_ROOT)] + proc_env["PYTHONPATH"].split(os.pathsep)
1643
+ )
1644
+ else:
1645
+ proc_env["PYTHONPATH"] = str(REPO_ROOT)
1646
+
1647
+ cmd = [*_modal_command_prefix(modal_cli), command, str(target_script)]
1648
+ if modal_name and command == "deploy":
1649
+ cmd.extend(["--name", modal_name])
1650
+ if dry_run:
1651
+ click.echo(
1652
+ "Dry run: " + " ".join(shlex.quote(component) for component in cmd),
1653
+ err=False,
1654
+ )
1655
+ return
1656
+ click.secho(
1657
+ "[modal-exec] " + " ".join(shlex.quote(component) for component in cmd),
1658
+ fg="cyan",
1659
+ )
1660
+ try:
1661
+ # Stream output live for better diagnostics
1662
+ proc = subprocess.Popen(
1663
+ cmd,
1664
+ stdout=subprocess.PIPE,
1665
+ stderr=subprocess.STDOUT,
1666
+ text=True,
1667
+ bufsize=1,
1668
+ env=proc_env,
1669
+ )
1670
+ task_app_url = None
1671
+ assert proc.stdout is not None
1672
+ for line in proc.stdout:
1673
+ click.echo(line, nl=False)
1674
+ if task_app_url is None and ("modal.run" in line and "=>" in line):
1675
+ parts = line.split("=>")
1676
+ if len(parts) >= 2:
1677
+ task_app_url = parts[-1].strip()
1678
+ if task_app_url and env_paths_list:
1679
+ env_file = env_paths_list[0]
1680
+ _save_to_env_file(env_file, "TASK_APP_BASE_URL", task_app_url)
1681
+ click.echo(f"\n✓ Task app URL: {task_app_url}\n")
1682
+ rc = proc.wait()
1683
+ if rc != 0:
1684
+ raise subprocess.CalledProcessError(rc, cmd)
1685
+ except subprocess.CalledProcessError as exc:
1686
+ raise click.ClickException(
1687
+ f"modal {command} failed with exit code {exc.returncode}"
1688
+ ) from exc
1689
+ finally:
1690
+ if wrapper_info is not None:
1691
+ wrapper_path, temp_root = wrapper_info
1692
+ with contextlib.suppress(Exception):
1693
+ wrapper_path.unlink(missing_ok=True)
1694
+ shutil.rmtree(temp_root, ignore_errors=True)
1695
+
1696
+
1697
+ def _preflight_env_key(env_paths: Sequence[Path] | None = None, *, crash_on_failure: bool = False) -> None:
1698
+ try:
1699
+ raw_backend = (
1700
+ os.environ.get("BACKEND_BASE_URL")
1701
+ or os.environ.get("SYNTH_BASE_URL")
1702
+ or f"{PROD_BASE_URL_DEFAULT}/api"
1703
+ )
1704
+ backend_base = raw_backend.rstrip("/")
1705
+ if not backend_base.endswith("/api"):
1706
+ backend_base = backend_base + "/api"
1707
+ synth_key = os.environ.get("SYNTH_API_KEY") or ""
1708
+ env_api_key = (
1709
+ os.environ.get("ENVIRONMENT_API_KEY") or os.environ.get("DEV_ENVIRONMENT_API_KEY") or ""
1710
+ ).strip()
1711
+
1712
+ def _preview(value: str) -> str:
1713
+ if len(value) <= 10:
1714
+ return value
1715
+ return f"{value[:6]}...{value[-4:]}"
1716
+
1717
+ minted = False
1718
+ if not env_api_key:
1719
+ secrets_module = _maybe_import("synth_ai.sdk.learning.rl.secrets")
1720
+ try:
1721
+ if secrets_module is None:
1722
+ raise RuntimeError("secrets module unavailable")
1723
+ mint_env_key = secrets_module.mint_environment_api_key
1724
+ env_api_key = mint_env_key()
1725
+ os.environ["ENVIRONMENT_API_KEY"] = env_api_key
1726
+ os.environ.setdefault("DEV_ENVIRONMENT_API_KEY", env_api_key)
1727
+ minted = True
1728
+ click.echo(
1729
+ f"[preflight] minted ENVIRONMENT_API_KEY ({_preview(env_api_key)})"
1730
+ )
1731
+ except Exception as mint_err:
1732
+ if crash_on_failure:
1733
+ raise click.ClickException(
1734
+ f"[CRITICAL] Failed to mint ENVIRONMENT_API_KEY: {mint_err}"
1735
+ ) from mint_err
1736
+ click.echo(
1737
+ f"[WARN] Failed to mint ENVIRONMENT_API_KEY automatically ({mint_err}); proceeding without upload"
1738
+ )
1739
+
1740
+ if env_api_key and not os.environ.get("ENVIRONMENT_API_KEY"):
1741
+ os.environ["ENVIRONMENT_API_KEY"] = env_api_key
1742
+ if env_api_key and not os.environ.get("DEV_ENVIRONMENT_API_KEY"):
1743
+ os.environ["DEV_ENVIRONMENT_API_KEY"] = env_api_key
1744
+
1745
+ if minted:
1746
+ _persist_env_api_key(env_api_key, env_paths)
1747
+
1748
+ if synth_key and env_api_key:
1749
+ import base64
1750
+
1751
+ import httpx
1752
+
1753
+ click.echo(f"[preflight] backend={backend_base}")
1754
+ with httpx.Client(timeout=15.0, headers={"Authorization": f"Bearer {synth_key}"}) as c:
1755
+ click.echo("[preflight] fetching public key…")
1756
+ rpk = c.get(f"{backend_base.rstrip('/')}/v1/crypto/public-key")
1757
+ pk = (rpk.json() or {}).get("public_key") if rpk.status_code == 200 else None
1758
+ if pk:
1759
+ try:
1760
+ from nacl.public import PublicKey, SealedBox
1761
+
1762
+ # Decode public key and build sealed box
1763
+ pk_bytes = base64.b64decode(pk, validate=True)
1764
+ pub = PublicKey(pk_bytes)
1765
+ sb = SealedBox(pub)
1766
+
1767
+ # Encrypt plaintext key
1768
+ ct_b64 = base64.b64encode(sb.encrypt(env_api_key.encode("utf-8"))).decode()
1769
+ payload = {"name": "ENVIRONMENT_API_KEY", "ciphertext_b64": ct_b64}
1770
+
1771
+ # Emit diagnostic logging (safe previews + hashes only)
1772
+ try:
1773
+ import hashlib as _hash
1774
+
1775
+ # Backend URL context
1776
+ click.echo(f"[preflight] posting to {backend_base.rstrip('/')}/v1/env-keys")
1777
+
1778
+ # Public key diagnostics
1779
+ pk_sha256 = _hash.sha256(pk_bytes).hexdigest()
1780
+ click.echo(
1781
+ f"[preflight] public_key: b64_len={len(pk)} sha256={pk_sha256} head={pk[:16]} tail={pk[-16:]}"
1782
+ )
1783
+
1784
+ # Plaintext diagnostics (never print full secret)
1785
+ _plain = env_api_key
1786
+ _plen = len(_plain)
1787
+ _ppref = (_plain[:6] + "…") if _plen > 10 else _plain
1788
+ _psuf = ("…" + _plain[-4:]) if _plen > 10 else ""
1789
+ _has_ws = any(ch.isspace() for ch in _plain)
1790
+ click.echo(
1791
+ f"[preflight] plaintext: len={_plen} preview={_ppref}{_psuf} has_ws={bool(_has_ws)}"
1792
+ )
1793
+
1794
+ # Ciphertext diagnostics
1795
+ try:
1796
+ _ct_bytes = base64.b64decode(ct_b64, validate=True)
1797
+ _ct_sha256 = _hash.sha256(_ct_bytes).hexdigest()
1798
+ click.echo(
1799
+ f"[preflight] ciphertext: b64_len={len(ct_b64)} sha256={_ct_sha256} head={ct_b64[:16]} tail={ct_b64[-16:]}"
1800
+ )
1801
+ except Exception:
1802
+ click.echo("[preflight] ciphertext: invalid base64 (unexpected)")
1803
+ except Exception:
1804
+ # Best-effort logging only
1805
+ pass
1806
+ with httpx.Client(
1807
+ timeout=15.0,
1808
+ headers={
1809
+ "Authorization": f"Bearer {synth_key}",
1810
+ "Content-Type": "application/json",
1811
+ },
1812
+ ) as c:
1813
+ click.echo("[preflight] upserting env key…")
1814
+ up = c.post(f"{backend_base.rstrip('/')}/v1/env-keys", json=payload)
1815
+ body_snip = ""
1816
+ try:
1817
+ body_snip = up.text[:400] if up.text else ""
1818
+ except Exception:
1819
+ body_snip = ""
1820
+ click.echo(f"[preflight] upsert status={up.status_code}{(' body='+body_snip) if body_snip else ''}")
1821
+
1822
+ # If upload succeeded (2xx), consider it successful even if verification fails
1823
+ # This handles cases where verification endpoint has issues
1824
+ if 200 <= up.status_code < 300:
1825
+ key_preview = (
1826
+ _preview(env_api_key)
1827
+ )
1828
+ click.echo(
1829
+ f"✅ ENVIRONMENT_API_KEY uploaded successfully ({key_preview})"
1830
+ )
1831
+
1832
+ # Try verification, but don't fail if it doesn't work
1833
+ click.echo("[preflight] verifying env key presence…")
1834
+ try:
1835
+ ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
1836
+ if ver.status_code == 200 and (ver.json() or {}).get("present"):
1837
+ click.echo("✅ Key verified in backend")
1838
+ else:
1839
+ click.echo(
1840
+ f"⚠️ Verification returned {ver.status_code}, but upload succeeded - proceeding"
1841
+ )
1842
+ except Exception as verify_err:
1843
+ click.echo(
1844
+ f"⚠️ Verification check failed ({verify_err}), but upload succeeded - proceeding"
1845
+ )
1846
+ else:
1847
+ error_msg = (
1848
+ f"ENVIRONMENT_API_KEY upload failed with status {up.status_code}"
1849
+ + (f" body={body_snip}" if body_snip else "")
1850
+ )
1851
+ if crash_on_failure:
1852
+ raise click.ClickException(f"[CRITICAL] {error_msg}")
1853
+ click.echo(f"[WARN] {error_msg}; proceeding anyway")
1854
+ except Exception as e:
1855
+ error_msg = f"Failed to encrypt/upload ENVIRONMENT_API_KEY: {e}"
1856
+ if crash_on_failure:
1857
+ raise click.ClickException(f"[CRITICAL] {error_msg}") from e
1858
+ click.echo(f"[WARN] {error_msg}; proceeding anyway")
1859
+ except Exception as e:
1860
+ error_msg = f"Backend preflight for ENVIRONMENT_API_KEY failed: {e}"
1861
+ if crash_on_failure:
1862
+ raise click.ClickException(f"[CRITICAL] {error_msg}") from e
1863
+ click.echo(f"[WARN] {error_msg}; proceeding anyway")
1864
+
1865
+
1866
+ def _run_modal_with_entry(
1867
+ entry: TaskAppEntryType,
1868
+ modal_cfg: ModalDeploymentConfigType,
1869
+ modal_cli: str,
1870
+ modal_name: str | None,
1871
+ env_paths: list[Path],
1872
+ command: str,
1873
+ *,
1874
+ dry_run: bool = False,
1875
+ original_path: Path | None = None,
1876
+ ) -> None:
1877
+ env_paths_list = [Path(p).resolve() for p in env_paths]
1878
+ dotenv_paths = [str(p) for p in env_paths_list]
1879
+ _load_env_files_into_process(dotenv_paths)
1880
+ fallback_dir = env_paths_list[0].parent if env_paths_list else Path.cwd()
1881
+ _ensure_env_values(env_paths_list, fallback_dir)
1882
+ _load_env_values(env_paths_list)
1883
+ _preflight_env_key(env_paths_list, crash_on_failure=True)
1884
+
1885
+ inline_secret_values: dict[str, str] = {}
1886
+ env_key = os.environ.get("ENVIRONMENT_API_KEY", "").strip()
1887
+ if env_key:
1888
+ inline_secret_values["ENVIRONMENT_API_KEY"] = env_key
1889
+ inline_secret_values.setdefault("DEV_ENVIRONMENT_API_KEY", env_key)
1890
+ aliases = os.environ.get("ENVIRONMENT_API_KEY_ALIASES", "").strip()
1891
+ if aliases:
1892
+ inline_secret_values["ENVIRONMENT_API_KEY_ALIASES"] = aliases
1893
+ for vendor_key in ("GROQ_API_KEY", "OPENAI_API_KEY"):
1894
+ val = os.environ.get(vendor_key, "").strip()
1895
+ if val:
1896
+ inline_secret_values[vendor_key] = val
1897
+
1898
+ if inline_secret_values:
1899
+ preview = inline_secret_values.get("ENVIRONMENT_API_KEY", "")
1900
+ shown = f"{preview[:6]}...{preview[-4:]}" if preview and len(preview) > 10 else preview
1901
+ click.echo(f"[deploy] inline ENVIRONMENT_API_KEY prepared ({shown})")
1902
+ else:
1903
+ click.echo("[deploy] no inline ENVIRONMENT_API_KEY found; relying on Modal secrets/dotenv")
1904
+
1905
+ script_path = _write_modal_entrypoint(
1906
+ entry,
1907
+ modal_cfg,
1908
+ modal_name,
1909
+ dotenv_paths=dotenv_paths,
1910
+ original_path=original_path,
1911
+ inline_secret_values=inline_secret_values,
1912
+ )
1913
+ cmd = [*_modal_command_prefix(modal_cli), command, str(script_path)]
1914
+ if modal_name and command == "deploy":
1915
+ cmd.extend(["--name", modal_name])
1916
+
1917
+ proc_env = os.environ.copy()
1918
+ pythonpath_entries: list[str] = [str(REPO_ROOT)]
1919
+ if original_path is not None:
1920
+ source_dir = Path(original_path).resolve().parent
1921
+ pythonpath_entries.insert(0, str(source_dir))
1922
+ existing_pp = proc_env.get("PYTHONPATH")
1923
+ if existing_pp:
1924
+ pythonpath_entries.append(existing_pp)
1925
+ proc_env["PYTHONPATH"] = os.pathsep.join(list(dict.fromkeys(pythonpath_entries)))
1926
+
1927
+ if dry_run:
1928
+ click.echo("Dry run: " + " ".join(shlex.quote(component) for component in cmd))
1929
+ script_path.unlink(missing_ok=True)
1930
+ return
1931
+ click.secho(
1932
+ "[modal-exec] " + " ".join(shlex.quote(component) for component in cmd),
1933
+ fg="cyan",
1934
+ )
1935
+
1936
+ try:
1937
+ # Stream output live for better diagnostics
1938
+ proc = subprocess.Popen(
1939
+ cmd,
1940
+ stdout=subprocess.PIPE,
1941
+ stderr=subprocess.STDOUT,
1942
+ text=True,
1943
+ bufsize=1,
1944
+ env=proc_env,
1945
+ )
1946
+ task_app_url = None
1947
+ assert proc.stdout is not None
1948
+ for line in proc.stdout:
1949
+ # Echo lines as they arrive
1950
+ click.echo(line, nl=False)
1951
+ # Look for lines containing modal.run URLs
1952
+ if task_app_url is None and ("modal.run" in line and "=>" in line):
1953
+ parts = line.split("=>")
1954
+ if len(parts) >= 2:
1955
+ task_app_url = parts[-1].strip()
1956
+ # Save URL immediately for convenience
1957
+ if task_app_url and env_paths_list:
1958
+ env_file = env_paths_list[0]
1959
+ _save_to_env_file(env_file, "TASK_APP_BASE_URL", task_app_url)
1960
+ click.echo(f"\n✓ Task app URL: {task_app_url}\n")
1961
+ rc = proc.wait()
1962
+ if rc != 0:
1963
+ raise subprocess.CalledProcessError(rc, cmd)
1964
+ except subprocess.CalledProcessError as exc:
1965
+ raise click.ClickException(
1966
+ f"modal {command} failed with exit code {exc.returncode}"
1967
+ ) from exc
1968
+ finally:
1969
+ script_path.unlink(missing_ok=True)
1970
+
1971
+
1972
+ def _load_env_values(paths: list[Path], *, allow_empty: bool = False) -> dict[str, str]:
1973
+ values: dict[str, str] = {}
1974
+ for p in paths:
1975
+ try:
1976
+ content = p.read_text(encoding="utf-8")
1977
+ except FileNotFoundError:
1978
+ continue
1979
+ for line in content.splitlines():
1980
+ if not line or line.lstrip().startswith("#") or "=" not in line:
1981
+ continue
1982
+ key, value = line.split("=", 1)
1983
+ if key and key not in values:
1984
+ values[key.strip()] = value.strip()
1985
+ if not allow_empty and not values:
1986
+ raise click.ClickException("No environment values found")
1987
+ os.environ.update({k: v for k, v in values.items() if k and v})
1988
+ return values
1989
+
1990
+
1991
+ def _interactive_create_env(target_dir: Path) -> Path | None:
1992
+ env_path = (target_dir / ".env").resolve()
1993
+ if env_path.exists():
1994
+ existing = _parse_env_file(env_path)
1995
+ env_api = (existing.get("ENVIRONMENT_API_KEY") or "").strip()
1996
+ if env_api:
1997
+ return env_path
1998
+ click.echo(f"Existing {env_path} is missing ENVIRONMENT_API_KEY. Let's update it.")
1999
+ return _interactive_fill_env(env_path)
2000
+
2001
+ click.echo("No .env found for this task app. Let's create one.")
2002
+ return _interactive_fill_env(env_path)
2003
+
2004
+
2005
+ def _parse_env_file(path: Path) -> dict[str, str]:
2006
+ data: dict[str, str] = {}
2007
+ try:
2008
+ for line in path.read_text(encoding="utf-8").splitlines():
2009
+ if not line or line.lstrip().startswith("#") or "=" not in line:
2010
+ continue
2011
+ key, value = line.split("=", 1)
2012
+ data[key.strip()] = value.strip()
2013
+ except FileNotFoundError:
2014
+ pass
2015
+ return data
2016
+
2017
+
2018
+ def _interactive_fill_env(env_path: Path) -> Path | None:
2019
+ if not sys.stdin.isatty():
2020
+ raise click.ClickException(
2021
+ "ENVIRONMENT_API_KEY missing. Provide --env-file or run `synth-ai setup` in an interactive shell to create one."
2022
+ )
2023
+ existing = _parse_env_file(env_path) if env_path.exists() else {}
2024
+
2025
+ def _prompt(label: str, *, default: str = "", required: bool) -> str | None:
2026
+ while True:
2027
+ try:
2028
+ value = click.prompt(
2029
+ label, default=default, show_default=bool(default) or not required
2030
+ ).strip()
2031
+ except (Abort, EOFError, KeyboardInterrupt):
2032
+ click.echo("Aborted env creation.")
2033
+ return None
2034
+ if value or not required:
2035
+ return value
2036
+ click.echo("This field is required.")
2037
+
2038
+ env_default = existing.get("ENVIRONMENT_API_KEY", "").strip()
2039
+ env_api_key = _prompt("ENVIRONMENT_API_KEY", default=env_default, required=True)
2040
+ if env_api_key is None:
2041
+ return None
2042
+ synth_default = existing.get("SYNTH_API_KEY", "").strip()
2043
+ openai_default = existing.get("OPENAI_API_KEY", "").strip()
2044
+ synth_key = _prompt("SYNTH_API_KEY (optional)", default=synth_default, required=False) or ""
2045
+ openai_key = _prompt("OPENAI_API_KEY (optional)", default=openai_default, required=False) or ""
2046
+
2047
+ lines = [
2048
+ f"ENVIRONMENT_API_KEY={env_api_key}",
2049
+ f"SYNTH_API_KEY={synth_key}",
2050
+ f"OPENAI_API_KEY={openai_key}",
2051
+ ]
2052
+ env_path.parent.mkdir(parents=True, exist_ok=True)
2053
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
2054
+ click.echo(f"Wrote credentials to {env_path}")
2055
+ return env_path
2056
+
2057
+
2058
+ def _ensure_env_values(env_paths: list[Path], fallback_dir: Path) -> None:
2059
+ if (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
2060
+ return
2061
+ target = env_paths[0] if env_paths else (fallback_dir / ".env").resolve()
2062
+ click.echo(
2063
+ "⚠️ ENVIRONMENT_API_KEY not set. Run `uvx synth-ai setup`, "
2064
+ "or pass --env-file pointing at a .env with ENVIRONMENT_API_KEY."
2065
+ )
2066
+ result = _interactive_fill_env(target)
2067
+ if result is None:
2068
+ raise click.ClickException("ENVIRONMENT_API_KEY required to continue")
2069
+ # After generating .env, load it and override any previously-empty values
2070
+ _load_env_values([result])
2071
+ if not (os.environ.get("ENVIRONMENT_API_KEY") or "").strip():
2072
+ raise click.ClickException("Failed to load ENVIRONMENT_API_KEY from generated .env")
2073
+
2074
+
2075
+ def _deploy_entry(
2076
+ entry: TaskAppEntryType,
2077
+ modal_name: str | None,
2078
+ dry_run: bool,
2079
+ modal_cli: str,
2080
+ env_file: Sequence[str],
2081
+ original_path: Path | None = None,
2082
+ ) -> None:
2083
+ modal_cfg = entry.modal # type: ignore[attr-defined]
2084
+ if modal_cfg is None:
2085
+ raise click.ClickException(
2086
+ f"Task app '{entry.app_id}' does not define Modal deployment settings"
2087
+ )
2088
+
2089
+ env_paths = _determine_env_files(entry, env_file, original_path=original_path)
2090
+ click.echo("Using env file(s): " + ", ".join(str(p.resolve()) for p in env_paths))
2091
+ _run_modal_with_entry(
2092
+ entry,
2093
+ modal_cfg,
2094
+ modal_cli,
2095
+ modal_name,
2096
+ env_paths,
2097
+ command="deploy",
2098
+ dry_run=dry_run,
2099
+ original_path=original_path,
2100
+ )
2101
+
2102
+
2103
+ def _modal_serve_entry(
2104
+ entry: TaskAppEntryType,
2105
+ modal_name: str | None,
2106
+ modal_cli: str,
2107
+ env_file: Sequence[str],
2108
+ original_path: Path | None = None,
2109
+ ) -> None:
2110
+ modal_cfg = entry.modal # type: ignore[attr-defined]
2111
+ if modal_cfg is None:
2112
+ raise click.ClickException(
2113
+ f"Task app '{entry.app_id}' does not define Modal deployment settings"
2114
+ )
2115
+
2116
+ env_paths = _determine_env_files(entry, env_file, original_path=original_path)
2117
+ click.echo("Using env file(s): " + ", ".join(str(p.resolve()) for p in env_paths))
2118
+ _run_modal_with_entry(
2119
+ entry,
2120
+ modal_cfg,
2121
+ modal_cli,
2122
+ modal_name,
2123
+ env_paths,
2124
+ command="serve",
2125
+ original_path=original_path,
2126
+ )
2127
+
2128
+
2129
+ @click.group(name="task-app-group", help="Utilities for serving and deploying Synth task apps.")
2130
+ def task_app_group() -> None:
2131
+ pass
2132
+
2133
+
2134
+ @task_app_group.command("list")
2135
+ def list_apps() -> None:
2136
+ """List registered task apps."""
2137
+
2138
+ entries = registry.list()
2139
+ if not entries:
2140
+ click.echo("No task apps registered.")
2141
+ return
2142
+ for entry in entries:
2143
+ aliases = f" (aliases: {', '.join(entry.aliases)})" if entry.aliases else ""
2144
+ click.echo(f"- {entry.app_id}{aliases}: {entry.description}")
2145
+
2146
+
2147
+ @task_app_group.command("validate")
2148
+ @click.argument("app_id", type=str, required=True)
2149
+ @click.option(
2150
+ "--url",
2151
+ type=str,
2152
+ default=None,
2153
+ help="Task app URL to validate (if not provided, starts a local server)",
2154
+ )
2155
+ @click.option(
2156
+ "--port",
2157
+ type=int,
2158
+ default=8765,
2159
+ help="Port to use for temporary server (default: 8765)",
2160
+ )
2161
+ @click.option(
2162
+ "--api-key",
2163
+ type=str,
2164
+ default=None,
2165
+ envvar="ENVIRONMENT_API_KEY",
2166
+ help="API key for authentication (default: $ENVIRONMENT_API_KEY)",
2167
+ )
2168
+ @click.option(
2169
+ "--min-instances",
2170
+ type=int,
2171
+ default=10,
2172
+ help="Minimum number of task instances required (default: 10)",
2173
+ )
2174
+ @click.option(
2175
+ "--verbose",
2176
+ "-v",
2177
+ is_flag=True,
2178
+ help="Show detailed information about the task app",
2179
+ )
2180
+ @click.option(
2181
+ "--json",
2182
+ "output_json",
2183
+ is_flag=True,
2184
+ help="Output results as JSON",
2185
+ )
2186
+ def validate_task_app_cmd(
2187
+ app_id: str,
2188
+ url: str | None,
2189
+ port: int,
2190
+ api_key: str | None,
2191
+ min_instances: int,
2192
+ verbose: bool,
2193
+ output_json: bool,
2194
+ ) -> None:
2195
+ """Validate a task app deployment readiness.
2196
+
2197
+ This command verifies that a task app is properly configured and ready to run
2198
+ by checking all required HTTP endpoints, authentication, and task availability.
2199
+
2200
+ By default, it starts a temporary local server for validation. You can also
2201
+ validate a remote deployment by passing --url.
2202
+
2203
+ \b
2204
+ What gets validated:
2205
+ • Root endpoint (/) responds correctly
2206
+ • Health endpoint (/health) is accessible with proper authentication
2207
+ • Info endpoint (/info) returns valid task metadata
2208
+ • Task info endpoint (/task_info) provides task instances
2209
+ • Rollout endpoint (/rollout) is registered
2210
+ • At least N task instances are available (default: 10)
2211
+
2212
+ \b
2213
+ Examples:
2214
+
2215
+ \b
2216
+ Validate grpo-crafter (starts local server automatically):
2217
+ $ synth-ai task-app validate grpo-crafter
2218
+
2219
+ \b
2220
+ Validate sokoban with verbose output:
2221
+ $ synth-ai task-app validate sokoban --verbose
2222
+
2223
+ \b
2224
+ Validate with custom port:
2225
+ $ synth-ai task-app validate sokoban --port 9000
2226
+
2227
+ \b
2228
+ Validate a remote deployment:
2229
+ $ synth-ai task-app validate grpo-crafter --url https://my-crafter.modal.run
2230
+
2231
+ \b
2232
+ Require at least 20 task instances:
2233
+ $ synth-ai task-app validate grpo-crafter --min-instances 20
2234
+
2235
+ \b
2236
+ Get JSON output for automation:
2237
+ $ synth-ai task-app validate sokoban --json
2238
+
2239
+ \b
2240
+ Common use cases:
2241
+ • Pre-deployment verification: Check task app works before deploying to Modal
2242
+ • CI/CD integration: Use --json flag for automated validation in pipelines
2243
+ • Debug failing deployments: Use --verbose to see detailed endpoint responses
2244
+ • Test API key configuration: Verify authentication is set up correctly
2245
+ """
2246
+ import socket
2247
+ import subprocess
2248
+ import tempfile
2249
+ import time
2250
+
2251
+ # Import the validate_task_app function defined in this module
2252
+ from ._validate_task_app import validate_task_app # type: ignore[attr-defined]
2253
+
2254
+ proc = None
2255
+ task_app_url = url
2256
+
2257
+ try:
2258
+ # If no URL provided, start a temporary server
2259
+ if not task_app_url:
2260
+ # Find an available port
2261
+ def is_port_available(port: int) -> bool:
2262
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2263
+ try:
2264
+ s.bind(("", port))
2265
+ return True
2266
+ except OSError:
2267
+ return False
2268
+
2269
+ while not is_port_available(port):
2270
+ port += 1
2271
+
2272
+ task_app_url = f"http://localhost:{port}"
2273
+
2274
+ if not output_json:
2275
+ click.echo(f"Starting temporary {app_id} server on port {port}...")
2276
+
2277
+ # Start the server in background
2278
+ env = os.environ.copy()
2279
+ if api_key:
2280
+ env["ENVIRONMENT_API_KEY"] = api_key
2281
+
2282
+ # Create a temporary trace DB and trace dir to avoid prompts
2283
+ import tempfile
2284
+ temp_dir = tempfile.mkdtemp()
2285
+ temp_trace_db = os.path.join(temp_dir, "validate_trace.db")
2286
+ temp_trace_dir = os.path.join(temp_dir, "traces")
2287
+ os.makedirs(temp_trace_dir, exist_ok=True)
2288
+
2289
+ proc = subprocess.Popen(
2290
+ [
2291
+ "uv",
2292
+ "run",
2293
+ "synth-ai",
2294
+ "task-app",
2295
+ "serve",
2296
+ app_id,
2297
+ "--port",
2298
+ str(port),
2299
+ "--no-reload",
2300
+ "--trace",
2301
+ temp_trace_dir,
2302
+ "--trace-db",
2303
+ temp_trace_db,
2304
+ ],
2305
+ env=env,
2306
+ stdin=subprocess.PIPE, # Add stdin to handle any prompts
2307
+ stdout=subprocess.DEVNULL if output_json else subprocess.PIPE,
2308
+ stderr=subprocess.DEVNULL if output_json else subprocess.PIPE,
2309
+ text=True,
2310
+ )
2311
+
2312
+ # Write empty input to stdin to skip any prompts
2313
+ if proc.stdin:
2314
+ try:
2315
+ proc.stdin.write("\n")
2316
+ proc.stdin.flush()
2317
+ proc.stdin.close()
2318
+ except Exception:
2319
+ pass
2320
+
2321
+ # Wait for server to be ready
2322
+ if not output_json:
2323
+ click.echo("Waiting for server to start...")
2324
+
2325
+ import httpx
2326
+ for _attempt in range(60): # 30 seconds timeout
2327
+ try:
2328
+ async def check_health():
2329
+ async with httpx.AsyncClient(timeout=2.0) as client:
2330
+ resp = await client.get(f"{task_app_url}/")
2331
+ return resp.status_code == 200
2332
+
2333
+ if asyncio.run(check_health()):
2334
+ break
2335
+ except Exception:
2336
+ pass
2337
+
2338
+ # Check if process died
2339
+ if proc.poll() is not None:
2340
+ stderr_output = ""
2341
+ if proc.stderr and not output_json:
2342
+ stderr_output = proc.stderr.read()
2343
+ click.echo(click.style("✗ Server process exited unexpectedly", fg="red"), err=True)
2344
+ if stderr_output and not output_json:
2345
+ click.echo(f"Error output:\n{stderr_output}", err=True)
2346
+ sys.exit(1)
2347
+
2348
+ time.sleep(0.5)
2349
+ else:
2350
+ click.echo(click.style("✗ Server failed to start within 30 seconds", fg="red"), err=True)
2351
+ sys.exit(1)
2352
+
2353
+ if not output_json:
2354
+ click.echo(click.style("✓ Server started", fg="green"))
2355
+ click.echo()
2356
+
2357
+ # Ensure URL doesn't have trailing slash
2358
+ task_app_url = task_app_url.rstrip("/")
2359
+
2360
+ async def _run() -> tuple[bool, dict[str, Any]]:
2361
+ return await validate_task_app(
2362
+ url=task_app_url,
2363
+ api_key=api_key,
2364
+ min_instances=min_instances,
2365
+ verbose=verbose,
2366
+ )
2367
+
2368
+ success, results = asyncio.run(_run())
2369
+
2370
+ if output_json:
2371
+ import json as _json
2372
+ click.echo(_json.dumps(results, indent=2))
2373
+
2374
+ sys.exit(0 if success else 1)
2375
+
2376
+ finally:
2377
+ # Cleanup: stop the temporary server
2378
+ if proc is not None:
2379
+ if not output_json:
2380
+ click.echo("\nStopping temporary server...")
2381
+ try:
2382
+ proc.terminate()
2383
+ proc.wait(timeout=5)
2384
+ except Exception:
2385
+ proc.kill()
2386
+
2387
+ # Cleanup temp trace DB
2388
+ if not url and 'temp_dir' in locals():
2389
+ import contextlib
2390
+ import shutil
2391
+ with contextlib.suppress(Exception):
2392
+ shutil.rmtree(temp_dir, ignore_errors=True)
2393
+
2394
+
2395
+ def _load_env_files_into_process(paths: Sequence[str]) -> None:
2396
+ for p in paths:
2397
+ try:
2398
+ txt = Path(p).expanduser().read_text()
2399
+ except Exception:
2400
+ continue
2401
+ for line in txt.splitlines():
2402
+ if not line or line.startswith("#") or "=" not in line:
2403
+ continue
2404
+ k, v = line.split("=", 1)
2405
+ key = k.strip()
2406
+ val = v.strip().strip('"').strip("'")
2407
+ # Load into process, but allow overriding if the current value is empty
2408
+ if key:
2409
+ current = os.environ.get(key)
2410
+ if current is None or not str(current).strip():
2411
+ os.environ[key] = val
2412
+
2413
+
2414
+ @click.command("serve")
2415
+ @click.argument("app_id", type=str, required=False)
2416
+ @click.option("--host", default="0.0.0.0", show_default=True)
2417
+ @click.option("--port", default=None, type=int, help="Port to serve on (default: 8001)")
2418
+ @click.option("--env-file", multiple=True, type=click.Path(), help="Extra .env files to load")
2419
+ @click.option(
2420
+ "--reload/--no-reload", "reload_flag", default=False, help="Enable uvicorn auto-reload"
2421
+ )
2422
+ @click.option(
2423
+ "--force/--no-force",
2424
+ "force",
2425
+ default=False,
2426
+ help="Kill any process already bound to the selected port before starting",
2427
+ )
2428
+ @click.option(
2429
+ "--trace",
2430
+ "trace_dir",
2431
+ type=click.Path(),
2432
+ default=None,
2433
+ help="Enable tracing and write SFT JSONL files to this directory (default: traces/v3)",
2434
+ )
2435
+ @click.option(
2436
+ "--trace-db",
2437
+ "trace_db",
2438
+ type=click.Path(),
2439
+ default=None,
2440
+ help="Override local trace DB path (default: traces/v3/synth_ai.db)",
2441
+ )
2442
+ def serve_command(
2443
+ app_id: str | None,
2444
+ host: str,
2445
+ port: int | None,
2446
+ env_file: Sequence[str],
2447
+ reload_flag: bool,
2448
+ force: bool,
2449
+ trace_dir: str | None,
2450
+ trace_db: str | None,
2451
+ ) -> None:
2452
+ return None
2453
+
2454
+
2455
+ @task_app_group.command("info")
2456
+ @click.option(
2457
+ "--base",
2458
+ "base_url",
2459
+ default=None,
2460
+ help="Task app base URL (default: TASK_APP_BASE_URL or http://127.0.0.1:8001)",
2461
+ )
2462
+ @click.option(
2463
+ "--api-key",
2464
+ default=None,
2465
+ help="Environment API key (default: ENVIRONMENT_API_KEY or dev fallbacks)",
2466
+ )
2467
+ @click.option(
2468
+ "--seed",
2469
+ "seeds",
2470
+ multiple=True,
2471
+ type=int,
2472
+ help="Optional seed(s) to request specific instances (repeatable)",
2473
+ )
2474
+ def info_command(base_url: str | None, api_key: str | None, seeds: tuple[int, ...]) -> None:
2475
+ """Fetch Task App /task_info with authentication and print JSON."""
2476
+ import json as _json
2477
+ import os as _os
2478
+
2479
+ import requests as _requests
2480
+
2481
+ base = (base_url or _os.getenv("TASK_APP_BASE_URL") or "http://127.0.0.1:8001").rstrip("/")
2482
+
2483
+ # Resolve API key, permitting dev fallbacks
2484
+ auth_module = _maybe_import("synth_ai.sdk.task.auth")
2485
+ if auth_module is not None:
2486
+ _norm_key = getattr(auth_module, "normalize_environment_api_key", lambda: _os.getenv("ENVIRONMENT_API_KEY"))
2487
+ else:
2488
+ _norm_key = lambda: _os.getenv("ENVIRONMENT_API_KEY") # noqa: E731
2489
+ key = (api_key or _norm_key() or "").strip()
2490
+ if not key:
2491
+ raise click.ClickException("Missing API key. Provide --api-key or set ENVIRONMENT_API_KEY.")
2492
+
2493
+ headers: dict[str, str] = {"X-API-Key": key, "Authorization": f"Bearer {key}"}
2494
+ aliases = (_os.getenv("ENVIRONMENT_API_KEY_ALIASES") or "").strip()
2495
+ keys_csv = (
2496
+ ",".join([key] + [p.strip() for p in aliases.split(",") if p.strip()]) if aliases else key
2497
+ )
2498
+ if keys_csv:
2499
+ headers["X-API-Keys"] = keys_csv
2500
+
2501
+ params: list[tuple[str, str]] = []
2502
+ for s in seeds:
2503
+ params.append(("seed", str(int(s))))
2504
+
2505
+ url = f"{base}/task_info"
2506
+ try:
2507
+ r = _requests.get(url, headers=headers, params=params or None, timeout=30)
2508
+ except Exception as exc:
2509
+ raise click.ClickException(f"Request failed: {exc}") from exc
2510
+ if not (200 <= r.status_code < 300):
2511
+ ct = r.headers.get("content-type", "")
2512
+ detail = r.text
2513
+ if ct.startswith("application/json"):
2514
+ with contextlib.suppress(Exception):
2515
+ detail = _json.dumps(r.json(), indent=2)
2516
+ raise click.ClickException(f"{url} returned {r.status_code}:\n{detail}")
2517
+
2518
+ data = (
2519
+ r.json()
2520
+ if r.headers.get("content-type", "").startswith("application/json")
2521
+ else {"raw": r.text}
2522
+ )
2523
+ click.echo(_json.dumps(data, indent=2, sort_keys=True))
2524
+
2525
+
2526
+ @task_app_group.command("serve")
2527
+ @click.argument("app_id", type=str, required=False)
2528
+ @click.option("--host", default="0.0.0.0", show_default=True)
2529
+ @click.option("--port", default=None, type=int, help="Port to serve on (default: 8001)")
2530
+ @click.option("--env-file", multiple=True, type=click.Path(), help="Extra .env files to load")
2531
+ @click.option(
2532
+ "--reload/--no-reload", "reload_flag", default=False, help="Enable uvicorn auto-reload"
2533
+ )
2534
+ @click.option(
2535
+ "--force/--no-force",
2536
+ "force",
2537
+ default=False,
2538
+ help="Kill any process already bound to the selected port before starting",
2539
+ )
2540
+ @click.option(
2541
+ "--trace",
2542
+ "trace_dir",
2543
+ type=click.Path(),
2544
+ default=None,
2545
+ help="Enable tracing and write SFT JSONL files to this directory (default: traces/v3)",
2546
+ )
2547
+ @click.option(
2548
+ "--trace-db",
2549
+ "trace_db",
2550
+ type=click.Path(),
2551
+ default=None,
2552
+ help="Override local trace DB path (default: traces/v3/synth_ai.db)",
2553
+ )
2554
+ def serve_task_group(
2555
+ app_id: str | None,
2556
+ host: str,
2557
+ port: int | None,
2558
+ env_file: Sequence[str],
2559
+ reload_flag: bool,
2560
+ force: bool,
2561
+ trace_dir: str | None,
2562
+ trace_db: str | None,
2563
+ ) -> None:
2564
+ """Serve a TaskAppConfig-based task app using uvicorn."""
2565
+ import contextlib
2566
+
2567
+ if not host:
2568
+ host = "0.0.0.0"
2569
+
2570
+ if port is None:
2571
+ port = 8001
2572
+
2573
+ # Auto-enable tracing by default
2574
+ try:
2575
+ auto_trace = os.getenv("SYNTH_AUTO_TRACE", "1")
2576
+ auto_trace_enabled = auto_trace not in {"0", "false", "False", ""}
2577
+ except Exception:
2578
+ auto_trace_enabled = True
2579
+
2580
+ if auto_trace_enabled:
2581
+ demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
2582
+ if trace_dir is None:
2583
+ default_trace_dir = (demo_base / "traces" / "v3").resolve()
2584
+ with contextlib.suppress(Exception):
2585
+ default_trace_dir.mkdir(parents=True, exist_ok=True)
2586
+ trace_dir = str(default_trace_dir)
2587
+ click.echo(f"[trace] Using trace directory: {trace_dir}")
2588
+ if trace_dir and trace_db is None:
2589
+ default_trace_db = (Path(trace_dir) / "synth_ai.db").resolve()
2590
+ with contextlib.suppress(Exception):
2591
+ default_trace_db.parent.mkdir(parents=True, exist_ok=True)
2592
+ trace_db = str(default_trace_db)
2593
+ click.echo(f"[trace] Using trace DB: {trace_db}")
2594
+
2595
+ # Select and serve the app
2596
+ choice = _select_app_choice(app_id, purpose="serve")
2597
+ entry = choice.ensure_entry()
2598
+ _serve_entry(
2599
+ entry,
2600
+ host,
2601
+ port,
2602
+ env_file,
2603
+ reload_flag,
2604
+ force,
2605
+ trace_dir=trace_dir,
2606
+ trace_db=trace_db,
2607
+ )
2608
+
2609
+
2610
+
2611
+ def _determine_env_files(
2612
+ entry: TaskAppEntryType, user_env_files: Sequence[str], *, original_path: Path | None = None
2613
+ ) -> list[Path]:
2614
+ resolved: list[Path] = []
2615
+ for candidate in user_env_files:
2616
+ p = Path(candidate).expanduser()
2617
+ if not p.exists():
2618
+ raise click.ClickException(f"Env file not found: {p}")
2619
+ resolved.append(p)
2620
+ if resolved:
2621
+ return resolved
2622
+
2623
+ declared: list[Path] = []
2624
+ for candidate in getattr(entry, "env_files", ()) or ():
2625
+ try:
2626
+ p = Path(candidate).expanduser()
2627
+ except Exception:
2628
+ continue
2629
+ if p.exists() and p.is_file():
2630
+ declared.append(p)
2631
+ if declared:
2632
+ return declared
2633
+
2634
+ def _append_candidate(collection: list[Path], candidate: Path) -> None:
2635
+ if candidate.exists() and candidate.is_file() and candidate not in collection:
2636
+ collection.append(candidate)
2637
+
2638
+ auto_candidates: list[Path] = []
2639
+
2640
+ search_dirs: list[Path] = []
2641
+ if original_path is not None:
2642
+ search_dirs.append(original_path.parent.resolve())
2643
+ for parent in original_path.parent.resolve().parents:
2644
+ search_dirs.append(parent)
2645
+ cwd = Path.cwd().resolve()
2646
+ if cwd not in search_dirs:
2647
+ search_dirs.append(cwd)
2648
+ repo_root = REPO_ROOT.resolve()
2649
+ if repo_root not in search_dirs:
2650
+ search_dirs.append(repo_root)
2651
+
2652
+ for directory in search_dirs:
2653
+ _append_candidate(auto_candidates, directory / ".env")
2654
+ for candidate in sorted(directory.glob("*.env")):
2655
+ _append_candidate(auto_candidates, candidate)
2656
+
2657
+ if auto_candidates:
2658
+ return [auto_candidates[0]]
2659
+
2660
+ raise click.ClickException(
2661
+ "No .env file discovered automatically. Pass --env-file /path/to/.env or generate one with `uvx synth-ai setup`."
2662
+ )
2663
+
2664
+
2665
+ def _ensure_port_free(port: int, host: str, *, force: bool) -> None:
2666
+ import os
2667
+ import socket
2668
+ import subprocess
2669
+ import time
2670
+
2671
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2672
+ in_use = s.connect_ex((host, port)) == 0
2673
+ if not in_use:
2674
+ return
2675
+
2676
+ try:
2677
+ out = subprocess.run(
2678
+ ["lsof", "-ti", f"TCP:{port}"], capture_output=True, text=True, check=False
2679
+ )
2680
+ pids = [pid for pid in out.stdout.strip().splitlines() if pid]
2681
+ except FileNotFoundError:
2682
+ pids = []
2683
+
2684
+ if not force:
2685
+ message = f"Port {port} appears to be in use"
2686
+ if pids:
2687
+ message += f" (PIDs: {', '.join(pids)})"
2688
+ raise click.ClickException(message)
2689
+
2690
+ for pid in pids:
2691
+ try:
2692
+ os.kill(int(pid), signal.SIGTERM)
2693
+ except Exception as exc:
2694
+ raise click.ClickException(f"Failed to terminate PID {pid}: {exc}") from exc
2695
+
2696
+ time.sleep(0.5)
2697
+
2698
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2699
+ still_in_use = s.connect_ex((host, port)) == 0
2700
+
2701
+ if still_in_use:
2702
+ for pid in pids:
2703
+ try:
2704
+ os.kill(int(pid), signal.SIGKILL)
2705
+ except Exception as exc:
2706
+ raise click.ClickException(f"Failed to force terminate PID {pid}: {exc}") from exc
2707
+ time.sleep(0.5)
2708
+
2709
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2710
+ in_use_after = s.connect_ex((host, port)) == 0
2711
+ if in_use_after:
2712
+ raise click.ClickException(
2713
+ f"Port {port} is still in use after attempting to terminate processes."
2714
+ )
2715
+
2716
+
2717
+ def _save_to_env_file(env_path: Path, key: str, value: str) -> None:
2718
+ """Save or update a key-value pair in the .env file."""
2719
+ try:
2720
+ # Read existing .env
2721
+ existing_lines = []
2722
+ if env_path.exists():
2723
+ existing_lines = env_path.read_text().splitlines()
2724
+ else:
2725
+ env_path.parent.mkdir(parents=True, exist_ok=True)
2726
+
2727
+ # Check if key already exists and update it
2728
+ key_updated = False
2729
+ new_lines = []
2730
+ for line in existing_lines:
2731
+ if line.strip().startswith(f"{key}="):
2732
+ new_lines.append(f"{key}={value}")
2733
+ key_updated = True
2734
+ else:
2735
+ new_lines.append(line)
2736
+
2737
+ if key_updated:
2738
+ # Write updated lines back
2739
+ env_path.write_text("\n".join(new_lines) + "\n")
2740
+ click.echo(f"Updated {key} in {env_path}")
2741
+ else:
2742
+ # Append to .env
2743
+ with open(env_path, "a") as f:
2744
+ if existing_lines and not existing_lines[-1].strip():
2745
+ # File exists and last line is not empty
2746
+ pass
2747
+ elif existing_lines:
2748
+ # Add newline before appending
2749
+ f.write("\n")
2750
+ f.write(f"{key}={value}\n")
2751
+ click.echo(f"Saved {key} to {env_path}")
2752
+ except Exception as e:
2753
+ click.echo(f"Warning: Could not save {key} to .env: {e}", err=True)
2754
+
2755
+
2756
+ def _persist_env_api_key(env_api_key: str, env_paths: Sequence[Path] | None) -> None:
2757
+ """Persist ENVIRONMENT_API_KEY to provided env files (or default .env)."""
2758
+ targets: list[Path] = []
2759
+ seen: set[Path] = set()
2760
+ for path in env_paths or ():
2761
+ try:
2762
+ resolved = Path(path).resolve()
2763
+ except Exception:
2764
+ continue
2765
+ if resolved in seen:
2766
+ continue
2767
+ seen.add(resolved)
2768
+ targets.append(resolved)
2769
+
2770
+ if not targets:
2771
+ demo_dir = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
2772
+ targets.append((demo_dir / ".env").resolve())
2773
+
2774
+ for target in targets:
2775
+ _save_to_env_file(target, "ENVIRONMENT_API_KEY", env_api_key)
2776
+
2777
+
2778
+ def _validate_required_env_keys() -> None:
2779
+ """Validate required environment keys are set, prompting if missing."""
2780
+ # Use demo directory .env file if set, otherwise current directory
2781
+ demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
2782
+ env_file = demo_base / ".env"
2783
+
2784
+ if env_file.exists():
2785
+ try:
2786
+ from dotenv import load_dotenv
2787
+
2788
+ load_dotenv(env_file, override=False)
2789
+ except Exception:
2790
+ pass # Best effort
2791
+
2792
+ env_api_key = os.environ.get("ENVIRONMENT_API_KEY", "").strip()
2793
+
2794
+ if not env_api_key:
2795
+ env_api_key = input("Please enter your RL Environment API key:\n> ").strip()
2796
+ if not env_api_key:
2797
+ raise click.ClickException("RL Environment API key is required to start the server")
2798
+ os.environ["ENVIRONMENT_API_KEY"] = env_api_key
2799
+ _save_to_env_file(env_file, "ENVIRONMENT_API_KEY", env_api_key)
2800
+
2801
+ # Check for Groq API key
2802
+ groq_api_key = os.environ.get("GROQ_API_KEY", "").strip()
2803
+
2804
+ if not groq_api_key:
2805
+ click.echo("\nInference API key configuration:")
2806
+ click.echo("This workflow requires a Groq API key.")
2807
+ groq_api_key = input("Groq API key (or press Enter to skip): ").strip()
2808
+ if groq_api_key:
2809
+ os.environ["GROQ_API_KEY"] = groq_api_key
2810
+ _save_to_env_file(env_file, "GROQ_API_KEY", groq_api_key)
2811
+
2812
+
2813
+ def _print_demo_next_steps_if_applicable() -> None:
2814
+ """Print next steps if currently in a demo directory."""
2815
+ try:
2816
+ cwd = Path.cwd().resolve()
2817
+ demo_dir = _load_demo_directory()
2818
+
2819
+ if demo_dir and demo_dir == cwd and (cwd / "run_local_rollout_traced.py").exists():
2820
+ click.echo("\n" + "=" * 60)
2821
+ click.echo("Next step: Collect traced rollouts")
2822
+ click.echo("=" * 60)
2823
+ click.echo("\nIn another terminal, run:")
2824
+ click.echo(f" cd {cwd}")
2825
+ click.echo(" uv run python run_local_rollout_traced.py")
2826
+ click.echo("\nRun this 5-10 times to collect diverse traces.")
2827
+ click.echo("=" * 60 + "\n")
2828
+ except Exception:
2829
+ pass
2830
+
2831
+
2832
+ def _serve_entry(
2833
+ entry: TaskAppEntryType,
2834
+ host: str,
2835
+ port: int,
2836
+ env_file: Sequence[str],
2837
+ reload_flag: bool,
2838
+ force: bool,
2839
+ *,
2840
+ trace_dir: str | None = None,
2841
+ trace_db: str | None = None,
2842
+ ) -> None:
2843
+ env_files = list(entry.env_files)
2844
+ env_files.extend(env_file)
2845
+
2846
+ trace_enabled = trace_dir is not None or trace_db is not None
2847
+ if trace_enabled:
2848
+ os.environ["TASKAPP_TRACING_ENABLED"] = "1"
2849
+
2850
+ # Ensure paths are absolute relative to demo directory
2851
+ demo_base = Path(os.environ.get("SYNTH_DEMO_DIR") or Path.cwd())
2852
+
2853
+ if trace_dir is not None:
2854
+ dir_path = Path(trace_dir).expanduser()
2855
+ if not dir_path.is_absolute():
2856
+ dir_path = (demo_base / dir_path).resolve()
2857
+ try:
2858
+ dir_path.mkdir(parents=True, exist_ok=True)
2859
+ except Exception as exc:
2860
+ raise click.ClickException(
2861
+ f"Failed to create trace directory {dir_path}: {exc}"
2862
+ ) from exc
2863
+ os.environ["TASKAPP_SFT_OUTPUT_DIR"] = str(dir_path)
2864
+ click.echo(f"Tracing enabled. SFT JSONL will be written to {dir_path}")
2865
+ if trace_db is not None:
2866
+ db_path = Path(trace_db).expanduser()
2867
+ if not db_path.is_absolute():
2868
+ db_path = (demo_base / db_path).resolve()
2869
+ # Construct the sqlite URL from the absolute path
2870
+ db_url = f"sqlite+aiosqlite:///{db_path}"
2871
+ os.environ["SQLD_DB_PATH"] = str(db_path)
2872
+ os.environ["TURSO_LOCAL_DB_URL"] = db_url
2873
+ click.echo(f"Tracing DB path set to {db_path}")
2874
+ tracing_config_module = _maybe_import("synth_ai.core.tracing_v3.config")
2875
+ if tracing_config_module is not None:
2876
+ trace_config = tracing_config_module.CONFIG
2877
+ new_db_url = os.getenv("TURSO_LOCAL_DB_URL") or trace_config.db_url
2878
+ trace_config.db_url = new_db_url
2879
+ if new_db_url:
2880
+ click.echo(f"Tracing DB URL resolved to {new_db_url}")
2881
+ elif os.getenv("TASKAPP_TRACING_ENABLED"):
2882
+ click.echo("Tracing enabled via environment variables")
2883
+
2884
+ _ensure_port_free(port, host, force=force)
2885
+
2886
+ _validate_required_env_keys()
2887
+ env_path_objs = [Path(p) for p in env_files if p]
2888
+ _preflight_env_key(env_path_objs)
2889
+
2890
+ # Print next steps if in demo context
2891
+ if trace_enabled:
2892
+ _print_demo_next_steps_if_applicable()
2893
+
2894
+ run_task_app(
2895
+ entry.config_factory,
2896
+ host=host,
2897
+ port=port,
2898
+ reload=reload_flag,
2899
+ env_files=env_files,
2900
+ )
2901
+
2902
+
2903
+ def _write_modal_entrypoint(
2904
+ entry: TaskAppEntryType,
2905
+ modal_cfg: ModalDeploymentConfigType,
2906
+ override_name: str | None,
2907
+ *,
2908
+ dotenv_paths: Sequence[str] | None = None,
2909
+ original_path: Path | None = None,
2910
+ inline_secret_values: dict[str, str] | None = None,
2911
+ ) -> Path:
2912
+ modal_name = override_name or modal_cfg.app_name
2913
+
2914
+ # For dynamically discovered apps, import the module by its package path
2915
+ # Compute the module name relative to the mounted repo root (/opt/synth_ai_repo)
2916
+ remote_file_str: str | None = None
2917
+ if original_path:
2918
+ try:
2919
+ # Build lookup of local->remote mounts
2920
+ mount_map: list[tuple[Path, Path]] = [
2921
+ (Path(local).resolve(), Path(remote))
2922
+ for (local, remote) in modal_cfg.extra_local_dirs
2923
+ ]
2924
+ orig = Path(original_path).resolve()
2925
+ for local_src, remote_dst in mount_map:
2926
+ with contextlib.suppress(Exception):
2927
+ if orig.is_relative_to(local_src): # py311+
2928
+ remote_file_str = str((remote_dst / orig.relative_to(local_src)).resolve())
2929
+ break
2930
+ try:
2931
+ rel = orig.relative_to(local_src)
2932
+ remote_file_str = str((remote_dst / rel).resolve())
2933
+ break
2934
+ except Exception:
2935
+ pass
2936
+ except Exception:
2937
+ remote_file_str = None
2938
+ module_name = entry.config_factory.__module__
2939
+
2940
+ # Prefer a guaranteed mount for the discovered file to avoid package import issues
2941
+ guaranteed_file_str: str | None = None
2942
+ if original_path:
2943
+ guaranteed_file_str = str(
2944
+ (Path("/opt/synth_ai_repo/__local_task_app__") / Path(original_path).stem).with_suffix(
2945
+ ".py"
2946
+ )
2947
+ )
2948
+
2949
+ dotenv_paths = [str(Path(path)) for path in (dotenv_paths or [])]
2950
+
2951
+ pip_packages = list(modal_cfg.pip_packages)
2952
+ # Ensure synth-ai (matching host version if available) is installed in the container
2953
+ synth_pkg = "synth-ai"
2954
+ host_synth = _maybe_import("synth_ai")
2955
+ if host_synth is not None:
2956
+ host_ver = getattr(host_synth, "__version__", None)
2957
+ if host_ver:
2958
+ synth_pkg = f"synth-ai=={host_ver}"
2959
+ if not any(str(p).startswith("synth-ai") for p in pip_packages):
2960
+ pip_packages.insert(0, synth_pkg)
2961
+
2962
+ apt_packages = list(modal_cfg.apt_packages)
2963
+ click.echo(f"[DEBUG] modal_cfg.apt_packages type: {type(modal_cfg.apt_packages)}")
2964
+ click.echo(f"[DEBUG] modal_cfg.apt_packages value: {modal_cfg.apt_packages}")
2965
+ click.echo(f"[DEBUG] apt_packages after list(): {apt_packages}")
2966
+
2967
+ local_dirs = [(str(Path(src)), dst) for src, dst in modal_cfg.extra_local_dirs]
2968
+ # Also mount the host synth_ai source if available to ensure latest code is used
2969
+ if host_synth is not None:
2970
+ try:
2971
+ host_synth_dir = Path(host_synth.__file__).resolve().parent
2972
+ sy_dst = "/opt/synth_ai_repo/synth_ai"
2973
+ candidate = (str(host_synth_dir), sy_dst)
2974
+ if candidate not in local_dirs:
2975
+ local_dirs.insert(0, candidate)
2976
+ except Exception:
2977
+ pass
2978
+ # Ensure the discovered app directory is mounted, regardless of modal_cfg
2979
+ if original_path:
2980
+ discovered_dir = str(Path(original_path).resolve().parent)
2981
+ mount_dst = "/opt/synth_ai_repo/__local_task_app__"
2982
+ if (discovered_dir, mount_dst) not in local_dirs:
2983
+ local_dirs.append((discovered_dir, mount_dst))
2984
+ secret_names = list(modal_cfg.secret_names)
2985
+ volume_mounts = [(name, mount) for name, mount in modal_cfg.volume_mounts]
2986
+ inline_secret_values = {k: v for k, v in (inline_secret_values or {}).items() if v}
2987
+
2988
+ script = f"""from __future__ import annotations
2989
+
2990
+ import importlib
2991
+ import importlib.util
2992
+ import sys
2993
+ import os
2994
+ import shutil
2995
+ import tempfile
2996
+ from pathlib import Path as _Path
2997
+ import fnmatch
2998
+ sys.path.insert(0, '/opt/synth_ai_repo')
2999
+
3000
+ from modal import App, Image, Secret, Volume, asgi_app
3001
+
3002
+ # Defer importing synth_ai until inside fastapi_app to avoid local import errors
3003
+
3004
+ ENTRY_ID = {entry.app_id!r}
3005
+ MODAL_APP_NAME = {modal_name!r}
3006
+ MODULE_NAME = {module_name!r}
3007
+ MODULE_FILE = {guaranteed_file_str or remote_file_str!r}
3008
+ DOTENV_PATHS = {dotenv_paths!r}
3009
+ INLINE_SECRET_VALUES = {inline_secret_values!r}
3010
+
3011
+ image = Image.debian_slim(python_version={modal_cfg.python_version!r})
3012
+
3013
+ # CRITICAL: Install iverilog for Verilog task app (hardcoded to prevent config issues)
3014
+ if {entry.app_id!r} == "grpo-verilog":
3015
+ image = image.apt_install("iverilog")
3016
+
3017
+ # Install apt packages first (before pip)
3018
+ apt_packages = {apt_packages!r}
3019
+ if apt_packages:
3020
+ image = image.apt_install(*apt_packages)
3021
+
3022
+ pip_packages = {pip_packages!r}
3023
+ if pip_packages:
3024
+ image = image.pip_install(*pip_packages)
3025
+
3026
+ local_dirs = {local_dirs!r}
3027
+
3028
+ def _copy_tree_filtered(src_dir: str) -> str:
3029
+ src = _Path(src_dir)
3030
+ temp_dir = _Path(tempfile.mkdtemp(prefix='synth_mount_'))
3031
+
3032
+ exclude_dirs = {".cache", ".git", "__pycache__"}
3033
+ exclude_globs = ['*.db', '*.db-journal', '*-wal', '*-shm']
3034
+
3035
+ for root, dirs, files in os.walk(src):
3036
+ rel_root = _Path(root).relative_to(src)
3037
+ # filter dirs in-place
3038
+ dirs[:] = [d for d in dirs if d not in exclude_dirs]
3039
+ # ensure target directory exists
3040
+ target_dir = (temp_dir / rel_root)
3041
+ target_dir.mkdir(parents=True, exist_ok=True)
3042
+ # copy files with filtering
3043
+ for name in files:
3044
+ if any(fnmatch.fnmatch(name, pat) for pat in exclude_globs):
3045
+ continue
3046
+ src_file = _Path(root) / name
3047
+ dst_file = target_dir / name
3048
+ try:
3049
+ shutil.copy2(src_file, dst_file)
3050
+ except Exception:
3051
+ # ignore problematic files
3052
+ continue
3053
+ return str(temp_dir)
3054
+
3055
+ for local_src, remote_dst in local_dirs:
3056
+ safe_src = _copy_tree_filtered(local_src)
3057
+ image = image.add_local_dir(safe_src, remote_dst)
3058
+
3059
+ secrets = {secret_names!r}
3060
+ secret_objs = [Secret.from_name(name) for name in secrets]
3061
+
3062
+ if INLINE_SECRET_VALUES:
3063
+ secret_objs.append(Secret.from_dict(INLINE_SECRET_VALUES))
3064
+
3065
+ if DOTENV_PATHS:
3066
+ secret_objs.extend(Secret.from_dotenv(path) for path in DOTENV_PATHS)
3067
+
3068
+ volume_mounts = {volume_mounts!r}
3069
+ volume_map = {{}}
3070
+ for vol_name, mount_path in volume_mounts:
3071
+ volume_map[mount_path] = Volume.from_name(vol_name, create_if_missing=True)
3072
+
3073
+ app = App(MODAL_APP_NAME)
3074
+
3075
+ @app.function(
3076
+ image=image,
3077
+ timeout={modal_cfg.timeout},
3078
+ memory={modal_cfg.memory},
3079
+ cpu={modal_cfg.cpu},
3080
+ min_containers={modal_cfg.min_containers},
3081
+ max_containers={modal_cfg.max_containers},
3082
+ secrets=secret_objs,
3083
+ volumes=volume_map,
3084
+ )
3085
+ @asgi_app()
3086
+ def fastapi_app():
3087
+ # Import the module to trigger registration (inside container)
3088
+ import os
3089
+ # Prefer mounted source over any preinstalled site-packages version
3090
+ import sys as _sys
3091
+ for k in list(_sys.modules.keys()):
3092
+ if k == 'synth_ai' or k.startswith('synth_ai.'):
3093
+ _sys.modules.pop(k, None)
3094
+ import importlib as _importlib
3095
+ _importlib.invalidate_caches()
3096
+ try:
3097
+ if MODULE_FILE and os.path.exists(MODULE_FILE):
3098
+ spec = importlib.util.spec_from_file_location(MODULE_NAME or 'task_app_module', MODULE_FILE)
3099
+ if not spec or not spec.loader:
3100
+ raise RuntimeError("Failed to prepare spec for: " + str(MODULE_FILE))
3101
+ mod = importlib.util.module_from_spec(spec)
3102
+ sys.modules[MODULE_NAME or 'task_app_module'] = mod
3103
+ spec.loader.exec_module(mod)
3104
+ else:
3105
+ try:
3106
+ importlib.import_module(MODULE_NAME)
3107
+ except Exception:
3108
+ fallback_file = '/opt/synth_ai_repo/__local_task_app__/' + (MODULE_NAME.split('.')[-1] if MODULE_NAME else 'task_app') + '.py'
3109
+ if os.path.exists(fallback_file):
3110
+ spec = importlib.util.spec_from_file_location(MODULE_NAME or 'task_app_module', fallback_file)
3111
+ if not spec or not spec.loader:
3112
+ raise RuntimeError("Failed to prepare fallback spec for: " + str(fallback_file))
3113
+ mod = importlib.util.module_from_spec(spec)
3114
+ sys.modules[MODULE_NAME or 'task_app_module'] = mod
3115
+ spec.loader.exec_module(mod)
3116
+ else:
3117
+ raise
3118
+ except Exception as e:
3119
+ raise RuntimeError("Task app import failed: " + str(e))
3120
+
3121
+ # Get the entry from registry (now that it's registered)
3122
+ from synth_ai.sdk.task.apps import registry
3123
+ from synth_ai.sdk.task.server import create_task_app
3124
+ entry = registry.get(ENTRY_ID)
3125
+ cfg = entry.modal
3126
+ if cfg is None:
3127
+ raise RuntimeError("Modal configuration missing for task app " + ENTRY_ID)
3128
+ config = entry.config_factory()
3129
+ return create_task_app(config)
3130
+ """
3131
+
3132
+ with tempfile.NamedTemporaryFile("w", suffix=f"_{entry.app_id}_modal.py", delete=False) as tmp:
3133
+ tmp.write(script)
3134
+ tmp.flush()
3135
+ name = tmp.name
3136
+ return Path(name)
3137
+
3138
+
3139
+ def register(cli: click.Group) -> None:
3140
+ cli.add_command(serve_command)
3141
+ cli.add_command(task_app_group)
3142
+ cli.add_command(filter_command)
3143
+
3144
+
3145
+ filter_command = filter_core.command