gatewaysdk 0.3.2__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 (376) hide show
  1. gatewaysdk/__init__.py +540 -0
  2. gatewaysdk/adapter/__init__.py +15 -0
  3. gatewaysdk/adapter/base.py +94 -0
  4. gatewaysdk/adapter/messages.py +270 -0
  5. gatewaysdk/adapter/triplet.py +1026 -0
  6. gatewaysdk/algorithm/__init__.py +39 -0
  7. gatewaysdk/algorithm/apo/__init__.py +5 -0
  8. gatewaysdk/algorithm/apo/apo.py +898 -0
  9. gatewaysdk/algorithm/apo/prompts/apply_edit_variant01.poml +22 -0
  10. gatewaysdk/algorithm/apo/prompts/apply_edit_variant02.poml +18 -0
  11. gatewaysdk/algorithm/apo/prompts/text_gradient_variant01.poml +18 -0
  12. gatewaysdk/algorithm/apo/prompts/text_gradient_variant02.poml +16 -0
  13. gatewaysdk/algorithm/apo/prompts/text_gradient_variant03.poml +107 -0
  14. gatewaysdk/algorithm/base.py +262 -0
  15. gatewaysdk/algorithm/decorator.py +264 -0
  16. gatewaysdk/algorithm/evals/__init__.py +7 -0
  17. gatewaysdk/algorithm/evals/evals.py +217 -0
  18. gatewaysdk/algorithm/fast.py +250 -0
  19. gatewaysdk/algorithm/gepa/__init__.py +61 -0
  20. gatewaysdk/algorithm/gepa/adapter.py +495 -0
  21. gatewaysdk/algorithm/gepa/gepa.py +570 -0
  22. gatewaysdk/algorithm/gepa/lib/__init__.py +18 -0
  23. gatewaysdk/algorithm/gepa/lib/adapters/README.md +12 -0
  24. gatewaysdk/algorithm/gepa/lib/adapters/__init__.py +0 -0
  25. gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/README.md +341 -0
  26. gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/__init__.py +1 -0
  27. gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/anymaths_adapter.py +174 -0
  28. gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/requirements.txt +1 -0
  29. gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/README.md +0 -0
  30. gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/__init__.py +0 -0
  31. gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/default_adapter.py +209 -0
  32. gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/README.md +7 -0
  33. gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/__init__.py +0 -0
  34. gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/dspy_adapter.py +307 -0
  35. gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/README.md +99 -0
  36. gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/dspy_program_proposal_signature.py +137 -0
  37. gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/full_program_adapter.py +268 -0
  38. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/GEPA_RAG.md +621 -0
  39. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/__init__.py +56 -0
  40. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/evaluation_metrics.py +226 -0
  41. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/generic_rag_adapter.py +496 -0
  42. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/rag_pipeline.py +238 -0
  43. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_store_interface.py +212 -0
  44. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/__init__.py +2 -0
  45. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/chroma_store.py +196 -0
  46. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/lancedb_store.py +422 -0
  47. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/milvus_store.py +409 -0
  48. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/qdrant_store.py +368 -0
  49. gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/weaviate_store.py +418 -0
  50. gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/README.md +552 -0
  51. gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/__init__.py +37 -0
  52. gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_adapter.py +699 -0
  53. gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_client.py +364 -0
  54. gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/README.md +9 -0
  55. gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/__init__.py +0 -0
  56. gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/terminal_bench_adapter.py +217 -0
  57. gatewaysdk/algorithm/gepa/lib/api.py +382 -0
  58. gatewaysdk/algorithm/gepa/lib/core/__init__.py +0 -0
  59. gatewaysdk/algorithm/gepa/lib/core/adapter.py +180 -0
  60. gatewaysdk/algorithm/gepa/lib/core/data_loader.py +74 -0
  61. gatewaysdk/algorithm/gepa/lib/core/engine.py +379 -0
  62. gatewaysdk/algorithm/gepa/lib/core/result.py +233 -0
  63. gatewaysdk/algorithm/gepa/lib/core/state.py +636 -0
  64. gatewaysdk/algorithm/gepa/lib/examples/__init__.py +0 -0
  65. gatewaysdk/algorithm/gepa/lib/examples/aime.py +24 -0
  66. gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/eval_default.py +111 -0
  67. gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/instruction_prompt.txt +9 -0
  68. gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/optimal_prompt.txt +24 -0
  69. gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/train_anymaths.py +177 -0
  70. gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/arc_agi.ipynb +25705 -0
  71. gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/example.ipynb +348 -0
  72. gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/__init__.py +4 -0
  73. gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/mcp_optimization_example.py +456 -0
  74. gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/RAG_GUIDE.md +613 -0
  75. gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/__init__.py +9 -0
  76. gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/rag_optimization.py +820 -0
  77. gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/requirements-rag.txt +29 -0
  78. gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/instruction_prompt.txt +16 -0
  79. gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/terminus.txt +9 -0
  80. gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/train_terminus.py +161 -0
  81. gatewaysdk/algorithm/gepa/lib/gepa_utils.py +117 -0
  82. gatewaysdk/algorithm/gepa/lib/logging/__init__.py +0 -0
  83. gatewaysdk/algorithm/gepa/lib/logging/experiment_tracker.py +187 -0
  84. gatewaysdk/algorithm/gepa/lib/logging/logger.py +75 -0
  85. gatewaysdk/algorithm/gepa/lib/logging/utils.py +103 -0
  86. gatewaysdk/algorithm/gepa/lib/proposer/__init__.py +0 -0
  87. gatewaysdk/algorithm/gepa/lib/proposer/base.py +31 -0
  88. gatewaysdk/algorithm/gepa/lib/proposer/merge.py +357 -0
  89. gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/__init__.py +0 -0
  90. gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/base.py +49 -0
  91. gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/reflective_mutation.py +176 -0
  92. gatewaysdk/algorithm/gepa/lib/py.typed +0 -0
  93. gatewaysdk/algorithm/gepa/lib/strategies/__init__.py +0 -0
  94. gatewaysdk/algorithm/gepa/lib/strategies/batch_sampler.py +77 -0
  95. gatewaysdk/algorithm/gepa/lib/strategies/candidate_selector.py +50 -0
  96. gatewaysdk/algorithm/gepa/lib/strategies/component_selector.py +36 -0
  97. gatewaysdk/algorithm/gepa/lib/strategies/eval_policy.py +64 -0
  98. gatewaysdk/algorithm/gepa/lib/strategies/instruction_proposal.py +126 -0
  99. gatewaysdk/algorithm/gepa/lib/utils/__init__.py +10 -0
  100. gatewaysdk/algorithm/gepa/lib/utils/stop_condition.py +196 -0
  101. gatewaysdk/algorithm/gepa/tracing.py +105 -0
  102. gatewaysdk/algorithm/utils.py +177 -0
  103. gatewaysdk/algorithm/verl/__init__.py +5 -0
  104. gatewaysdk/algorithm/verl/interface.py +202 -0
  105. gatewaysdk/automations.py +111 -0
  106. gatewaysdk/benchmark_hub/README.md +88 -0
  107. gatewaysdk/benchmark_hub/__init__.py +119 -0
  108. gatewaysdk/benchmark_hub/_archive.py +189 -0
  109. gatewaysdk/benchmark_hub/_tar.py +6 -0
  110. gatewaysdk/benchmark_hub/client.py +992 -0
  111. gatewaysdk/benchmark_hub/dispatch_shard.py +34 -0
  112. gatewaysdk/benchmark_hub/eval_config.py +20 -0
  113. gatewaysdk/benchmark_hub/evals.py +351 -0
  114. gatewaysdk/benchmark_hub/harbor_adapter.py +673 -0
  115. gatewaysdk/benchmark_hub/path_utils.py +81 -0
  116. gatewaysdk/benchmark_hub/save_utils.py +101 -0
  117. gatewaysdk/benchmark_hub/verifiers_adapter.py +327 -0
  118. gatewaysdk/benchmark_hub/versioning.py +72 -0
  119. gatewaysdk/build.py +515 -0
  120. gatewaysdk/cli/__init__.py +58 -0
  121. gatewaysdk/cli/agent_runner.py +132 -0
  122. gatewaysdk/cli/http_client.py +115 -0
  123. gatewaysdk/cli/platform.py +4086 -0
  124. gatewaysdk/cli/prometheus.py +115 -0
  125. gatewaysdk/cli/release_gate.py +215 -0
  126. gatewaysdk/cli/store.py +131 -0
  127. gatewaysdk/cli/vllm.py +29 -0
  128. gatewaysdk/client.py +406 -0
  129. gatewaysdk/config.py +348 -0
  130. gatewaysdk/connectors/__init__.py +25 -0
  131. gatewaysdk/connectors/client.py +203 -0
  132. gatewaysdk/connectors/skill.py +25 -0
  133. gatewaysdk/connectors/template.py +106 -0
  134. gatewaysdk/context.py +606 -0
  135. gatewaysdk/emitter/__init__.py +43 -0
  136. gatewaysdk/emitter/annotation.py +370 -0
  137. gatewaysdk/emitter/exception.py +54 -0
  138. gatewaysdk/emitter/message.py +61 -0
  139. gatewaysdk/emitter/object.py +117 -0
  140. gatewaysdk/emitter/reward.py +320 -0
  141. gatewaysdk/env_var.py +156 -0
  142. gatewaysdk/environment/__init__.py +108 -0
  143. gatewaysdk/environment/_bundle.py +281 -0
  144. gatewaysdk/environment/_harbor.py +276 -0
  145. gatewaysdk/environment/_materialize.py +97 -0
  146. gatewaysdk/environment/_tar.py +33 -0
  147. gatewaysdk/environment/_world.py +346 -0
  148. gatewaysdk/environment/core.py +527 -0
  149. gatewaysdk/environment/errors.py +58 -0
  150. gatewaysdk/environment/runtime.py +150 -0
  151. gatewaysdk/environment/schema/__init__.py +35 -0
  152. gatewaysdk/environment/schema/__main__.py +225 -0
  153. gatewaysdk/environment/schema/_slack_fidelity.py +116 -0
  154. gatewaysdk/environment/schema/_slack_scenario.py +183 -0
  155. gatewaysdk/environment/schema/api.py +1628 -0
  156. gatewaysdk/environment/schema/batch.py +755 -0
  157. gatewaysdk/environment/schema/compiler.py +615 -0
  158. gatewaysdk/environment/schema/conform.py +428 -0
  159. gatewaysdk/environment/schema/connector.py +777 -0
  160. gatewaysdk/environment/schema/connectors/apple-business-manager/handlers.py +17 -0
  161. gatewaysdk/environment/schema/connectors/apple-business-manager/parity.json +28 -0
  162. gatewaysdk/environment/schema/connectors/apple-business-manager/provenance.json +233 -0
  163. gatewaysdk/environment/schema/connectors/apple-business-manager/scope.toml +112 -0
  164. gatewaysdk/environment/schema/connectors/apple-business-manager/world.json +1380 -0
  165. gatewaysdk/environment/schema/connectors/base.json +28 -0
  166. gatewaysdk/environment/schema/connectors/custom/handlers.py +18 -0
  167. gatewaysdk/environment/schema/connectors/custom/world.json +123 -0
  168. gatewaysdk/environment/schema/connectors/github/handlers.py +81 -0
  169. gatewaysdk/environment/schema/connectors/github/parity.json +37 -0
  170. gatewaysdk/environment/schema/connectors/github/provenance.json +134 -0
  171. gatewaysdk/environment/schema/connectors/github/scope.toml +158 -0
  172. gatewaysdk/environment/schema/connectors/github/world.json +5869 -0
  173. gatewaysdk/environment/schema/connectors/google-calendar/handlers.py +23 -0
  174. gatewaysdk/environment/schema/connectors/google-calendar/world.json +209 -0
  175. gatewaysdk/environment/schema/connectors/google-drive/handlers.py +18 -0
  176. gatewaysdk/environment/schema/connectors/google-drive/world.json +286 -0
  177. gatewaysdk/environment/schema/connectors/jamf/handlers.py +34 -0
  178. gatewaysdk/environment/schema/connectors/jamf/parity.json +49 -0
  179. gatewaysdk/environment/schema/connectors/jamf/provenance.json +181 -0
  180. gatewaysdk/environment/schema/connectors/jamf/scope.toml +128 -0
  181. gatewaysdk/environment/schema/connectors/jamf/world.json +2218 -0
  182. gatewaysdk/environment/schema/connectors/jira/handlers.py +28 -0
  183. gatewaysdk/environment/schema/connectors/jira/world.json +389 -0
  184. gatewaysdk/environment/schema/connectors/kandji/handlers.py +38 -0
  185. gatewaysdk/environment/schema/connectors/kandji/parity.json +29 -0
  186. gatewaysdk/environment/schema/connectors/kandji/provenance.json +152 -0
  187. gatewaysdk/environment/schema/connectors/kandji/scope.toml +120 -0
  188. gatewaysdk/environment/schema/connectors/kandji/world.json +765 -0
  189. gatewaysdk/environment/schema/connectors/linear/handlers.py +20 -0
  190. gatewaysdk/environment/schema/connectors/linear/world.json +363 -0
  191. gatewaysdk/environment/schema/connectors/microsoft-teams/fidelity.json +45 -0
  192. gatewaysdk/environment/schema/connectors/microsoft-teams/handlers.py +1 -0
  193. gatewaysdk/environment/schema/connectors/microsoft-teams/provenance.json +851 -0
  194. gatewaysdk/environment/schema/connectors/microsoft-teams/scope.md +231 -0
  195. gatewaysdk/environment/schema/connectors/microsoft-teams/teams-types.json +1710 -0
  196. gatewaysdk/environment/schema/connectors/microsoft-teams/world.json +858 -0
  197. gatewaysdk/environment/schema/connectors/netsuite/handlers.py +32 -0
  198. gatewaysdk/environment/schema/connectors/netsuite/parity.json +50 -0
  199. gatewaysdk/environment/schema/connectors/netsuite/provenance.json +137 -0
  200. gatewaysdk/environment/schema/connectors/netsuite/scope.toml +78 -0
  201. gatewaysdk/environment/schema/connectors/netsuite/world.json +16243 -0
  202. gatewaysdk/environment/schema/connectors/salesforce/handlers.py +806 -0
  203. gatewaysdk/environment/schema/connectors/salesforce/parity.json +46 -0
  204. gatewaysdk/environment/schema/connectors/salesforce/provenance.json +143 -0
  205. gatewaysdk/environment/schema/connectors/salesforce/scope.toml +174 -0
  206. gatewaysdk/environment/schema/connectors/salesforce/world.json +4695 -0
  207. gatewaysdk/environment/schema/connectors/slack/MODEL.md +236 -0
  208. gatewaysdk/environment/schema/connectors/slack/capabilities.json +1039 -0
  209. gatewaysdk/environment/schema/connectors/slack/handlers.py +762 -0
  210. gatewaysdk/environment/schema/connectors/slack/parity.json +65 -0
  211. gatewaysdk/environment/schema/connectors/slack/provenance.json +294 -0
  212. gatewaysdk/environment/schema/connectors/slack/scope.toml +44 -0
  213. gatewaysdk/environment/schema/connectors/slack/world.json +4993 -0
  214. gatewaysdk/environment/schema/connectors/workday/handlers.py +75 -0
  215. gatewaysdk/environment/schema/connectors/workday/parity.json +36 -0
  216. gatewaysdk/environment/schema/connectors/workday/provenance.json +179 -0
  217. gatewaysdk/environment/schema/connectors/workday/scope.toml +180 -0
  218. gatewaysdk/environment/schema/connectors/workday/world.json +806 -0
  219. gatewaysdk/environment/schema/data.py +593 -0
  220. gatewaysdk/environment/schema/extract.py +263 -0
  221. gatewaysdk/environment/schema/host.py +2437 -0
  222. gatewaysdk/environment/schema/host_surfaces.py +275 -0
  223. gatewaysdk/environment/schema/platform.py +1272 -0
  224. gatewaysdk/environment/schema/scaffold.py +250 -0
  225. gatewaysdk/environment/schema/skill.py +118 -0
  226. gatewaysdk/environment/schema/skills/agents/connector-template-author.md +202 -0
  227. gatewaysdk/environment/schema/skills/connector-schema-authoring/CONNECTORS.md +331 -0
  228. gatewaysdk/environment/schema/skills/connector-schema-authoring/CONVENTIONS.md +100 -0
  229. gatewaysdk/environment/schema/skills/connector-schema-authoring/SKILL.md +313 -0
  230. gatewaysdk/environment/schema/skills/world-data-ingestion/ROWS.md +223 -0
  231. gatewaysdk/environment/schema/skills/world-data-ingestion/SKILL.md +293 -0
  232. gatewaysdk/environment/schema/skills/worlds-getting-started/SKILL.md +390 -0
  233. gatewaysdk/environment/schema/slack.py +757 -0
  234. gatewaysdk/environment/schema/snapshot.py +479 -0
  235. gatewaysdk/environment/schema/store.py +904 -0
  236. gatewaysdk/environment/schema/tasks.py +525 -0
  237. gatewaysdk/environment/schema/tools_world.py +322 -0
  238. gatewaysdk/environment/schema/validation.py +577 -0
  239. gatewaysdk/environment/schema/worker.py +78 -0
  240. gatewaysdk/environment/schema/workers.py +500 -0
  241. gatewaysdk/environment/schema/world_tests.py +336 -0
  242. gatewaysdk/environments/__init__.py +24 -0
  243. gatewaysdk/environments/client.py +486 -0
  244. gatewaysdk/environments/types.py +318 -0
  245. gatewaysdk/execution/__init__.py +15 -0
  246. gatewaysdk/execution/base.py +64 -0
  247. gatewaysdk/execution/client_server.py +443 -0
  248. gatewaysdk/execution/events.py +69 -0
  249. gatewaysdk/execution/inter_process.py +16 -0
  250. gatewaysdk/execution/shared_memory.py +282 -0
  251. gatewaysdk/experiments/__init__.py +90 -0
  252. gatewaysdk/experiments/assignment.py +177 -0
  253. gatewaysdk/experiments/client.py +877 -0
  254. gatewaysdk/experiments/exposure.py +222 -0
  255. gatewaysdk/experiments/types.py +81 -0
  256. gatewaysdk/importers/__init__.py +49 -0
  257. gatewaysdk/importers/_normalize.py +91 -0
  258. gatewaysdk/importers/builder.py +173 -0
  259. gatewaysdk/importers/client.py +95 -0
  260. gatewaysdk/importers/langsmith.py +196 -0
  261. gatewaysdk/importers/recipes.py +249 -0
  262. gatewaysdk/instrumentation/__init__.py +300 -0
  263. gatewaysdk/instrumentation/agentops.py +314 -0
  264. gatewaysdk/instrumentation/agentops_langchain.py +45 -0
  265. gatewaysdk/instrumentation/base.py +119 -0
  266. gatewaysdk/instrumentation/litellm.py +83 -0
  267. gatewaysdk/instrumentation/registry.py +273 -0
  268. gatewaysdk/instrumentation/vllm.py +81 -0
  269. gatewaysdk/instrumentation/weave.py +500 -0
  270. gatewaysdk/integrations/__init__.py +15 -0
  271. gatewaysdk/integrations/gateway/__init__.py +11 -0
  272. gatewaysdk/integrations/gateway/client.py +171 -0
  273. gatewaysdk/integrations/tool_access.py +549 -0
  274. gatewaysdk/litagent/__init__.py +11 -0
  275. gatewaysdk/litagent/decorator.py +536 -0
  276. gatewaysdk/litagent/litagent.py +252 -0
  277. gatewaysdk/llm_proxy.py +1742 -0
  278. gatewaysdk/logging.py +370 -0
  279. gatewaysdk/memory.py +278 -0
  280. gatewaysdk/personas.py +186 -0
  281. gatewaysdk/platform/__init__.py +17 -0
  282. gatewaysdk/platform/builder.py +221 -0
  283. gatewaysdk/platform/compatibility.py +67 -0
  284. gatewaysdk/platform/manifest.py +185 -0
  285. gatewaysdk/platform/orchestrator.py +901 -0
  286. gatewaysdk/platform/registry.py +122 -0
  287. gatewaysdk/platform/worker.py +864 -0
  288. gatewaysdk/replay/__init__.py +1032 -0
  289. gatewaysdk/replay/pytest.py +56 -0
  290. gatewaysdk/reward.py +7 -0
  291. gatewaysdk/run.py +1781 -0
  292. gatewaysdk/runner/__init__.py +11 -0
  293. gatewaysdk/runner/agent.py +878 -0
  294. gatewaysdk/runner/base.py +182 -0
  295. gatewaysdk/runner/legacy.py +309 -0
  296. gatewaysdk/security.py +700 -0
  297. gatewaysdk/semconv.py +170 -0
  298. gatewaysdk/server.py +399 -0
  299. gatewaysdk/sessions.py +282 -0
  300. gatewaysdk/store/__init__.py +45 -0
  301. gatewaysdk/store/base.py +908 -0
  302. gatewaysdk/store/client_server.py +2093 -0
  303. gatewaysdk/store/collection/__init__.py +30 -0
  304. gatewaysdk/store/collection/base.py +587 -0
  305. gatewaysdk/store/collection/memory.py +970 -0
  306. gatewaysdk/store/collection/mongo.py +1412 -0
  307. gatewaysdk/store/collection_based.py +1823 -0
  308. gatewaysdk/store/gateway.py +983 -0
  309. gatewaysdk/store/gateway_listener.py +465 -0
  310. gatewaysdk/store/listener.py +58 -0
  311. gatewaysdk/store/memory.py +396 -0
  312. gatewaysdk/store/mongo.py +165 -0
  313. gatewaysdk/store/redis_stream.py +517 -0
  314. gatewaysdk/store/sqlite.py +3 -0
  315. gatewaysdk/store/threading.py +370 -0
  316. gatewaysdk/store/utils.py +142 -0
  317. gatewaysdk/tracer/__init__.py +14 -0
  318. gatewaysdk/tracer/base.py +286 -0
  319. gatewaysdk/tracer/dummy.py +106 -0
  320. gatewaysdk/tracer/otel.py +559 -0
  321. gatewaysdk/tracing/__init__.py +110 -0
  322. gatewaysdk/tracing/api.py +808 -0
  323. gatewaysdk/tracing/attributes.py +9 -0
  324. gatewaysdk/tracing/context.py +272 -0
  325. gatewaysdk/tracing/exporters/__init__.py +10 -0
  326. gatewaysdk/tracing/exporters/gateway.py +228 -0
  327. gatewaysdk/tracing/identity.py +288 -0
  328. gatewaysdk/tracing/init.py +620 -0
  329. gatewaysdk/tracing/instrumentors/__init__.py +15 -0
  330. gatewaysdk/tracing/instrumentors/claude_agent_sdk.py +766 -0
  331. gatewaysdk/tracing/instrumentors/instrumentation_principles.md +294 -0
  332. gatewaysdk/tracing/instrumentors/registry.py +352 -0
  333. gatewaysdk/tracing/mapping.py +729 -0
  334. gatewaysdk/tracing/processors.py +393 -0
  335. gatewaysdk/tracing/push.py +617 -0
  336. gatewaysdk/tracing/push_models.py +247 -0
  337. gatewaysdk/tracing/semconv.py +294 -0
  338. gatewaysdk/tracing/span_builder.py +356 -0
  339. gatewaysdk/trainer/__init__.py +6 -0
  340. gatewaysdk/trainer/init_utils.py +263 -0
  341. gatewaysdk/trainer/legacy.py +359 -0
  342. gatewaysdk/trainer/registry.py +12 -0
  343. gatewaysdk/trainer/trainer.py +638 -0
  344. gatewaysdk/types/__init__.py +63 -0
  345. gatewaysdk/types/core.py +556 -0
  346. gatewaysdk/types/resources.py +204 -0
  347. gatewaysdk/types/tracer.py +515 -0
  348. gatewaysdk/types/tracing.py +162 -0
  349. gatewaysdk/users.py +251 -0
  350. gatewaysdk/utils/__init__.py +1 -0
  351. gatewaysdk/utils/id.py +18 -0
  352. gatewaysdk/utils/metrics.py +1025 -0
  353. gatewaysdk/utils/otel.py +550 -0
  354. gatewaysdk/utils/otlp.py +556 -0
  355. gatewaysdk/utils/redact.py +22 -0
  356. gatewaysdk/utils/server_launcher.py +1045 -0
  357. gatewaysdk/utils/system_snapshot.py +90 -0
  358. gatewaysdk/verl/__init__.py +8 -0
  359. gatewaysdk/verl/__main__.py +6 -0
  360. gatewaysdk/verl/async_server.py +46 -0
  361. gatewaysdk/verl/config.yaml +27 -0
  362. gatewaysdk/verl/daemon.py +1154 -0
  363. gatewaysdk/verl/dataset.py +44 -0
  364. gatewaysdk/verl/entrypoint.py +248 -0
  365. gatewaysdk/verl/trainer.py +549 -0
  366. gatewaysdk/world_browser.py +748 -0
  367. gatewaysdk/world_data.py +453 -0
  368. gatewaysdk/world_sessions.py +978 -0
  369. gatewaysdk/world_tasks.py +285 -0
  370. gatewaysdk/world_tools.py +115 -0
  371. gatewaysdk/worlds.py +639 -0
  372. gatewaysdk-0.3.2.dist-info/METADATA +236 -0
  373. gatewaysdk-0.3.2.dist-info/RECORD +376 -0
  374. gatewaysdk-0.3.2.dist-info/WHEEL +4 -0
  375. gatewaysdk-0.3.2.dist-info/entry_points.txt +5 -0
  376. gatewaysdk-0.3.2.dist-info/licenses/LICENSE +19 -0
gatewaysdk/run.py ADDED
@@ -0,0 +1,1781 @@
1
+ # Copyright (c) Gateway. All rights reserved.
2
+
3
+ """Gateway Runs - Flexible metric tracking for experiments.
4
+
5
+ This module provides wandb-style experiment tracking with automatic crash detection
6
+ via heartbeats, signal handling, and context managers.
7
+
8
+ Example usage::
9
+
10
+ import gatewaysdk
11
+
12
+ # Recommended: Context manager (handles crashes automatically)
13
+ with gatewaysdk.run("experiment-v1", config={"lr": 0.01}) as run:
14
+ for epoch in range(100):
15
+ loss = train()
16
+ run.log({"loss": loss, "accuracy": acc}, step=epoch)
17
+
18
+ # Alternative: Manual lifecycle (must call finish!)
19
+ run = gatewaysdk.init_run("experiment-v1")
20
+ run.log({"metric": value})
21
+ run.finish()
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import atexit
28
+ import functools
29
+ import hashlib
30
+ import json
31
+ import logging
32
+ import os
33
+ import signal
34
+ import subprocess
35
+ import sys
36
+ import threading
37
+ import time
38
+ import uuid
39
+ from contextlib import contextmanager
40
+ from contextvars import ContextVar
41
+ from dataclasses import dataclass, field
42
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, TypeVar, Union
43
+
44
+ if TYPE_CHECKING:
45
+ from gatewaysdk.store.listener import StorageListener
46
+
47
+ import requests
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ # Type variable for decorators
52
+ F = TypeVar("F", bound=Callable[..., Any])
53
+
54
+
55
+ # =============================================================================
56
+ # Reproducibility Helpers
57
+ # =============================================================================
58
+
59
+
60
+ def _compute_config_hash(config: Optional[Dict[str, Any]]) -> Optional[str]:
61
+ """SHA256 hash of config for detecting identical configs.
62
+
63
+ Returns first 16 characters of the hash for brevity.
64
+ """
65
+ if not config:
66
+ return None
67
+ try:
68
+ config_str = json.dumps(config, sort_keys=True, default=str)
69
+ return hashlib.sha256(config_str.encode()).hexdigest()[:16]
70
+ except Exception:
71
+ return None
72
+
73
+
74
+ def _get_git_info() -> Dict[str, Any]:
75
+ """Capture git commit, branch, and dirty state.
76
+
77
+ Returns empty dict if not in a git repo or git is not available.
78
+ """
79
+ try:
80
+ # Get commit SHA (first 12 chars)
81
+ sha = (
82
+ subprocess.check_output(
83
+ ["git", "rev-parse", "HEAD"],
84
+ stderr=subprocess.DEVNULL,
85
+ timeout=5,
86
+ )
87
+ .decode()
88
+ .strip()[:12]
89
+ )
90
+
91
+ # Get branch name
92
+ branch = (
93
+ subprocess.check_output(
94
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
95
+ stderr=subprocess.DEVNULL,
96
+ timeout=5,
97
+ )
98
+ .decode()
99
+ .strip()
100
+ )
101
+
102
+ # Check if working directory is dirty
103
+ dirty = (
104
+ subprocess.call(
105
+ ["git", "diff", "--quiet"],
106
+ stderr=subprocess.DEVNULL,
107
+ timeout=5,
108
+ )
109
+ != 0
110
+ )
111
+
112
+ return {"gitSha": sha, "gitBranch": branch, "gitDirty": dirty}
113
+ except Exception:
114
+ return {}
115
+
116
+
117
+ # =============================================================================
118
+ # Types
119
+ # =============================================================================
120
+
121
+ RunState = Literal["pending", "running", "completed", "failed", "cancelled", "crashed"]
122
+
123
+
124
+ class GatewayRunError(RuntimeError):
125
+ """Run creation or completion could not satisfy its durability contract."""
126
+
127
+
128
+ # =============================================================================
129
+ # Constants
130
+ # =============================================================================
131
+
132
+ MAX_RUN_NAME_LENGTH = 256
133
+ MAX_METRIC_BUFFER_SIZE = 10000
134
+
135
+
136
+ @dataclass
137
+ class Metric:
138
+ """A self-describing metric with metadata."""
139
+
140
+ name: str
141
+ value: float
142
+ step: Optional[int] = None
143
+ unit: str = ""
144
+ metric_type: str = "gauge"
145
+ labels: Dict[str, str] = field(default_factory=dict)
146
+
147
+
148
+ # =============================================================================
149
+ # Run and Rollout Context Management
150
+ # =============================================================================
151
+
152
+ _current_run: ContextVar[Optional["Run"]] = ContextVar("gateway_current_run", default=None)
153
+ _current_rollout: ContextVar[Optional["Rollout"]] = ContextVar("gateway_current_rollout", default=None)
154
+
155
+
156
+ def get_current_run() -> Optional["Run"]:
157
+ """Get the currently active run, if any."""
158
+ return _current_run.get()
159
+
160
+
161
+ def _set_current_run(run: Optional["Run"]) -> None:
162
+ """Set the current active run."""
163
+ _current_run.set(run)
164
+
165
+
166
+ def get_current_rollout() -> Optional["Rollout"]:
167
+ """Get the currently active rollout (episode), if any."""
168
+ return _current_rollout.get()
169
+
170
+
171
+ def _set_current_rollout(rollout: Optional["Rollout"]) -> None:
172
+ """Set the current active rollout."""
173
+ _current_rollout.set(rollout)
174
+
175
+
176
+ # =============================================================================
177
+ # Rollout Class
178
+ # =============================================================================
179
+
180
+
181
+ class Rollout:
182
+ """A Rollout/Episode within a Gateway Run.
183
+
184
+ Represents a single episode (multi-turn interaction) within a larger
185
+ experiment (Run). Each rollout gets its own session_id that is injected
186
+ into traces created within its context.
187
+
188
+ The trace_id is captured automatically by TracingContextSpanProcessor
189
+ when the first span starts within this rollout. This allows score()
190
+ to link scores to the correct trace even after instrumented spans end.
191
+
192
+ Usage::
193
+
194
+ with gatewaysdk.run("gepa-v1") as run:
195
+ for candidate in population:
196
+ with run.rollout(f"candidate-{candidate.id}") as ep:
197
+ result = agent.execute(task)
198
+ ep.score("rubric", grade)
199
+ # Traces get: run_id="run-xxx", session_id="ep-yyy"
200
+ """
201
+
202
+ def __init__(
203
+ self,
204
+ run: "Run",
205
+ name: Optional[str] = None,
206
+ ):
207
+ """Initialize a Rollout.
208
+
209
+ Args:
210
+ run: The parent Run instance.
211
+ name: Optional human-readable name for this rollout.
212
+ """
213
+ self._run = run
214
+ self._name = name
215
+ self._session_id = f"ep-{uuid.uuid4().hex[:8]}"
216
+ self._trace_id: Optional[str] = None
217
+ self._started = False
218
+ self._finished = False
219
+
220
+ @property
221
+ def session_id(self) -> str:
222
+ """Get the session ID for this rollout."""
223
+ return self._session_id
224
+
225
+ @property
226
+ def trace_id(self) -> Optional[str]:
227
+ """Get the trace ID captured from the first span in this rollout."""
228
+ return self._trace_id
229
+
230
+ @property
231
+ def name(self) -> Optional[str]:
232
+ """Get the rollout name."""
233
+ return self._name
234
+
235
+ @property
236
+ def run(self) -> "Run":
237
+ """Get the parent run."""
238
+ return self._run
239
+
240
+ def _capture_trace_id(self, trace_id: str) -> None:
241
+ """Store the trace_id from the first span in this rollout.
242
+
243
+ Called by TracingContextSpanProcessor.on_start() when a span
244
+ starts within this rollout's context.
245
+ """
246
+ if self._trace_id is None:
247
+ self._trace_id = trace_id
248
+
249
+ def __enter__(self) -> "Rollout":
250
+ """Context manager entry - set this as the current rollout."""
251
+ self._started = True
252
+ _set_current_rollout(self)
253
+ logger.debug(f"Started rollout: {self._name or self._session_id}")
254
+ return self
255
+
256
+ def score(
257
+ self,
258
+ name: str,
259
+ value: Union[float, str],
260
+ **kwargs,
261
+ ) -> None:
262
+ """Post a score linked to this rollout's session and trace.
263
+
264
+ Convenience shorthand that auto-fills session_id and trace_id
265
+ from this rollout. See :meth:`Run.score` for full documentation.
266
+
267
+ Args:
268
+ name: Score name (e.g., "rubric", "accuracy").
269
+ value: Score value.
270
+ **kwargs: Additional arguments passed to Run.score().
271
+
272
+ Example::
273
+
274
+ with run.rollout("task-1") as rollout:
275
+ result = await run_agent(task)
276
+ rollout.score("rubric", 0.85)
277
+ """
278
+ # Use the trace_id captured from spans within this rollout
279
+ if "trace_id" not in kwargs and self._trace_id:
280
+ kwargs["trace_id"] = self._trace_id
281
+ self._run.score(name, value, session_id=self._session_id, **kwargs)
282
+
283
+ def success(
284
+ self,
285
+ name: str,
286
+ value: Union[bool, float] = True,
287
+ **kwargs,
288
+ ) -> None:
289
+ """Emit a success signal linked to this rollout's session and trace.
290
+
291
+ Convenience shorthand; see :meth:`Run.success` for full documentation.
292
+ """
293
+ if "trace_id" not in kwargs and self._trace_id:
294
+ kwargs["trace_id"] = self._trace_id
295
+ self._run.success(name, value, session_id=self._session_id, **kwargs)
296
+
297
+ def __exit__(self, exc_type, exc_val, exc_tb):
298
+ """Context manager exit - clear the current rollout."""
299
+ _set_current_rollout(None)
300
+ self._finished = True
301
+ if exc_type is not None:
302
+ logger.debug(f"Rollout {self._session_id} exited with exception: {exc_val}")
303
+ else:
304
+ logger.debug(f"Rollout {self._session_id} completed")
305
+ return False # Don't suppress exceptions
306
+
307
+
308
+ # =============================================================================
309
+ # Run Class
310
+ # =============================================================================
311
+
312
+
313
+ class Run:
314
+ """A Gateway Run for tracking metrics with automatic heartbeat and crash detection.
315
+
316
+ Features:
317
+ - Automatic heartbeat to detect crashed runs
318
+ - Signal handlers for SIGINT/SIGTERM (Ctrl+C)
319
+ - Context manager support with exception handling
320
+ - atexit handler for scripts that exit without calling finish()
321
+ - Automatic trace linkage via session_id
322
+ """
323
+
324
+ def __init__(
325
+ self,
326
+ name: Optional[str] = None,
327
+ *,
328
+ project_id: Optional[str] = None,
329
+ heartbeat_interval: int = 30,
330
+ source: str = "sdk",
331
+ labels: Optional[Dict[str, Any]] = None,
332
+ config: Optional[Dict[str, Any]] = None,
333
+ tags: Optional[List[str]] = None,
334
+ api_url: Optional[str] = None,
335
+ api_key: Optional[str] = None,
336
+ listener: Optional["StorageListener"] = None,
337
+ ):
338
+ """Initialize a Run.
339
+
340
+ Args:
341
+ name: Human-readable name for the run.
342
+ project_id: Project ID (from env GATEWAY_PROJECT_ID if not provided).
343
+ heartbeat_interval: Seconds between heartbeats (default: 30).
344
+ source: Source identifier (e.g., "gepa", "trainer", "notebook").
345
+ labels: Arbitrary key-value labels for filtering.
346
+ config: Hyperparameters/config dict (logged for reproducibility).
347
+ tags: Simple string tags for categorization.
348
+ api_url: API URL (from env GATEWAY_HOST if not provided).
349
+ api_key: API key (from env GATEWAY_SECRET_KEY if not provided).
350
+ listener: Optional StorageListener for GEPA/optimization callbacks.
351
+
352
+ Note:
353
+ Runs can have MANY sessions (episodes/rollouts). Use run.rollout()
354
+ to create individual episodes within a run.
355
+ """
356
+ # Validate and truncate name if too long
357
+ if name and len(name) > MAX_RUN_NAME_LENGTH:
358
+ logger.warning(f"Run name truncated from {len(name)} to {MAX_RUN_NAME_LENGTH} characters")
359
+ name = name[:MAX_RUN_NAME_LENGTH]
360
+ self._name = name or f"run-{uuid.uuid4().hex[:8]}"
361
+ self._project_id = project_id or os.environ.get("GATEWAY_PROJECT_ID")
362
+ self._heartbeat_interval = heartbeat_interval
363
+ self._source = source
364
+ self._labels = labels or {}
365
+ self._config = config
366
+ self._tags = tags or []
367
+
368
+ # API configuration
369
+ self._api_url = api_url or os.environ.get("GATEWAY_HOST", "http://localhost:3000")
370
+ self._api_key = api_key or os.environ.get("GATEWAY_SECRET_KEY")
371
+ # Store public key for auth (read from env since it's set by GatewayLightningStore)
372
+ self._public_key = os.environ.get("GATEWAY_PUBLIC_KEY", "")
373
+
374
+ # Internal state
375
+ self._run_id: Optional[str] = None
376
+ self._run_number: int = 0
377
+ self._state: RunState = "pending"
378
+ self._heartbeat_thread: Optional[threading.Thread] = None
379
+ self._stop_heartbeat = threading.Event()
380
+ self._flush_ticker_thread: Optional[threading.Thread] = None
381
+ self._stop_flush_ticker = threading.Event()
382
+ self._finished = False
383
+ self._lock = threading.Lock()
384
+ self._score_threads: List[threading.Thread] = []
385
+ self._score_delivery_failures: List[str] = []
386
+ self._last_finish_error: Optional[str] = None
387
+ self._last_finish_exception: Optional[BaseException] = None
388
+
389
+ # Signal handling state
390
+ self._original_sigint: Optional[Callable] = None
391
+ self._original_sigterm: Optional[Callable] = None
392
+
393
+ # Metric buffer for batching
394
+ self._metric_buffer: List[Dict[str, Any]] = []
395
+ self._metric_buffer_lock = threading.Lock()
396
+ self._metric_flush_lock = threading.Lock()
397
+ self._dropped_metric_count = 0
398
+ self._last_flush_time = time.time()
399
+ self._flush_interval = 1.0 # Flush every 1 second for live updates
400
+
401
+ # Optional listener for GEPA/optimization callbacks
402
+ self._listener: Optional["StorageListener"] = listener
403
+
404
+ # Cancellation callback -- set by Trainer/algorithm to propagate
405
+ # remote cancellation (from UI "Cancel Run") to the algorithm.
406
+ self._on_cancel: Optional[Callable[[], None]] = None
407
+
408
+ @property
409
+ def id(self) -> Optional[str]:
410
+ """Get the run ID."""
411
+ return self._run_id
412
+
413
+ @property
414
+ def run_number(self) -> int:
415
+ """Get the auto-incrementing run number for this project."""
416
+ return self._run_number
417
+
418
+ @property
419
+ def name(self) -> str:
420
+ """Get the run name."""
421
+ return self._name
422
+
423
+ @property
424
+ def state(self) -> RunState:
425
+ """Get the current run state."""
426
+ return self._state
427
+
428
+ @property
429
+ def project_id(self) -> Optional[str]:
430
+ """Get the project ID."""
431
+ return self._project_id
432
+
433
+ @property
434
+ def listener(self) -> Optional["StorageListener"]:
435
+ """Get the attached listener, if any."""
436
+ return self._listener
437
+
438
+ def _notify_listener(self, method_name: str, *args: Any, **kwargs: Any) -> None:
439
+ """Notify the listener of an event (synchronous wrapper for async methods).
440
+
441
+ This method safely calls listener methods, handling both sync and async cases.
442
+ Exceptions are logged but not raised to avoid disrupting the main flow.
443
+ """
444
+ if self._listener is None:
445
+ return
446
+ try:
447
+ method = getattr(self._listener, method_name, None)
448
+ if method is not None:
449
+ import asyncio
450
+
451
+ if asyncio.iscoroutinefunction(method):
452
+ # Run async method in a new event loop if needed
453
+ try:
454
+ loop = asyncio.get_running_loop()
455
+ # Already in an async context, schedule it
456
+ asyncio.create_task(method(*args, **kwargs))
457
+ except RuntimeError:
458
+ # No running loop, run synchronously
459
+ asyncio.run(method(*args, **kwargs))
460
+ else:
461
+ method(*args, **kwargs)
462
+ except Exception as e:
463
+ logger.warning(f"Listener {method_name} failed: {e}")
464
+
465
+ # =========================================================================
466
+ # Rollout/Episode Methods
467
+ # =========================================================================
468
+
469
+ def rollout(self, name: Optional[str] = None) -> Rollout:
470
+ """Create a new rollout (episode) within this run.
471
+
472
+ A rollout represents a single episode or interaction sequence.
473
+ Traces created within the rollout context get both run_id and
474
+ session_id attributes for proper grouping.
475
+
476
+ Usage::
477
+
478
+ with gatewaysdk.run("gepa-v1") as run:
479
+ for candidate in population:
480
+ with run.rollout(f"candidate-{candidate.id}") as ep:
481
+ result = agent.execute(task)
482
+ # Traces get: run_id="run-xxx", session_id="ep-yyy"
483
+
484
+ Args:
485
+ name: Optional human-readable name for this rollout.
486
+
487
+ Returns:
488
+ A Rollout context manager.
489
+ """
490
+ return Rollout(run=self, name=name)
491
+
492
+ # =========================================================================
493
+ # Lifecycle Methods
494
+ # =========================================================================
495
+
496
+ def _auto_init_tracing(self) -> None:
497
+ """Auto-initialize tracing if not already done and Gateway env vars are set.
498
+
499
+ This enables the "Runs standalone" experience: when a user creates a Run,
500
+ tracing is automatically set up so all LLM calls within the run context
501
+ are traced and exported to Gateway, linked to this run via run_id.
502
+
503
+ Only initializes if:
504
+ - Tracing is not already initialized
505
+ - GATEWAY_HOST or GATEWAY_PUBLIC_KEY env vars are configured
506
+ """
507
+ try:
508
+ from gatewaysdk.tracing.init import _initialized as tracing_initialized
509
+ from gatewaysdk.tracing.init import init as tracing_init
510
+
511
+ if tracing_initialized:
512
+ logger.debug("Tracing already initialized, skipping auto-init")
513
+ return
514
+
515
+ # Only auto-init if Gateway env vars are present (somewhere to send spans)
516
+ has_gateway_host = bool(os.environ.get("GATEWAY_HOST"))
517
+ has_gateway_keys = bool(os.environ.get("GATEWAY_PUBLIC_KEY") and os.environ.get("GATEWAY_SECRET_KEY"))
518
+
519
+ if has_gateway_host or has_gateway_keys:
520
+ logger.info("Auto-initializing tracing from msk.run() (Gateway env vars detected)")
521
+ tracing_init(service_name=self._source or "gatewaysdk")
522
+ else:
523
+ logger.debug("Skipping tracing auto-init: no Gateway env vars configured")
524
+
525
+ except Exception as e:
526
+ logger.warning(f"Failed to auto-init tracing: {e}")
527
+
528
+ def _start(self) -> "Run":
529
+ """Initialize the run on the backend and start heartbeat.
530
+
531
+ Automatically initializes tracing if not already done and Gateway
532
+ environment variables are configured. This ensures that traces created
533
+ within this run are exported to Gateway and linked via run_id.
534
+ """
535
+ if not self._project_id:
536
+ raise ValueError(
537
+ "project_id is required. Set GATEWAY_PROJECT_ID environment variable "
538
+ "or pass project_id to init_run()."
539
+ )
540
+
541
+ # Create run via API (skip if run_id is pre-set, e.g., platform runs)
542
+ if self._run_id is None:
543
+ run_id, run_number = self._create_run_on_backend()
544
+ self._run_id = run_id
545
+ self._run_number = run_number
546
+ else:
547
+ logger.info(f"Reusing existing run {self._run_id} (pre-set)")
548
+
549
+ # Backend identity is the activation commit point. Do not initialize
550
+ # tracing or install lifecycle side effects before it is acquired.
551
+ self._auto_init_tracing()
552
+ self._state = "running"
553
+
554
+ # Set as current run (for trace context)
555
+ _set_current_run(self)
556
+
557
+ # Start heartbeat thread
558
+ self._start_heartbeat_loop()
559
+
560
+ # Start flush ticker for live metric updates (every 1s)
561
+ self._start_flush_ticker()
562
+
563
+ # Register signal handlers
564
+ self._register_signal_handlers()
565
+
566
+ # Register atexit handler
567
+ atexit.register(self._atexit_handler)
568
+
569
+ # Print run URL for convenience
570
+ run_url = f"{self._api_url}/project/{self._project_id}/runs/{self._run_id}"
571
+ logger.info(f"Gateway Run #{self._run_number} started: {run_url}")
572
+
573
+ return self
574
+
575
+ def _create_run_on_backend(self) -> tuple[str, int]:
576
+ """Create run record on backend, return (run_id, run_number)."""
577
+ if not self._public_key:
578
+ raise GatewayRunError("Run create failed: GATEWAY_PUBLIC_KEY is missing or empty")
579
+
580
+ if not self._api_key:
581
+ raise GatewayRunError("Run create failed: GATEWAY_SECRET_KEY (API secret key) is missing or empty")
582
+
583
+ try:
584
+ # Ensure config is always a dict (API rejects null)
585
+ config = self._config if self._config is not None else {}
586
+
587
+ # Auto-capture reproducibility info
588
+ config_hash = _compute_config_hash(config) or ""
589
+ git_info = _get_git_info()
590
+
591
+ url = f"{self._api_url}/api/public/runs"
592
+ headers = self._get_headers()
593
+ payload = {
594
+ "name": self._name,
595
+ "heartbeatInterval": self._heartbeat_interval,
596
+ "source": self._source,
597
+ "labels": self._labels or {},
598
+ "config": config,
599
+ "tags": self._tags or [],
600
+ # Reproducibility tracking (API requires strings, not null)
601
+ "configHash": config_hash,
602
+ "gitSha": git_info.get("gitSha") or "",
603
+ "gitBranch": git_info.get("gitBranch") or "",
604
+ "gitDirty": git_info.get("gitDirty", False),
605
+ # SDK metadata
606
+ "sdkVersion": self._get_sdk_version(),
607
+ "clientId": str(uuid.uuid4()),
608
+ "hostname": self._get_hostname(),
609
+ }
610
+
611
+ logger.debug(f"Creating run at {url}")
612
+ response = requests.post(url, json=payload, headers=headers, timeout=30)
613
+
614
+ if not response.ok:
615
+ try:
616
+ error_body = response.json()
617
+ except Exception:
618
+ error_body = response.text
619
+ raise GatewayRunError(f"Run create failed with HTTP status {response.status_code}: {error_body}")
620
+
621
+ try:
622
+ data = response.json()
623
+ except Exception as error:
624
+ raise GatewayRunError(f"Run create failed: response JSON could not be decoded ({error})") from error
625
+
626
+ if not isinstance(data, dict):
627
+ raise GatewayRunError("Run create failed: response must be a JSON object")
628
+
629
+ run_id = data.get("runId")
630
+ if not isinstance(run_id, str) or not run_id:
631
+ raise GatewayRunError("Run create failed: response runId must be a non-empty string")
632
+
633
+ run_number = data.get("runNumber")
634
+ if isinstance(run_number, bool) or not isinstance(run_number, int) or run_number < 0:
635
+ raise GatewayRunError("Run create failed: response runNumber must be a non-negative integer")
636
+
637
+ logger.info(f"Created run {run_id} (#{run_number}) on backend")
638
+ return run_id, run_number
639
+
640
+ except GatewayRunError:
641
+ raise
642
+ except requests.exceptions.ConnectionError as error:
643
+ raise GatewayRunError(f"Run create failed: connection to {self._api_url} failed ({error})") from error
644
+ except requests.exceptions.Timeout as error:
645
+ raise GatewayRunError(f"Run create failed: request to {self._api_url} timed out ({error})") from error
646
+ except Exception as error:
647
+ raise GatewayRunError(f"Run create failed unexpectedly: {type(error).__name__}: {error}") from error
648
+
649
+ def _get_headers(self) -> Dict[str, str]:
650
+ """Get HTTP headers for API requests."""
651
+ import base64
652
+
653
+ headers = {"Content-Type": "application/json"}
654
+ if self._api_key and self._public_key:
655
+ # Use Basic auth with public_key:secret_key
656
+ credentials = base64.b64encode(f"{self._public_key}:{self._api_key}".encode()).decode()
657
+ headers["Authorization"] = f"Basic {credentials}"
658
+ elif self._api_key:
659
+ # Fallback: try reading public key from env (in case it was set after __init__)
660
+ public_key = os.environ.get("GATEWAY_PUBLIC_KEY", "")
661
+ if public_key:
662
+ credentials = base64.b64encode(f"{public_key}:{self._api_key}".encode()).decode()
663
+ headers["Authorization"] = f"Basic {credentials}"
664
+ else:
665
+ logger.warning("GATEWAY_PUBLIC_KEY not set - API requests will fail auth")
666
+ return headers
667
+
668
+ def _get_sdk_version(self) -> str:
669
+ """Get the gatewaysdk version."""
670
+ try:
671
+ import gatewaysdk
672
+
673
+ return getattr(gatewaysdk, "__version__", "unknown")
674
+ except Exception:
675
+ return "unknown"
676
+
677
+ def _get_hostname(self) -> str:
678
+ """Get the hostname."""
679
+ import socket
680
+
681
+ try:
682
+ return socket.gethostname()
683
+ except Exception:
684
+ return "unknown"
685
+
686
+ # =========================================================================
687
+ # Heartbeat
688
+ # =========================================================================
689
+
690
+ def _start_heartbeat_loop(self):
691
+ """Start background heartbeat thread."""
692
+ self._stop_heartbeat.clear()
693
+
694
+ def heartbeat_loop():
695
+ while not self._stop_heartbeat.wait(timeout=self._heartbeat_interval):
696
+ try:
697
+ self._send_heartbeat()
698
+ # Also flush metrics periodically
699
+ self._maybe_flush_metrics()
700
+ except Exception as e:
701
+ logger.warning(f"Heartbeat failed: {e}")
702
+
703
+ self._heartbeat_thread = threading.Thread(
704
+ target=heartbeat_loop,
705
+ name=f"gateway-heartbeat-{self._run_id}",
706
+ daemon=True,
707
+ )
708
+ self._heartbeat_thread.start()
709
+
710
+ def _send_heartbeat(self):
711
+ """Send heartbeat to backend and check for remote cancellation."""
712
+ if not self._run_id or self._run_id.startswith("local-"):
713
+ return
714
+
715
+ try:
716
+ response = requests.post(
717
+ f"{self._api_url}/api/public/runs/{self._run_id}/heartbeat",
718
+ headers=self._get_headers(),
719
+ timeout=10,
720
+ )
721
+ response.raise_for_status()
722
+
723
+ # Check if the run was cancelled via the UI.
724
+ try:
725
+ data = response.json()
726
+ if data.get("state") == "cancelled":
727
+ logger.info(f"Run {self._run_id} cancelled via UI")
728
+ self._fire_cancel_callback()
729
+ # Force-exit the entire process. The UI already set
730
+ # state=cancelled in Postgres so no finish request needed.
731
+ # os._exit() skips Python cleanup but that's intentional --
732
+ # we need to kill forked subprocesses (shm strategy) and
733
+ # can't use SIGINT from the heartbeat thread (deadlock).
734
+ import os as _os
735
+
736
+ _os._exit(130)
737
+ except (ValueError, KeyError):
738
+ pass # Response wasn't JSON or missing fields -- ignore
739
+ except Exception as e:
740
+ logger.warning(f"Heartbeat request failed: {e}")
741
+
742
+ # =========================================================================
743
+ # Remote cancellation
744
+ # =========================================================================
745
+
746
+ def set_on_cancel(self, callback: Callable[[], None]) -> None:
747
+ """Register a callback invoked when the run is cancelled remotely.
748
+
749
+ Typically set to ``algorithm.request_cancel`` so that a "Cancel Run"
750
+ click in the UI propagates to the running algorithm.
751
+ """
752
+ self._on_cancel = callback
753
+
754
+ def _fire_cancel_callback(self) -> None:
755
+ """Invoke the on_cancel callback if registered."""
756
+ cb = self._on_cancel
757
+ if cb is not None:
758
+ try:
759
+ cb()
760
+ except Exception as e:
761
+ logger.warning(f"on_cancel callback failed: {e}")
762
+
763
+ # =========================================================================
764
+ # Flush Ticker (for live metric updates)
765
+ # =========================================================================
766
+
767
+ def _start_flush_ticker(self):
768
+ """Start background flush ticker thread for live metric updates."""
769
+ self._stop_flush_ticker.clear()
770
+
771
+ def flush_ticker_loop():
772
+ while not self._stop_flush_ticker.wait(timeout=self._flush_interval):
773
+ try:
774
+ self._maybe_flush_metrics()
775
+ except Exception as e:
776
+ logger.debug(f"Flush ticker failed: {e}")
777
+
778
+ self._flush_ticker_thread = threading.Thread(
779
+ target=flush_ticker_loop,
780
+ name=f"gateway-flush-ticker-{self._run_id}",
781
+ daemon=True,
782
+ )
783
+ self._flush_ticker_thread.start()
784
+
785
+ # =========================================================================
786
+ # Signal Handling
787
+ # =========================================================================
788
+
789
+ def _register_signal_handlers(self):
790
+ """Register handlers for SIGINT/SIGTERM to cleanup on Ctrl+C."""
791
+
792
+ def signal_handler(signum, frame):
793
+ logger.info(f"Received signal {signum}, finishing run as cancelled")
794
+ self.finish(state="cancelled")
795
+
796
+ # Call original handler
797
+ if signum == signal.SIGINT and callable(self._original_sigint):
798
+ self._original_sigint(signum, frame)
799
+ elif signum == signal.SIGTERM and callable(self._original_sigterm):
800
+ self._original_sigterm(signum, frame)
801
+
802
+ sys.exit(128 + signum)
803
+
804
+ try:
805
+ self._original_sigint = signal.signal(signal.SIGINT, signal_handler)
806
+ self._original_sigterm = signal.signal(signal.SIGTERM, signal_handler)
807
+ except Exception as e:
808
+ # Signal handling may not work in all environments (e.g., threads)
809
+ logger.debug(f"Could not register signal handlers: {e}")
810
+
811
+ def _restore_signal_handlers(self):
812
+ """Restore original signal handlers."""
813
+ try:
814
+ if self._original_sigint is not None:
815
+ signal.signal(signal.SIGINT, self._original_sigint)
816
+ if self._original_sigterm is not None:
817
+ signal.signal(signal.SIGTERM, self._original_sigterm)
818
+ except Exception as e:
819
+ logger.debug(f"Could not restore signal handlers: {e}")
820
+
821
+ def _atexit_handler(self):
822
+ """Called on Python exit - mark as failed if not finished."""
823
+ if not self._finished:
824
+ logger.warning("Run not finished, marking as failed")
825
+ try:
826
+ self.finish(state="failed")
827
+ except Exception:
828
+ pass # Best effort
829
+
830
+ # =========================================================================
831
+ # Logging Methods
832
+ # =========================================================================
833
+
834
+ def log(
835
+ self,
836
+ metrics: Union[Dict[str, Any], str],
837
+ value: Optional[float] = None,
838
+ *,
839
+ step: Optional[int] = None,
840
+ labels: Optional[Dict[str, str]] = None,
841
+ unit: str = "",
842
+ metric_type: str = "gauge",
843
+ ):
844
+ """Log metrics for this run.
845
+
846
+ Can be called with a dict of metrics::
847
+
848
+ run.log({"loss": 0.5, "accuracy": 0.92}, step=100)
849
+
850
+ Or with a single metric name and value::
851
+
852
+ run.log("loss", 0.5, step=100, unit="", metric_type="gauge")
853
+
854
+ Args:
855
+ metrics: Dict of {metric_name: value} or single metric name.
856
+ value: Metric value (only when metrics is a string).
857
+ step: Training step/iteration number.
858
+ labels: Per-metric labels for filtering.
859
+ unit: Unit of measurement (e.g., "ms", "tokens", "%").
860
+ metric_type: Type hint for visualization ("gauge", "counter", "histogram").
861
+ """
862
+ with self._lock:
863
+ if self._finished:
864
+ logger.warning("Cannot log to finished run")
865
+ return
866
+
867
+ if isinstance(metrics, str):
868
+ # Single metric
869
+ if value is None:
870
+ raise ValueError("value is required when logging a single metric")
871
+ metric_list = [
872
+ {
873
+ "name": metrics,
874
+ "value": float(value),
875
+ "step": step,
876
+ "labels": labels or {},
877
+ "unit": unit,
878
+ "metricType": metric_type,
879
+ }
880
+ ]
881
+ else:
882
+ # Dict of metrics
883
+ metric_list = [
884
+ {
885
+ "name": name,
886
+ "value": float(val),
887
+ "step": step,
888
+ "labels": labels or {},
889
+ "unit": unit,
890
+ "metricType": metric_type,
891
+ }
892
+ for name, val in metrics.items()
893
+ ]
894
+
895
+ # Admission and buffering are one lifecycle transaction. A concurrent
896
+ # finish either observes this complete batch or rejects it above.
897
+ with self._lock:
898
+ if self._finished:
899
+ logger.warning("Cannot log to finished run")
900
+ return
901
+ with self._metric_buffer_lock:
902
+ self._metric_buffer.extend(metric_list)
903
+ should_flush = len(self._metric_buffer) >= 100
904
+
905
+ # Flush if buffer is large enough
906
+ if should_flush:
907
+ self._flush_metrics()
908
+
909
+ def log_metrics(self, metrics: List[Metric]):
910
+ """Log a batch of Metric objects.
911
+
912
+ Args:
913
+ metrics: List of Metric objects with full metadata.
914
+ """
915
+ for m in metrics:
916
+ self.log(
917
+ m.name,
918
+ m.value,
919
+ step=m.step,
920
+ labels=m.labels,
921
+ unit=m.unit,
922
+ metric_type=m.metric_type,
923
+ )
924
+
925
+ def _maybe_flush_metrics(self):
926
+ """Flush metrics if enough time has passed."""
927
+ with self._lock:
928
+ if self._finished:
929
+ return
930
+ if time.time() - self._last_flush_time > self._flush_interval:
931
+ self._flush_metrics()
932
+
933
+ def _flush_metrics(self) -> bool:
934
+ """Send one buffered metric batch and report whether it was delivered."""
935
+ # Serialize the complete take/send/requeue operation. Buffer locking
936
+ # alone cannot keep a periodic flush from racing the final drain.
937
+ with self._metric_flush_lock:
938
+ with self._metric_buffer_lock:
939
+ if not self._metric_buffer:
940
+ return True
941
+ metrics_to_send = self._metric_buffer.copy()
942
+ self._metric_buffer.clear()
943
+ self._last_flush_time = time.time()
944
+
945
+ if not self._run_id or self._run_id.startswith("local-"):
946
+ logger.debug(f"Skipping metric flush (local run): {len(metrics_to_send)} metrics")
947
+ return True
948
+
949
+ try:
950
+ response = requests.post(
951
+ f"{self._api_url}/api/public/runs/{self._run_id}/metrics",
952
+ json={
953
+ "metrics": metrics_to_send,
954
+ },
955
+ headers=self._get_headers(),
956
+ timeout=30,
957
+ )
958
+ response.raise_for_status()
959
+ logger.debug(f"Flushed {len(metrics_to_send)} metrics")
960
+ return True
961
+ except Exception as error:
962
+ if not getattr(self, "_metric_flush_warned", False):
963
+ logger.warning("Failed to flush metrics (suppressing further warnings): " f"{error}")
964
+ self._metric_flush_warned = True
965
+
966
+ # Requeueing can recover a transient error. Only permanently
967
+ # dropped accepted metrics become a sticky delivery failure.
968
+ with self._metric_buffer_lock:
969
+ combined = metrics_to_send + self._metric_buffer
970
+ if len(combined) > MAX_METRIC_BUFFER_SIZE:
971
+ dropped = len(combined) - MAX_METRIC_BUFFER_SIZE
972
+ self._dropped_metric_count += dropped
973
+ logger.warning(
974
+ f"Dropping {dropped} oldest metrics to prevent buffer overflow "
975
+ f"(max: {MAX_METRIC_BUFFER_SIZE})"
976
+ )
977
+ combined = combined[-MAX_METRIC_BUFFER_SIZE:]
978
+ self._metric_buffer = combined
979
+ return False
980
+
981
+ def flush(self) -> None:
982
+ """Manually flush buffered metrics to the backend.
983
+
984
+ Call this when you want immediate visibility of logged metrics
985
+ without waiting for the automatic flush interval.
986
+
987
+ Example:
988
+ run.log({"accuracy": 0.95})
989
+ run.flush() # Metrics visible immediately in UI
990
+ """
991
+ self._flush_metrics()
992
+
993
+ # =========================================================================
994
+ # Live State (Tables-like feature for rich structured data)
995
+ # =========================================================================
996
+
997
+ def log_state(self, state: Dict[str, Any]) -> None:
998
+ """Log rich structured state for live visualization.
999
+
1000
+ This is similar to W&B Tables - allows streaming structured data
1001
+ (candidates, trees, pareto fronts) that can be visualized in real-time.
1002
+
1003
+ Unlike log() which is for numeric metrics, log_state() is for
1004
+ complex objects like:
1005
+ - Candidate prompts with full text
1006
+ - Evolution tree structure (parent-child relationships)
1007
+ - Pareto front membership
1008
+ - Per-instance scores
1009
+
1010
+ Args:
1011
+ state: Dictionary containing structured state data.
1012
+ Common keys for GEPA:
1013
+ - candidates: List of candidate dicts with idx, prompt, score, parent
1014
+ - parents: Parent indices for tree structure
1015
+ - scores: Validation scores per candidate
1016
+ - pareto_front: Dict mapping instance to best candidate indices
1017
+ - best_idx: Index of best overall candidate
1018
+ - generation: Current generation number
1019
+
1020
+ Example:
1021
+ run.log_state({
1022
+ "candidates": [
1023
+ {"idx": 0, "prompt": "You are...", "score": 0.72, "parent": None},
1024
+ {"idx": 1, "prompt": "As an expert...", "score": 0.78, "parent": 0},
1025
+ ],
1026
+ "pareto_front": {"instance_1": [1], "instance_2": [0, 1]},
1027
+ "best_idx": 1,
1028
+ "generation": 3,
1029
+ })
1030
+ """
1031
+ if not self._run_id or self._run_id.startswith("local-"):
1032
+ logger.info(f"[log_state] Skipping (local run): {self._run_id}")
1033
+ return
1034
+
1035
+ logger.info(f"[log_state] Sending to {self._api_url}/api/public/runs/{self._run_id}/state")
1036
+ try:
1037
+ response = requests.patch(
1038
+ f"{self._api_url}/api/public/runs/{self._run_id}/state",
1039
+ json={"state": state},
1040
+ headers=self._get_headers(),
1041
+ timeout=30,
1042
+ )
1043
+ response.raise_for_status()
1044
+ logger.info(f"[log_state] Success: {response.status_code}")
1045
+ except Exception as e:
1046
+ logger.warning(f"[log_state] Failed: {e}")
1047
+
1048
+ # =========================================================================
1049
+ # Scores (Gateway Scores linked to traces)
1050
+ # =========================================================================
1051
+
1052
+ def score(
1053
+ self,
1054
+ name: str,
1055
+ value: Union[float, str],
1056
+ *,
1057
+ trace_id: Optional[str] = None,
1058
+ session_id: Optional[str] = None,
1059
+ observation_id: Optional[str] = None,
1060
+ data_type: str = "NUMERIC",
1061
+ comment: Optional[str] = None,
1062
+ metadata: Optional[Dict[str, Any]] = None,
1063
+ ) -> None:
1064
+ """Post a score to Gateway, linked to a trace.
1065
+
1066
+ Scores are evaluation results attached to traces (visible on the Scores
1067
+ page and in trace detail views). Unlike run.log() which logs run-level
1068
+ metrics, score() creates Gateway Score entities linked to specific traces.
1069
+
1070
+ Auto-detection:
1071
+ - If trace_id is not provided, attempts to read it from the current
1072
+ OTel span context.
1073
+ - If session_id is not provided, reads it from the active Rollout.
1074
+
1075
+ Args:
1076
+ name: Score name (e.g., "rubric", "accuracy", "task_completion").
1077
+ value: Score value. Numeric for NUMERIC type, string for CATEGORICAL.
1078
+ trace_id: Trace ID to link the score to. Auto-detected from OTel
1079
+ context if not provided.
1080
+ session_id: Session ID to link the score to. Auto-detected from
1081
+ the active Rollout if not provided.
1082
+ observation_id: Optional observation/span ID for span-level scores.
1083
+ data_type: Score data type: "NUMERIC", "CATEGORICAL", or "BOOLEAN".
1084
+ comment: Optional comment explaining the score.
1085
+ metadata: Optional metadata dict.
1086
+
1087
+ Example::
1088
+
1089
+ with msk.run("experiment") as r:
1090
+ with r.rollout("task-1") as rollout:
1091
+ result = await run_agent(task)
1092
+ grade = evaluate(result)
1093
+ # Score linked to trace + session automatically
1094
+ r.score("rubric", grade)
1095
+
1096
+ # Or with explicit IDs
1097
+ r.score("accuracy", 0.95, trace_id="abc123")
1098
+ """
1099
+ with self._lock:
1100
+ if self._finished:
1101
+ logger.warning("Cannot score on a finished run")
1102
+ return
1103
+
1104
+ # Auto-detect session_id from active Rollout
1105
+ if session_id is None:
1106
+ current_rollout = get_current_rollout()
1107
+ if current_rollout is not None:
1108
+ session_id = current_rollout.session_id
1109
+
1110
+ # Session scores are valid first-class scores and intentionally carry
1111
+ # no trace/observation target. Only consult ambient trace context when
1112
+ # no explicit or active Rollout session is available.
1113
+ if not session_id and trace_id is None:
1114
+ trace_id = self._get_current_trace_id()
1115
+
1116
+ if not session_id and not trace_id:
1117
+ logger.warning(
1118
+ "Cannot post score: no session_id or trace_id is available. "
1119
+ "Call score() inside a Rollout/traced block or pass a target explicitly."
1120
+ )
1121
+ return
1122
+
1123
+ # Build score payload (matches Gateway POST /api/public/scores schema).
1124
+ # The API requires EXACTLY ONE of: traceId, sessionId, or datasetRunId.
1125
+ # Prefer sessionId (scores typically grade a whole session/rollout);
1126
+ # fall back to traceId when no session context exists.
1127
+ score_payload: Dict[str, Any] = {
1128
+ "name": name,
1129
+ "value": value,
1130
+ "dataType": data_type,
1131
+ }
1132
+
1133
+ if session_id:
1134
+ score_payload["sessionId"] = session_id
1135
+ elif trace_id:
1136
+ score_payload["traceId"] = trace_id
1137
+ if observation_id:
1138
+ score_payload["observationId"] = observation_id
1139
+ else:
1140
+ logger.warning(f"Cannot post score '{name}': no sessionId or traceId available.")
1141
+ return
1142
+ if comment:
1143
+ score_payload["comment"] = comment
1144
+
1145
+ # Merge metadata with run context
1146
+ score_metadata: Dict[str, Any] = {
1147
+ "source": "gatewaysdk",
1148
+ "run_id": self._run_id,
1149
+ "run_name": self._name,
1150
+ }
1151
+ if metadata:
1152
+ score_metadata.update(metadata)
1153
+ score_payload["metadata"] = score_metadata
1154
+
1155
+ # Send in a background thread (non-blocking)
1156
+ self._send_score_async(score_payload)
1157
+
1158
+ def success(
1159
+ self,
1160
+ name: str,
1161
+ value: Union[bool, float] = True,
1162
+ *,
1163
+ agent_id: Optional[str] = None,
1164
+ metric_name: Optional[str] = None,
1165
+ trace_id: Optional[str] = None,
1166
+ session_id: Optional[str] = None,
1167
+ observation_id: Optional[str] = None,
1168
+ data_type: Optional[str] = None,
1169
+ comment: Optional[str] = None,
1170
+ metadata: Optional[Dict[str, Any]] = None,
1171
+ ) -> None:
1172
+ """Emit a success signal that feeds a Gateway Success Metric.
1173
+
1174
+ A thin convenience wrapper over :meth:`score`. A boolean ``value`` is
1175
+ recorded as a 1/0 BOOLEAN score (a pass/fail signal); a numeric value is
1176
+ recorded as a NUMERIC score. The signal is tagged with the agent
1177
+ identity (``gateway.agent.id``) and the metric name so the platform can
1178
+ roll it up into that agent's Success Metric.
1179
+
1180
+ Args:
1181
+ name: Signal/score name. Match it to the Success Metric (or a linked
1182
+ eval's score name) you want it to count toward.
1183
+ value: ``True``/``False`` for a pass/fail signal, or a number.
1184
+ agent_id: The agent this signal belongs to (``gateway.agent.id``).
1185
+ Defaults to the trace's agent when omitted.
1186
+ metric_name: The Success Metric name; defaults to ``name``.
1187
+ trace_id, session_id, observation_id: Linkage; auto-detected like
1188
+ :meth:`score` when omitted.
1189
+ data_type: Override the inferred "BOOLEAN"/"NUMERIC".
1190
+ comment: Optional comment.
1191
+ metadata: Extra metadata merged into the signal.
1192
+
1193
+ Example::
1194
+
1195
+ with gatewaysdk.run("prod") as r:
1196
+ r.success("resolved_ticket", True, agent_id="support-triage")
1197
+ r.success("handle_time_s", 42.0, agent_id="support-triage")
1198
+ """
1199
+ if isinstance(value, bool):
1200
+ numeric_value: float = 1.0 if value else 0.0
1201
+ inferred_type = "BOOLEAN"
1202
+ else:
1203
+ numeric_value = float(value)
1204
+ inferred_type = "NUMERIC"
1205
+
1206
+ signal_metadata: Dict[str, Any] = {
1207
+ "gateway.success.signal": True,
1208
+ "gateway.success.metricName": metric_name or name,
1209
+ }
1210
+ if agent_id:
1211
+ signal_metadata["gateway.agent.id"] = agent_id
1212
+ if metadata:
1213
+ signal_metadata.update(metadata)
1214
+
1215
+ self.score(
1216
+ name,
1217
+ numeric_value,
1218
+ trace_id=trace_id,
1219
+ session_id=session_id,
1220
+ observation_id=observation_id,
1221
+ data_type=data_type or inferred_type,
1222
+ comment=comment,
1223
+ metadata=signal_metadata,
1224
+ )
1225
+
1226
+ def _get_current_trace_id(self) -> Optional[str]:
1227
+ """Get the trace ID from the current OTel span context.
1228
+
1229
+ Returns the hex trace ID if there is an active span, None otherwise.
1230
+ """
1231
+ try:
1232
+ from opentelemetry import trace as otel_trace
1233
+
1234
+ current_span = otel_trace.get_current_span()
1235
+ span_context = current_span.get_span_context()
1236
+ if span_context and span_context.is_valid:
1237
+ return format(span_context.trace_id, "032x")
1238
+ except Exception as e:
1239
+ logger.debug(f"Could not get trace_id from OTel context: {e}")
1240
+ return None
1241
+
1242
+ def _send_score_async(self, score_payload: Dict[str, Any]) -> None:
1243
+ """Send a score to Gateway in a background thread.
1244
+
1245
+ Threads are tracked and joined in finish() to ensure delivery.
1246
+ """
1247
+ if not self._run_id or self._run_id.startswith("local-"):
1248
+ logger.debug(f"Skipping score (local run): {score_payload.get('name')}")
1249
+ return
1250
+
1251
+ headers = self._get_headers()
1252
+ url = f"{self._api_url}/api/public/scores"
1253
+
1254
+ def _send():
1255
+ try:
1256
+ response = requests.post(
1257
+ url,
1258
+ json=score_payload,
1259
+ headers=headers,
1260
+ timeout=30,
1261
+ )
1262
+ if not response.ok:
1263
+ try:
1264
+ error_body = response.json()
1265
+ except Exception:
1266
+ error_body = response.text
1267
+ logger.warning(
1268
+ f"Failed to post score '{score_payload['name']}': "
1269
+ f"HTTP {response.status_code} - {error_body}"
1270
+ )
1271
+ self._record_score_delivery_failure(
1272
+ f"score '{score_payload['name']}' was rejected with " f"HTTP {response.status_code}"
1273
+ )
1274
+ return
1275
+ logger.info(
1276
+ f"Score posted: {score_payload['name']}={score_payload['value']} "
1277
+ f"(trace={score_payload.get('traceId', 'N/A')[:12]}...)"
1278
+ )
1279
+ except Exception as error:
1280
+ logger.warning(f"Failed to post score: {error}")
1281
+ self._record_score_delivery_failure(f"score '{score_payload['name']}' delivery raised {error}")
1282
+
1283
+ thread = threading.Thread(
1284
+ target=_send,
1285
+ name=f"gateway-score-{score_payload.get('name', 'unknown')}",
1286
+ daemon=True,
1287
+ )
1288
+
1289
+ # Register and start under the lifecycle gate so finish cannot take a
1290
+ # worker snapshot between acceptance and thread startup.
1291
+ with self._lock:
1292
+ if self._finished:
1293
+ logger.warning("Cannot score on a finished run")
1294
+ return
1295
+ self._score_threads.append(thread)
1296
+ try:
1297
+ thread.start()
1298
+ except Exception as error:
1299
+ self._score_threads.remove(thread)
1300
+ failure = f"score '{score_payload['name']}' worker could not start: {error}"
1301
+ self._score_delivery_failures.append(failure)
1302
+ logger.warning(failure)
1303
+
1304
+ def _record_score_delivery_failure(self, failure: str) -> None:
1305
+ """Retain a terminal score outcome until finish decides final state."""
1306
+ with self._lock:
1307
+ self._score_delivery_failures.append(failure)
1308
+
1309
+ # =========================================================================
1310
+ # Finish
1311
+ # =========================================================================
1312
+
1313
+ def finish(self, state: RunState = "completed") -> None:
1314
+ """Mark the run as finished.
1315
+
1316
+ Args:
1317
+ state: Final state ("completed", "failed", "cancelled").
1318
+ """
1319
+ with self._lock:
1320
+ if self._finished:
1321
+ return
1322
+ # This is the admission gate for both metrics and scores. Work
1323
+ # registered before this point is included in the snapshots below;
1324
+ # later calls retain the existing warning/no-op behavior.
1325
+ self._finished = True
1326
+ score_threads = list(self._score_threads)
1327
+
1328
+ delivery_failures: List[str] = []
1329
+
1330
+ # Stop periodic producers before the final drain. A flush already in
1331
+ # progress is serialized with the final attempt by _metric_flush_lock.
1332
+ self._stop_heartbeat.set()
1333
+ self._stop_flush_ticker.set()
1334
+
1335
+ if self._heartbeat_thread:
1336
+ self._heartbeat_thread.join(timeout=5)
1337
+ if self._flush_ticker_thread:
1338
+ self._flush_ticker_thread.join(timeout=2)
1339
+
1340
+ # Flush remaining metrics
1341
+ try:
1342
+ metrics_delivered = self._flush_metrics()
1343
+ except Exception as error:
1344
+ metrics_delivered = False
1345
+ logger.warning(f"Final metric delivery raised unexpectedly: {error}")
1346
+
1347
+ if not metrics_delivered:
1348
+ delivery_failures.append("metric delivery failed during the final flush")
1349
+ if self._dropped_metric_count:
1350
+ delivery_failures.append(
1351
+ f"metric delivery permanently dropped {self._dropped_metric_count} " "accepted metrics"
1352
+ )
1353
+
1354
+ # Wait for pending score submissions to complete
1355
+ for thread in score_threads:
1356
+ try:
1357
+ thread.join(timeout=10)
1358
+ if thread.is_alive():
1359
+ delivery_failures.append(
1360
+ f"score delivery worker {getattr(thread, 'name', '<unknown>')} "
1361
+ "did not finish before the join deadline"
1362
+ )
1363
+ except Exception as error:
1364
+ delivery_failures.append(f"score delivery worker could not be joined: {error}")
1365
+
1366
+ with self._lock:
1367
+ self._score_threads.clear()
1368
+ delivery_failures.extend(self._score_delivery_failures)
1369
+
1370
+ requested_success = state == "completed"
1371
+ effective_state: RunState = "failed" if requested_success and delivery_failures else state
1372
+ self._state = effective_state
1373
+
1374
+ self._last_finish_error = None
1375
+ self._last_finish_exception = None
1376
+ try:
1377
+ finish_confirmed = self._send_finish(effective_state)
1378
+ except Exception as error:
1379
+ self._last_finish_error = str(error)
1380
+ self._last_finish_exception = error
1381
+ finish_confirmed = False
1382
+ logger.warning(f"Failed to send finish: {error}")
1383
+
1384
+ completion_error: Optional[GatewayRunError] = None
1385
+ if requested_success and delivery_failures:
1386
+ reason = "; ".join(delivery_failures)
1387
+ if not finish_confirmed:
1388
+ reason = (
1389
+ f"{reason}; failed state was not confirmed: " f"{self._last_finish_error or 'unknown finish error'}"
1390
+ )
1391
+ completion_error = GatewayRunError(f"Run finish delivery failed: {reason}")
1392
+ elif requested_success and not finish_confirmed:
1393
+ self._state = "failed"
1394
+ completion_error = GatewayRunError(
1395
+ "Run finish could not be confirmed: " f"{self._last_finish_error or 'unknown finish error'}"
1396
+ )
1397
+
1398
+ # Restore signal handlers
1399
+ self._restore_signal_handlers()
1400
+
1401
+ # Unregister atexit
1402
+ try:
1403
+ atexit.unregister(self._atexit_handler)
1404
+ except Exception:
1405
+ pass
1406
+
1407
+ # Do not erase a different outer/sentinel Run context.
1408
+ if get_current_run() is self:
1409
+ _set_current_run(None)
1410
+
1411
+ if completion_error is not None:
1412
+ logger.warning(f"Gateway Run finish failed: {self._name} ({self._state})")
1413
+ if self._last_finish_exception is not None:
1414
+ raise completion_error from self._last_finish_exception
1415
+ raise completion_error
1416
+
1417
+ if delivery_failures or not finish_confirmed:
1418
+ logger.warning("Gateway Run cleanup completed with delivery warnings: " f"{self._name} ({self._state})")
1419
+ else:
1420
+ logger.info(f"Gateway Run finished: {self._name} ({self._state})")
1421
+
1422
+ def _send_finish(self, state: RunState) -> bool:
1423
+ """Send final state to the backend and report confirmation."""
1424
+ self._last_finish_error = None
1425
+ self._last_finish_exception = None
1426
+ if not self._run_id or self._run_id.startswith("local-"):
1427
+ return True
1428
+
1429
+ try:
1430
+ response = requests.post(
1431
+ f"{self._api_url}/api/public/runs/{self._run_id}/finish",
1432
+ json={
1433
+ "state": state,
1434
+ },
1435
+ headers=self._get_headers(),
1436
+ timeout=10,
1437
+ )
1438
+ response.raise_for_status()
1439
+ return True
1440
+ except Exception as error:
1441
+ self._last_finish_error = str(error)
1442
+ self._last_finish_exception = error
1443
+ logger.warning(f"Failed to send finish: {error}")
1444
+ return False
1445
+
1446
+ # =========================================================================
1447
+ # Context Manager
1448
+ # =========================================================================
1449
+
1450
+ def __enter__(self) -> "Run":
1451
+ """Context manager entry - start the run."""
1452
+ return self._start()
1453
+
1454
+ def __exit__(self, exc_type, exc_val, exc_tb):
1455
+ """Context manager exit - finish with appropriate state."""
1456
+ if exc_type is not None:
1457
+ # Exception occurred - mark as failed
1458
+ logger.error(f"Run failed with exception: {exc_val}")
1459
+ self.finish(state="failed")
1460
+ elif not self._finished:
1461
+ # No exception, not manually finished - complete
1462
+ self.finish(state="completed")
1463
+
1464
+ return False # Don't suppress exceptions
1465
+
1466
+
1467
+ # =============================================================================
1468
+ # Convenience Functions
1469
+ # =============================================================================
1470
+
1471
+
1472
+ def init_run(
1473
+ name: Optional[str] = None,
1474
+ *,
1475
+ project_id: Optional[str] = None,
1476
+ heartbeat_interval: int = 30,
1477
+ source: str = "sdk",
1478
+ labels: Optional[Dict[str, Any]] = None,
1479
+ config: Optional[Dict[str, Any]] = None,
1480
+ tags: Optional[List[str]] = None,
1481
+ **kwargs,
1482
+ ) -> Run:
1483
+ """Initialize and start a new Gateway Run.
1484
+
1485
+ This is the main entry point for creating runs. For the safest usage,
1486
+ prefer the context manager form::
1487
+
1488
+ with gatewaysdk.run("my-experiment") as run:
1489
+ run.log({"metric": value})
1490
+
1491
+ Args:
1492
+ name: Human-readable name for the run.
1493
+ project_id: Project ID (from env GATEWAY_PROJECT_ID if not provided).
1494
+ heartbeat_interval: Seconds between heartbeats (default: 30).
1495
+ source: Source identifier (e.g., "gepa", "trainer").
1496
+ labels: Arbitrary key-value labels for filtering.
1497
+ config: Hyperparameters/config dict.
1498
+ tags: Simple string tags for categorization.
1499
+
1500
+ Returns:
1501
+ An initialized and started Run instance.
1502
+
1503
+ Note:
1504
+ Use run.rollout() to create individual episodes/rollouts within a run.
1505
+ Each rollout gets its own session_id for trace grouping.
1506
+ """
1507
+ run = Run(
1508
+ name=name,
1509
+ project_id=project_id,
1510
+ heartbeat_interval=heartbeat_interval,
1511
+ source=source,
1512
+ labels=labels,
1513
+ config=config,
1514
+ tags=tags,
1515
+ **kwargs,
1516
+ )
1517
+ return run._start()
1518
+
1519
+
1520
+ @contextmanager
1521
+ def run(
1522
+ name: Optional[str] = None,
1523
+ **kwargs,
1524
+ ):
1525
+ """Context manager for a Gateway Run.
1526
+
1527
+ This is the recommended way to use runs as it automatically handles
1528
+ crashes, signals, and exceptions::
1529
+
1530
+ with gatewaysdk.run("my-experiment", config={"lr": 0.01}) as r:
1531
+ for epoch in range(100):
1532
+ loss = train()
1533
+ r.log({"loss": loss}, step=epoch)
1534
+
1535
+ Args:
1536
+ name: Human-readable name for the run.
1537
+ **kwargs: Additional arguments passed to Run constructor.
1538
+
1539
+ Yields:
1540
+ A Run instance.
1541
+ """
1542
+ r = Run(name=name, **kwargs)
1543
+ with r:
1544
+ yield r
1545
+
1546
+
1547
+ def log(metrics: Dict[str, Any], step: Optional[int] = None, **kwargs):
1548
+ """Log metrics to the current run.
1549
+
1550
+ Convenience function for logging to the currently active run.
1551
+
1552
+ Args:
1553
+ metrics: Dict of {metric_name: value}.
1554
+ step: Training step/iteration number.
1555
+ **kwargs: Additional arguments passed to Run.log().
1556
+
1557
+ Raises:
1558
+ RuntimeError: If no run is currently active.
1559
+ """
1560
+ current = get_current_run()
1561
+ if current is None:
1562
+ raise RuntimeError("No active run. Call gatewaysdk.init_run() or use 'with gatewaysdk.run(...)'")
1563
+ current.log(metrics, step=step, **kwargs)
1564
+
1565
+
1566
+ def score(
1567
+ name: str,
1568
+ value: Union[float, str],
1569
+ **kwargs,
1570
+ ) -> None:
1571
+ """Post a score to Gateway from the current run.
1572
+
1573
+ Convenience function for posting scores to the currently active run.
1574
+ Auto-detects trace_id from OTel context and session_id from the active
1575
+ Rollout.
1576
+
1577
+ Args:
1578
+ name: Score name (e.g., "rubric", "accuracy").
1579
+ value: Score value.
1580
+ **kwargs: Additional arguments passed to Run.score().
1581
+
1582
+ Raises:
1583
+ RuntimeError: If no run is currently active.
1584
+ """
1585
+ current = get_current_run()
1586
+ if current is None:
1587
+ raise RuntimeError("No active run. Call gatewaysdk.init_run() or use 'with gatewaysdk.run(...)'")
1588
+ current.score(name, value, **kwargs)
1589
+
1590
+
1591
+ def success(
1592
+ name: str,
1593
+ value: Union[bool, float] = True,
1594
+ **kwargs,
1595
+ ) -> None:
1596
+ """Emit a success signal from the current run.
1597
+
1598
+ Convenience function for the currently active run; see :meth:`Run.success`.
1599
+
1600
+ Raises:
1601
+ RuntimeError: If no run is currently active.
1602
+ """
1603
+ current = get_current_run()
1604
+ if current is None:
1605
+ raise RuntimeError("No active run. Call gatewaysdk.init_run() or use 'with gatewaysdk.run(...)'")
1606
+ current.success(name, value, **kwargs)
1607
+
1608
+
1609
+ def finish(state: RunState = "completed"):
1610
+ """Finish the current run.
1611
+
1612
+ Convenience function for finishing the currently active run.
1613
+
1614
+ Args:
1615
+ state: Final state ("completed", "failed", "cancelled").
1616
+ """
1617
+ current = get_current_run()
1618
+ if current is None:
1619
+ return
1620
+ current.finish(state=state)
1621
+
1622
+
1623
+ # =============================================================================
1624
+ # Decorator-based API
1625
+ # =============================================================================
1626
+
1627
+
1628
+ def experiment(
1629
+ name: Optional[str] = None,
1630
+ *,
1631
+ inject_run: bool = True,
1632
+ **run_kwargs,
1633
+ ) -> Callable[[F], F]:
1634
+ """Decorator to wrap a function in a Gateway Run.
1635
+
1636
+ This provides an alternative to context managers for experiment tracking.
1637
+ The run is automatically started before the function executes and finished
1638
+ after it completes (or fails).
1639
+
1640
+ Example::
1641
+
1642
+ @gatewaysdk.experiment("gepa-v1", config={"lr": 0.01})
1643
+ def train(run):
1644
+ run.log({"started": True})
1645
+ for candidate in population:
1646
+ evaluate(candidate)
1647
+
1648
+ @gatewaysdk.experiment(inject_run=False)
1649
+ def simple_experiment():
1650
+ # Access run via get_current_run()
1651
+ gatewaysdk.log({"metric": value})
1652
+
1653
+ Args:
1654
+ name: Human-readable name for the run. Defaults to function name.
1655
+ inject_run: If True (default), passes the Run instance as the first
1656
+ argument to the decorated function. If False, access via
1657
+ get_current_run().
1658
+ **run_kwargs: Additional arguments passed to Run constructor
1659
+ (config, labels, tags, source, etc).
1660
+
1661
+ Returns:
1662
+ A decorator function.
1663
+ """
1664
+
1665
+ def decorator(fn: F) -> F:
1666
+ @functools.wraps(fn)
1667
+ def wrapper(*args, **kwargs):
1668
+ run_name = name or fn.__name__
1669
+ r = Run(name=run_name, **run_kwargs)
1670
+ with r:
1671
+ if inject_run:
1672
+ return fn(r, *args, **kwargs)
1673
+ return fn(*args, **kwargs)
1674
+
1675
+ @functools.wraps(fn)
1676
+ async def async_wrapper(*args, **kwargs):
1677
+ run_name = name or fn.__name__
1678
+ r = Run(name=run_name, **run_kwargs)
1679
+ with r:
1680
+ if inject_run:
1681
+ return await fn(r, *args, **kwargs)
1682
+ return await fn(*args, **kwargs)
1683
+
1684
+ if asyncio.iscoroutinefunction(fn):
1685
+ return async_wrapper # type: ignore
1686
+ return wrapper # type: ignore
1687
+
1688
+ return decorator
1689
+
1690
+
1691
+ def episode(
1692
+ name: Optional[str] = None,
1693
+ *,
1694
+ name_fn: Optional[Callable[..., str]] = None,
1695
+ inject_rollout: bool = False,
1696
+ ) -> Callable[[F], F]:
1697
+ """Decorator to wrap a function in a Rollout (episode).
1698
+
1699
+ This provides an alternative to context managers for episode tracking.
1700
+ The rollout is automatically started before the function executes and
1701
+ ended after it completes.
1702
+
1703
+ Requires an active run (created via @experiment decorator or context manager).
1704
+
1705
+ Example::
1706
+
1707
+ @gatewaysdk.experiment("gepa", config={"lr": 0.01})
1708
+ def train(run):
1709
+ for candidate in population:
1710
+ evaluate(candidate)
1711
+
1712
+ @gatewaysdk.episode()
1713
+ def evaluate(candidate):
1714
+ result = agent.execute(task)
1715
+ # Traces automatically get run_id + session_id
1716
+
1717
+ # With dynamic name based on arguments
1718
+ @gatewaysdk.episode(name_fn=lambda c: f"candidate-{c.id}")
1719
+ def evaluate_named(candidate):
1720
+ result = agent.execute(task)
1721
+
1722
+ Args:
1723
+ name: Static name for the rollout. Defaults to function name.
1724
+ name_fn: Optional function that takes the same arguments as the
1725
+ decorated function and returns a rollout name.
1726
+ inject_rollout: If True, passes the Rollout instance as the first
1727
+ argument to the decorated function. Default False.
1728
+
1729
+ Returns:
1730
+ A decorator function.
1731
+
1732
+ Raises:
1733
+ RuntimeError: If no active run exists when the function is called.
1734
+ """
1735
+
1736
+ def decorator(fn: F) -> F:
1737
+ @functools.wraps(fn)
1738
+ def wrapper(*args, **kwargs):
1739
+ current_run = get_current_run()
1740
+ if current_run is None:
1741
+ raise RuntimeError(
1742
+ "@episode requires an active run. " "Use @experiment decorator or 'with gatewaysdk.run(...)' first."
1743
+ )
1744
+
1745
+ # Determine rollout name
1746
+ if name_fn is not None:
1747
+ rollout_name = name_fn(*args, **kwargs)
1748
+ else:
1749
+ rollout_name = name or fn.__name__
1750
+
1751
+ rollout = current_run.rollout(rollout_name)
1752
+ with rollout:
1753
+ if inject_rollout:
1754
+ return fn(rollout, *args, **kwargs)
1755
+ return fn(*args, **kwargs)
1756
+
1757
+ @functools.wraps(fn)
1758
+ async def async_wrapper(*args, **kwargs):
1759
+ current_run = get_current_run()
1760
+ if current_run is None:
1761
+ raise RuntimeError(
1762
+ "@episode requires an active run. " "Use @experiment decorator or 'with gatewaysdk.run(...)' first."
1763
+ )
1764
+
1765
+ # Determine rollout name
1766
+ if name_fn is not None:
1767
+ rollout_name = name_fn(*args, **kwargs)
1768
+ else:
1769
+ rollout_name = name or fn.__name__
1770
+
1771
+ rollout = current_run.rollout(rollout_name)
1772
+ with rollout:
1773
+ if inject_rollout:
1774
+ return await fn(rollout, *args, **kwargs)
1775
+ return await fn(*args, **kwargs)
1776
+
1777
+ if asyncio.iscoroutinefunction(fn):
1778
+ return async_wrapper # type: ignore
1779
+ return wrapper # type: ignore
1780
+
1781
+ return decorator