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.
- gatewaysdk/__init__.py +540 -0
- gatewaysdk/adapter/__init__.py +15 -0
- gatewaysdk/adapter/base.py +94 -0
- gatewaysdk/adapter/messages.py +270 -0
- gatewaysdk/adapter/triplet.py +1026 -0
- gatewaysdk/algorithm/__init__.py +39 -0
- gatewaysdk/algorithm/apo/__init__.py +5 -0
- gatewaysdk/algorithm/apo/apo.py +898 -0
- gatewaysdk/algorithm/apo/prompts/apply_edit_variant01.poml +22 -0
- gatewaysdk/algorithm/apo/prompts/apply_edit_variant02.poml +18 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant01.poml +18 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant02.poml +16 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant03.poml +107 -0
- gatewaysdk/algorithm/base.py +262 -0
- gatewaysdk/algorithm/decorator.py +264 -0
- gatewaysdk/algorithm/evals/__init__.py +7 -0
- gatewaysdk/algorithm/evals/evals.py +217 -0
- gatewaysdk/algorithm/fast.py +250 -0
- gatewaysdk/algorithm/gepa/__init__.py +61 -0
- gatewaysdk/algorithm/gepa/adapter.py +495 -0
- gatewaysdk/algorithm/gepa/gepa.py +570 -0
- gatewaysdk/algorithm/gepa/lib/__init__.py +18 -0
- gatewaysdk/algorithm/gepa/lib/adapters/README.md +12 -0
- gatewaysdk/algorithm/gepa/lib/adapters/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/README.md +341 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/__init__.py +1 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/anymaths_adapter.py +174 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/requirements.txt +1 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/README.md +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/default_adapter.py +209 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/README.md +7 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/dspy_adapter.py +307 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/README.md +99 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/dspy_program_proposal_signature.py +137 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/full_program_adapter.py +268 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/GEPA_RAG.md +621 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/__init__.py +56 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/evaluation_metrics.py +226 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/generic_rag_adapter.py +496 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/rag_pipeline.py +238 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_store_interface.py +212 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/__init__.py +2 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/chroma_store.py +196 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/lancedb_store.py +422 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/milvus_store.py +409 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/qdrant_store.py +368 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/weaviate_store.py +418 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/README.md +552 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/__init__.py +37 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_adapter.py +699 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_client.py +364 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/README.md +9 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/terminal_bench_adapter.py +217 -0
- gatewaysdk/algorithm/gepa/lib/api.py +382 -0
- gatewaysdk/algorithm/gepa/lib/core/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/core/adapter.py +180 -0
- gatewaysdk/algorithm/gepa/lib/core/data_loader.py +74 -0
- gatewaysdk/algorithm/gepa/lib/core/engine.py +379 -0
- gatewaysdk/algorithm/gepa/lib/core/result.py +233 -0
- gatewaysdk/algorithm/gepa/lib/core/state.py +636 -0
- gatewaysdk/algorithm/gepa/lib/examples/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/examples/aime.py +24 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/eval_default.py +111 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/instruction_prompt.txt +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/optimal_prompt.txt +24 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/train_anymaths.py +177 -0
- gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/arc_agi.ipynb +25705 -0
- gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/example.ipynb +348 -0
- gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/__init__.py +4 -0
- gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/mcp_optimization_example.py +456 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/RAG_GUIDE.md +613 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/__init__.py +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/rag_optimization.py +820 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/requirements-rag.txt +29 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/instruction_prompt.txt +16 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/terminus.txt +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/train_terminus.py +161 -0
- gatewaysdk/algorithm/gepa/lib/gepa_utils.py +117 -0
- gatewaysdk/algorithm/gepa/lib/logging/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/logging/experiment_tracker.py +187 -0
- gatewaysdk/algorithm/gepa/lib/logging/logger.py +75 -0
- gatewaysdk/algorithm/gepa/lib/logging/utils.py +103 -0
- gatewaysdk/algorithm/gepa/lib/proposer/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/proposer/base.py +31 -0
- gatewaysdk/algorithm/gepa/lib/proposer/merge.py +357 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/base.py +49 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/reflective_mutation.py +176 -0
- gatewaysdk/algorithm/gepa/lib/py.typed +0 -0
- gatewaysdk/algorithm/gepa/lib/strategies/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/strategies/batch_sampler.py +77 -0
- gatewaysdk/algorithm/gepa/lib/strategies/candidate_selector.py +50 -0
- gatewaysdk/algorithm/gepa/lib/strategies/component_selector.py +36 -0
- gatewaysdk/algorithm/gepa/lib/strategies/eval_policy.py +64 -0
- gatewaysdk/algorithm/gepa/lib/strategies/instruction_proposal.py +126 -0
- gatewaysdk/algorithm/gepa/lib/utils/__init__.py +10 -0
- gatewaysdk/algorithm/gepa/lib/utils/stop_condition.py +196 -0
- gatewaysdk/algorithm/gepa/tracing.py +105 -0
- gatewaysdk/algorithm/utils.py +177 -0
- gatewaysdk/algorithm/verl/__init__.py +5 -0
- gatewaysdk/algorithm/verl/interface.py +202 -0
- gatewaysdk/automations.py +111 -0
- gatewaysdk/benchmark_hub/README.md +88 -0
- gatewaysdk/benchmark_hub/__init__.py +119 -0
- gatewaysdk/benchmark_hub/_archive.py +189 -0
- gatewaysdk/benchmark_hub/_tar.py +6 -0
- gatewaysdk/benchmark_hub/client.py +992 -0
- gatewaysdk/benchmark_hub/dispatch_shard.py +34 -0
- gatewaysdk/benchmark_hub/eval_config.py +20 -0
- gatewaysdk/benchmark_hub/evals.py +351 -0
- gatewaysdk/benchmark_hub/harbor_adapter.py +673 -0
- gatewaysdk/benchmark_hub/path_utils.py +81 -0
- gatewaysdk/benchmark_hub/save_utils.py +101 -0
- gatewaysdk/benchmark_hub/verifiers_adapter.py +327 -0
- gatewaysdk/benchmark_hub/versioning.py +72 -0
- gatewaysdk/build.py +515 -0
- gatewaysdk/cli/__init__.py +58 -0
- gatewaysdk/cli/agent_runner.py +132 -0
- gatewaysdk/cli/http_client.py +115 -0
- gatewaysdk/cli/platform.py +4086 -0
- gatewaysdk/cli/prometheus.py +115 -0
- gatewaysdk/cli/release_gate.py +215 -0
- gatewaysdk/cli/store.py +131 -0
- gatewaysdk/cli/vllm.py +29 -0
- gatewaysdk/client.py +406 -0
- gatewaysdk/config.py +348 -0
- gatewaysdk/connectors/__init__.py +25 -0
- gatewaysdk/connectors/client.py +203 -0
- gatewaysdk/connectors/skill.py +25 -0
- gatewaysdk/connectors/template.py +106 -0
- gatewaysdk/context.py +606 -0
- gatewaysdk/emitter/__init__.py +43 -0
- gatewaysdk/emitter/annotation.py +370 -0
- gatewaysdk/emitter/exception.py +54 -0
- gatewaysdk/emitter/message.py +61 -0
- gatewaysdk/emitter/object.py +117 -0
- gatewaysdk/emitter/reward.py +320 -0
- gatewaysdk/env_var.py +156 -0
- gatewaysdk/environment/__init__.py +108 -0
- gatewaysdk/environment/_bundle.py +281 -0
- gatewaysdk/environment/_harbor.py +276 -0
- gatewaysdk/environment/_materialize.py +97 -0
- gatewaysdk/environment/_tar.py +33 -0
- gatewaysdk/environment/_world.py +346 -0
- gatewaysdk/environment/core.py +527 -0
- gatewaysdk/environment/errors.py +58 -0
- gatewaysdk/environment/runtime.py +150 -0
- gatewaysdk/environment/schema/__init__.py +35 -0
- gatewaysdk/environment/schema/__main__.py +225 -0
- gatewaysdk/environment/schema/_slack_fidelity.py +116 -0
- gatewaysdk/environment/schema/_slack_scenario.py +183 -0
- gatewaysdk/environment/schema/api.py +1628 -0
- gatewaysdk/environment/schema/batch.py +755 -0
- gatewaysdk/environment/schema/compiler.py +615 -0
- gatewaysdk/environment/schema/conform.py +428 -0
- gatewaysdk/environment/schema/connector.py +777 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/handlers.py +17 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/parity.json +28 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/provenance.json +233 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/scope.toml +112 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/world.json +1380 -0
- gatewaysdk/environment/schema/connectors/base.json +28 -0
- gatewaysdk/environment/schema/connectors/custom/handlers.py +18 -0
- gatewaysdk/environment/schema/connectors/custom/world.json +123 -0
- gatewaysdk/environment/schema/connectors/github/handlers.py +81 -0
- gatewaysdk/environment/schema/connectors/github/parity.json +37 -0
- gatewaysdk/environment/schema/connectors/github/provenance.json +134 -0
- gatewaysdk/environment/schema/connectors/github/scope.toml +158 -0
- gatewaysdk/environment/schema/connectors/github/world.json +5869 -0
- gatewaysdk/environment/schema/connectors/google-calendar/handlers.py +23 -0
- gatewaysdk/environment/schema/connectors/google-calendar/world.json +209 -0
- gatewaysdk/environment/schema/connectors/google-drive/handlers.py +18 -0
- gatewaysdk/environment/schema/connectors/google-drive/world.json +286 -0
- gatewaysdk/environment/schema/connectors/jamf/handlers.py +34 -0
- gatewaysdk/environment/schema/connectors/jamf/parity.json +49 -0
- gatewaysdk/environment/schema/connectors/jamf/provenance.json +181 -0
- gatewaysdk/environment/schema/connectors/jamf/scope.toml +128 -0
- gatewaysdk/environment/schema/connectors/jamf/world.json +2218 -0
- gatewaysdk/environment/schema/connectors/jira/handlers.py +28 -0
- gatewaysdk/environment/schema/connectors/jira/world.json +389 -0
- gatewaysdk/environment/schema/connectors/kandji/handlers.py +38 -0
- gatewaysdk/environment/schema/connectors/kandji/parity.json +29 -0
- gatewaysdk/environment/schema/connectors/kandji/provenance.json +152 -0
- gatewaysdk/environment/schema/connectors/kandji/scope.toml +120 -0
- gatewaysdk/environment/schema/connectors/kandji/world.json +765 -0
- gatewaysdk/environment/schema/connectors/linear/handlers.py +20 -0
- gatewaysdk/environment/schema/connectors/linear/world.json +363 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/fidelity.json +45 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/handlers.py +1 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/provenance.json +851 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/scope.md +231 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/teams-types.json +1710 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/world.json +858 -0
- gatewaysdk/environment/schema/connectors/netsuite/handlers.py +32 -0
- gatewaysdk/environment/schema/connectors/netsuite/parity.json +50 -0
- gatewaysdk/environment/schema/connectors/netsuite/provenance.json +137 -0
- gatewaysdk/environment/schema/connectors/netsuite/scope.toml +78 -0
- gatewaysdk/environment/schema/connectors/netsuite/world.json +16243 -0
- gatewaysdk/environment/schema/connectors/salesforce/handlers.py +806 -0
- gatewaysdk/environment/schema/connectors/salesforce/parity.json +46 -0
- gatewaysdk/environment/schema/connectors/salesforce/provenance.json +143 -0
- gatewaysdk/environment/schema/connectors/salesforce/scope.toml +174 -0
- gatewaysdk/environment/schema/connectors/salesforce/world.json +4695 -0
- gatewaysdk/environment/schema/connectors/slack/MODEL.md +236 -0
- gatewaysdk/environment/schema/connectors/slack/capabilities.json +1039 -0
- gatewaysdk/environment/schema/connectors/slack/handlers.py +762 -0
- gatewaysdk/environment/schema/connectors/slack/parity.json +65 -0
- gatewaysdk/environment/schema/connectors/slack/provenance.json +294 -0
- gatewaysdk/environment/schema/connectors/slack/scope.toml +44 -0
- gatewaysdk/environment/schema/connectors/slack/world.json +4993 -0
- gatewaysdk/environment/schema/connectors/workday/handlers.py +75 -0
- gatewaysdk/environment/schema/connectors/workday/parity.json +36 -0
- gatewaysdk/environment/schema/connectors/workday/provenance.json +179 -0
- gatewaysdk/environment/schema/connectors/workday/scope.toml +180 -0
- gatewaysdk/environment/schema/connectors/workday/world.json +806 -0
- gatewaysdk/environment/schema/data.py +593 -0
- gatewaysdk/environment/schema/extract.py +263 -0
- gatewaysdk/environment/schema/host.py +2437 -0
- gatewaysdk/environment/schema/host_surfaces.py +275 -0
- gatewaysdk/environment/schema/platform.py +1272 -0
- gatewaysdk/environment/schema/scaffold.py +250 -0
- gatewaysdk/environment/schema/skill.py +118 -0
- gatewaysdk/environment/schema/skills/agents/connector-template-author.md +202 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/CONNECTORS.md +331 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/CONVENTIONS.md +100 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/SKILL.md +313 -0
- gatewaysdk/environment/schema/skills/world-data-ingestion/ROWS.md +223 -0
- gatewaysdk/environment/schema/skills/world-data-ingestion/SKILL.md +293 -0
- gatewaysdk/environment/schema/skills/worlds-getting-started/SKILL.md +390 -0
- gatewaysdk/environment/schema/slack.py +757 -0
- gatewaysdk/environment/schema/snapshot.py +479 -0
- gatewaysdk/environment/schema/store.py +904 -0
- gatewaysdk/environment/schema/tasks.py +525 -0
- gatewaysdk/environment/schema/tools_world.py +322 -0
- gatewaysdk/environment/schema/validation.py +577 -0
- gatewaysdk/environment/schema/worker.py +78 -0
- gatewaysdk/environment/schema/workers.py +500 -0
- gatewaysdk/environment/schema/world_tests.py +336 -0
- gatewaysdk/environments/__init__.py +24 -0
- gatewaysdk/environments/client.py +486 -0
- gatewaysdk/environments/types.py +318 -0
- gatewaysdk/execution/__init__.py +15 -0
- gatewaysdk/execution/base.py +64 -0
- gatewaysdk/execution/client_server.py +443 -0
- gatewaysdk/execution/events.py +69 -0
- gatewaysdk/execution/inter_process.py +16 -0
- gatewaysdk/execution/shared_memory.py +282 -0
- gatewaysdk/experiments/__init__.py +90 -0
- gatewaysdk/experiments/assignment.py +177 -0
- gatewaysdk/experiments/client.py +877 -0
- gatewaysdk/experiments/exposure.py +222 -0
- gatewaysdk/experiments/types.py +81 -0
- gatewaysdk/importers/__init__.py +49 -0
- gatewaysdk/importers/_normalize.py +91 -0
- gatewaysdk/importers/builder.py +173 -0
- gatewaysdk/importers/client.py +95 -0
- gatewaysdk/importers/langsmith.py +196 -0
- gatewaysdk/importers/recipes.py +249 -0
- gatewaysdk/instrumentation/__init__.py +300 -0
- gatewaysdk/instrumentation/agentops.py +314 -0
- gatewaysdk/instrumentation/agentops_langchain.py +45 -0
- gatewaysdk/instrumentation/base.py +119 -0
- gatewaysdk/instrumentation/litellm.py +83 -0
- gatewaysdk/instrumentation/registry.py +273 -0
- gatewaysdk/instrumentation/vllm.py +81 -0
- gatewaysdk/instrumentation/weave.py +500 -0
- gatewaysdk/integrations/__init__.py +15 -0
- gatewaysdk/integrations/gateway/__init__.py +11 -0
- gatewaysdk/integrations/gateway/client.py +171 -0
- gatewaysdk/integrations/tool_access.py +549 -0
- gatewaysdk/litagent/__init__.py +11 -0
- gatewaysdk/litagent/decorator.py +536 -0
- gatewaysdk/litagent/litagent.py +252 -0
- gatewaysdk/llm_proxy.py +1742 -0
- gatewaysdk/logging.py +370 -0
- gatewaysdk/memory.py +278 -0
- gatewaysdk/personas.py +186 -0
- gatewaysdk/platform/__init__.py +17 -0
- gatewaysdk/platform/builder.py +221 -0
- gatewaysdk/platform/compatibility.py +67 -0
- gatewaysdk/platform/manifest.py +185 -0
- gatewaysdk/platform/orchestrator.py +901 -0
- gatewaysdk/platform/registry.py +122 -0
- gatewaysdk/platform/worker.py +864 -0
- gatewaysdk/replay/__init__.py +1032 -0
- gatewaysdk/replay/pytest.py +56 -0
- gatewaysdk/reward.py +7 -0
- gatewaysdk/run.py +1781 -0
- gatewaysdk/runner/__init__.py +11 -0
- gatewaysdk/runner/agent.py +878 -0
- gatewaysdk/runner/base.py +182 -0
- gatewaysdk/runner/legacy.py +309 -0
- gatewaysdk/security.py +700 -0
- gatewaysdk/semconv.py +170 -0
- gatewaysdk/server.py +399 -0
- gatewaysdk/sessions.py +282 -0
- gatewaysdk/store/__init__.py +45 -0
- gatewaysdk/store/base.py +908 -0
- gatewaysdk/store/client_server.py +2093 -0
- gatewaysdk/store/collection/__init__.py +30 -0
- gatewaysdk/store/collection/base.py +587 -0
- gatewaysdk/store/collection/memory.py +970 -0
- gatewaysdk/store/collection/mongo.py +1412 -0
- gatewaysdk/store/collection_based.py +1823 -0
- gatewaysdk/store/gateway.py +983 -0
- gatewaysdk/store/gateway_listener.py +465 -0
- gatewaysdk/store/listener.py +58 -0
- gatewaysdk/store/memory.py +396 -0
- gatewaysdk/store/mongo.py +165 -0
- gatewaysdk/store/redis_stream.py +517 -0
- gatewaysdk/store/sqlite.py +3 -0
- gatewaysdk/store/threading.py +370 -0
- gatewaysdk/store/utils.py +142 -0
- gatewaysdk/tracer/__init__.py +14 -0
- gatewaysdk/tracer/base.py +286 -0
- gatewaysdk/tracer/dummy.py +106 -0
- gatewaysdk/tracer/otel.py +559 -0
- gatewaysdk/tracing/__init__.py +110 -0
- gatewaysdk/tracing/api.py +808 -0
- gatewaysdk/tracing/attributes.py +9 -0
- gatewaysdk/tracing/context.py +272 -0
- gatewaysdk/tracing/exporters/__init__.py +10 -0
- gatewaysdk/tracing/exporters/gateway.py +228 -0
- gatewaysdk/tracing/identity.py +288 -0
- gatewaysdk/tracing/init.py +620 -0
- gatewaysdk/tracing/instrumentors/__init__.py +15 -0
- gatewaysdk/tracing/instrumentors/claude_agent_sdk.py +766 -0
- gatewaysdk/tracing/instrumentors/instrumentation_principles.md +294 -0
- gatewaysdk/tracing/instrumentors/registry.py +352 -0
- gatewaysdk/tracing/mapping.py +729 -0
- gatewaysdk/tracing/processors.py +393 -0
- gatewaysdk/tracing/push.py +617 -0
- gatewaysdk/tracing/push_models.py +247 -0
- gatewaysdk/tracing/semconv.py +294 -0
- gatewaysdk/tracing/span_builder.py +356 -0
- gatewaysdk/trainer/__init__.py +6 -0
- gatewaysdk/trainer/init_utils.py +263 -0
- gatewaysdk/trainer/legacy.py +359 -0
- gatewaysdk/trainer/registry.py +12 -0
- gatewaysdk/trainer/trainer.py +638 -0
- gatewaysdk/types/__init__.py +63 -0
- gatewaysdk/types/core.py +556 -0
- gatewaysdk/types/resources.py +204 -0
- gatewaysdk/types/tracer.py +515 -0
- gatewaysdk/types/tracing.py +162 -0
- gatewaysdk/users.py +251 -0
- gatewaysdk/utils/__init__.py +1 -0
- gatewaysdk/utils/id.py +18 -0
- gatewaysdk/utils/metrics.py +1025 -0
- gatewaysdk/utils/otel.py +550 -0
- gatewaysdk/utils/otlp.py +556 -0
- gatewaysdk/utils/redact.py +22 -0
- gatewaysdk/utils/server_launcher.py +1045 -0
- gatewaysdk/utils/system_snapshot.py +90 -0
- gatewaysdk/verl/__init__.py +8 -0
- gatewaysdk/verl/__main__.py +6 -0
- gatewaysdk/verl/async_server.py +46 -0
- gatewaysdk/verl/config.yaml +27 -0
- gatewaysdk/verl/daemon.py +1154 -0
- gatewaysdk/verl/dataset.py +44 -0
- gatewaysdk/verl/entrypoint.py +248 -0
- gatewaysdk/verl/trainer.py +549 -0
- gatewaysdk/world_browser.py +748 -0
- gatewaysdk/world_data.py +453 -0
- gatewaysdk/world_sessions.py +978 -0
- gatewaysdk/world_tasks.py +285 -0
- gatewaysdk/world_tools.py +115 -0
- gatewaysdk/worlds.py +639 -0
- gatewaysdk-0.3.2.dist-info/METADATA +236 -0
- gatewaysdk-0.3.2.dist-info/RECORD +376 -0
- gatewaysdk-0.3.2.dist-info/WHEEL +4 -0
- gatewaysdk-0.3.2.dist-info/entry_points.txt +5 -0
- 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
|