agentenv-framework 0.9.1254__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.
- agent_env/__init__.py +0 -0
- agent_env/a2a_agent/__init__.py +23 -0
- agent_env/a2a_agent/a2a_agent.py +600 -0
- agent_env/a2a_agent/conversation_store.py +356 -0
- agent_env/a2a_agent/object_transfer.py +609 -0
- agent_env/a2a_agent/protocol.py +238 -0
- agent_env/a2a_agent/store.py +201 -0
- agent_env/a2a_agent/validator.py +901 -0
- agent_env/artifact/__init__.py +48 -0
- agent_env/artifact/artifact.py +74 -0
- agent_env/artifact/artifacts/__init__.py +0 -0
- agent_env/artifact/artifacts/cli.py +83 -0
- agent_env/artifact/artifacts/docker_image.py +457 -0
- agent_env/artifact/artifacts/environment.py +89 -0
- agent_env/artifact/artifacts/environment_universe.py +156 -0
- agent_env/artifact/artifacts/file.py +214 -0
- agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
- agent_env/artifact/artifacts/skill.py +206 -0
- agent_env/artifact/artifacts/vm_image.py +95 -0
- agent_env/artifact/ref.py +15 -0
- agent_env/artifact/registry.py +251 -0
- agent_env/artifact/store.py +295 -0
- agent_env/artifact/universe.py +38 -0
- agent_env/attribution.py +41 -0
- agent_env/bundle/__init__.py +4 -0
- agent_env/bundle/_fs.py +128 -0
- agent_env/bundle/authoring.py +239 -0
- agent_env/bundle/installed.py +175 -0
- agent_env/bundle/ledger.py +269 -0
- agent_env/bundle/materialize.py +204 -0
- agent_env/bundle/parse.py +594 -0
- agent_env/bundle/plan.py +414 -0
- agent_env/bundle/resolve.py +404 -0
- agent_env/bundle/run.py +361 -0
- agent_env/cli/__init__.py +69 -0
- agent_env/cli/__main__.py +10 -0
- agent_env/cli/_installers.py +576 -0
- agent_env/cli/_plugin_changes.py +947 -0
- agent_env/cli/a2a_agent/__init__.py +22 -0
- agent_env/cli/a2a_agent/add_skill.py +97 -0
- agent_env/cli/a2a_agent/deploy.py +60 -0
- agent_env/cli/a2a_agent/get.py +15 -0
- agent_env/cli/a2a_agent/get_instance.py +26 -0
- agent_env/cli/a2a_agent/put.py +78 -0
- agent_env/cli/a2a_agent/validate.py +56 -0
- agent_env/cli/artifact/__init__.py +20 -0
- agent_env/cli/artifact/cli.py +83 -0
- agent_env/cli/artifact/environment.py +44 -0
- agent_env/cli/artifact/environment_universe.py +163 -0
- agent_env/cli/artifact/file_artifact_universe.py +281 -0
- agent_env/cli/artifact/skill.py +128 -0
- agent_env/cli/banner.py +25 -0
- agent_env/cli/config.py +263 -0
- agent_env/cli/env/__init__.py +31 -0
- agent_env/cli/env/deploy.py +112 -0
- agent_env/cli/env/gateway.py +57 -0
- agent_env/cli/env/get_instance.py +40 -0
- agent_env/cli/env/mcp_server.py +410 -0
- agent_env/cli/env/multi.py +282 -0
- agent_env/cli/env/service_db.py +99 -0
- agent_env/cli/env/snapshot.py +35 -0
- agent_env/cli/env/state/__init__.py +6 -0
- agent_env/cli/env/state/init.py +108 -0
- agent_env/cli/env/state/teardown.py +44 -0
- agent_env/cli/env/website.py +216 -0
- agent_env/cli/env/website_browser.py +67 -0
- agent_env/cli/eval/__init__.py +16 -0
- agent_env/cli/eval/add_tasks.py +63 -0
- agent_env/cli/eval/create.py +49 -0
- agent_env/cli/eval/run.py +194 -0
- agent_env/cli/identity/__init__.py +5 -0
- agent_env/cli/identity/client_identity.py +37 -0
- agent_env/cli/plugin.py +438 -0
- agent_env/cli/run.py +285 -0
- agent_env/cli/task/__init__.py +21 -0
- agent_env/cli/task/create.py +109 -0
- agent_env/cli/task/get.py +17 -0
- agent_env/cli/task/get_instance.py +34 -0
- agent_env/cli/task/run.py +615 -0
- agent_env/cli/task/validate.py +31 -0
- agent_env/cli/up.py +123 -0
- agent_env/cli/utils.py +183 -0
- agent_env/config/__init__.py +50 -0
- agent_env/config/describe.py +1067 -0
- agent_env/config/errors.py +13 -0
- agent_env/config/loader.py +245 -0
- agent_env/config/model.py +154 -0
- agent_env/config/paths.py +25 -0
- agent_env/config/plugin_tables.py +101 -0
- agent_env/config/provenance.py +134 -0
- agent_env/config/runtime.py +1064 -0
- agent_env/config/snapshot.py +81 -0
- agent_env/entity_refs.py +186 -0
- agent_env/env/__init__.py +49 -0
- agent_env/env/env.py +433 -0
- agent_env/env/env_artifact_store.py +87 -0
- agent_env/env/envs/__init__.py +7 -0
- agent_env/env/envs/_deployment.py +281 -0
- agent_env/env/envs/gateway_server.py +32 -0
- agent_env/env/envs/mcp_server.py +387 -0
- agent_env/env/envs/multi_env.py +780 -0
- agent_env/env/envs/service_db/Dockerfile +20 -0
- agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
- agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
- agent_env/env/envs/service_db.py +92 -0
- agent_env/env/envs/website.py +294 -0
- agent_env/env/envs/website_browser/Dockerfile +40 -0
- agent_env/env/envs/website_browser/__init__.py +17 -0
- agent_env/env/envs/website_browser/entrypoint.sh +13 -0
- agent_env/env/gateway/Dockerfile +14 -0
- agent_env/env/gateway/__init__.py +37 -0
- agent_env/env/gateway/clock.py +184 -0
- agent_env/env/gateway/constants.py +190 -0
- agent_env/env/gateway/entrypoint.py +156 -0
- agent_env/env/gateway/gateway.py +1095 -0
- agent_env/env/gateway/get_time.py +60 -0
- agent_env/env/gateway/requirements.txt +4 -0
- agent_env/env/gateway/triggers.py +1193 -0
- agent_env/env/legacy_protocol.py +146 -0
- agent_env/env/registry.py +93 -0
- agent_env/env/snapshot_store.py +372 -0
- agent_env/env/store.py +411 -0
- agent_env/eval/__init__.py +14 -0
- agent_env/eval/eval.py +68 -0
- agent_env/eval/store.py +116 -0
- agent_env/examples/hello/README.md +9 -0
- agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
- agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
- agent_env/examples/hello/tasks/hello.json +10 -0
- agent_env/explorer/__init__.py +12 -0
- agent_env/explorer/app.py +277 -0
- agent_env/explorer/openapi_docs.py +242 -0
- agent_env/explorer/plugin.py +95 -0
- agent_env/explorer/routers/__init__.py +1 -0
- agent_env/explorer/routers/common.py +174 -0
- agent_env/explorer/routers/conversations.py +25 -0
- agent_env/explorer/routers/objects.py +112 -0
- agent_env/explorer/routers/runs.py +475 -0
- agent_env/plugins/__init__.py +59 -0
- agent_env/plugins/_cli.py +244 -0
- agent_env/plugins/_discovery.py +97 -0
- agent_env/plugins/_inventory.py +262 -0
- agent_env/plugins/_registration.py +268 -0
- agent_env/plugins/_report.py +47 -0
- agent_env/plugins/_requirements.py +55 -0
- agent_env/providers/__init__.py +37 -0
- agent_env/providers/env_providers/__init__.py +12 -0
- agent_env/providers/env_providers/constants.py +13 -0
- agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
- agent_env/providers/env_providers/env_provider.py +237 -0
- agent_env/providers/env_providers/env_server_provider.py +133 -0
- agent_env/providers/env_state/__init__.py +49 -0
- agent_env/providers/env_state/env_state_provider.py +554 -0
- agent_env/providers/env_state/local_postgres.py +390 -0
- agent_env/providers/env_state/store.py +112 -0
- agent_env/providers/sandbox_providers/__init__.py +29 -0
- agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
- agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
- agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
- agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
- agent_env/providers/sandbox_providers/e2b/template.py +158 -0
- agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
- agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
- agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
- agent_env/providers/sandbox_providers/sandbox.py +387 -0
- agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
- agent_env/py.typed +0 -0
- agent_env/runner/__init__.py +16 -0
- agent_env/runner/local_runner.py +190 -0
- agent_env/runner/runner.py +127 -0
- agent_env/runner/store.py +94 -0
- agent_env/store/__init__.py +126 -0
- agent_env/store/_google.py +68 -0
- agent_env/store/base.py +33 -0
- agent_env/store/document_store/__init__.py +60 -0
- agent_env/store/document_store/document_store.py +519 -0
- agent_env/store/document_store/dynamodb_document_store.py +306 -0
- agent_env/store/document_store/evaluation.py +199 -0
- agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
- agent_env/store/document_store/mongo_document_store.py +259 -0
- agent_env/store/document_store/sqlite_document_store.py +314 -0
- agent_env/store/ids.py +123 -0
- agent_env/store/image_store/__init__.py +34 -0
- agent_env/store/image_store/ecr_image_store.py +131 -0
- agent_env/store/image_store/google_credentials.py +113 -0
- agent_env/store/image_store/image_store.py +113 -0
- agent_env/store/image_store/local_registry_image_store.py +106 -0
- agent_env/store/image_store/oci_registry_credentials.py +196 -0
- agent_env/store/local_state.py +27 -0
- agent_env/store/object_store/__init__.py +24 -0
- agent_env/store/object_store/gcs_object_store.py +415 -0
- agent_env/store/object_store/local_object_store.py +143 -0
- agent_env/store/object_store/object_store.py +198 -0
- agent_env/store/object_store/s3_object_store.py +354 -0
- agent_env/store/query.py +112 -0
- agent_env/store/routing.py +609 -0
- agent_env/store/secret_store/__init__.py +19 -0
- agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
- agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
- agent_env/store/secret_store/local_secret_store.py +47 -0
- agent_env/store/secret_store/secret_store.py +19 -0
- agent_env/task/__init__.py +27 -0
- agent_env/task/interrupts.py +109 -0
- agent_env/task/registry.py +27 -0
- agent_env/task/step_journal.py +99 -0
- agent_env/task/store.py +940 -0
- agent_env/task/task.py +815 -0
- agent_env/task/teardown.py +142 -0
- agent_env/task_step/__init__.py +47 -0
- agent_env/task_step/context.py +195 -0
- agent_env/task_step/context_ops.py +224 -0
- agent_env/task_step/registry.py +182 -0
- agent_env/task_step/review_store.py +118 -0
- agent_env/task_step/snapshot_utils/__init__.py +2 -0
- agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
- agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
- agent_env/task_step/store.py +129 -0
- agent_env/task_step/task_step.py +173 -0
- agent_env/task_step/task_steps/__init__.py +27 -0
- agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
- agent_env/task_step/task_steps/add_skills.py +240 -0
- agent_env/task_step/task_steps/apply_server_config.py +288 -0
- agent_env/task_step/task_steps/collect_artifacts.py +940 -0
- agent_env/task_step/task_steps/deploy_agent.py +626 -0
- agent_env/task_step/task_steps/deploy_env.py +197 -0
- agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
- agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
- agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
- agent_env/task_step/task_steps/install_agent.py +376 -0
- agent_env/task_step/task_steps/load_artifact.py +775 -0
- agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
- agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
- agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
- agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
- agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
- agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
- agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
- agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
- agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
- agent_env/task_step/task_steps/peer_agents.py +92 -0
- agent_env/task_step/task_steps/prompt_agent.py +900 -0
- agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
- agent_env/task_step/task_steps/register_env_triggers.py +97 -0
- agent_env/task_step/task_steps/reset_env.py +71 -0
- agent_env/task_step/task_steps/review.py +152 -0
- agent_env/task_step/task_steps/run_code.py +461 -0
- agent_env/task_step/task_steps/run_code_runner.py +52 -0
- agent_env/task_step/task_steps/run_docker_container.py +380 -0
- agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
- agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
- agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
- agent_env/task_step/task_steps/snapshot_env.py +637 -0
- agent_env/task_step/task_steps/sync_env_clock.py +132 -0
- agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
- agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
- agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
- agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
- agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
- agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
- agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
- agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
- agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
- agent_env/task_step/thread_work.py +61 -0
- agent_env/utils/__init__.py +0 -0
- agent_env/utils/card_naming.py +108 -0
- agent_env/utils/deprecation.py +109 -0
- agent_env/utils/docker_build.py +29 -0
- agent_env/utils/exec_retry.py +77 -0
- agent_env/utils/litellm_attribution.py +59 -0
- agent_env/utils/paths.py +20 -0
- agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
- agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
- agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
- agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
- agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
- agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
- agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"""Multi environment combining multiple MCP servers and websites."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING, Callable, ClassVar, Optional
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Off by default: a bake builds and pushes a multi-GB servicedb image, which is the right
|
|
11
|
+
# trade once per universe and the wrong one on every deploy. Callers that know they are
|
|
12
|
+
# seeding a reusable universe pass snapshot_after_load=True explicitly; this flag exists
|
|
13
|
+
# so an operator can turn it on for a whole worker without a code change.
|
|
14
|
+
_SNAPSHOT_AFTER_LOAD_ENV_VAR = "AGENT_ENV_SNAPSHOT_AFTER_LOAD"
|
|
15
|
+
|
|
16
|
+
# Escape hatch for the derived per-load concurrency cap: pin the old load-everything-at-once
|
|
17
|
+
# behaviour, or hold it fixed while bisecting a contention problem.
|
|
18
|
+
_LOAD_CONCURRENCY_ENV_VAR = "AGENT_ENV_LOAD_CONCURRENCY"
|
|
19
|
+
|
|
20
|
+
def _snapshot_after_load_default() -> bool:
|
|
21
|
+
return os.environ.get(_SNAPSHOT_AFTER_LOAD_ENV_VAR, "").strip().lower() in ("1", "true", "yes")
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact
|
|
25
|
+
from agent_env.providers.env_state import DatabaseStateProvider
|
|
26
|
+
|
|
27
|
+
from agent_env.env.env import Env, gateway_url_of
|
|
28
|
+
from agent_env.env.gateway import GatewayMode
|
|
29
|
+
from agent_env.env.envs._deployment import (
|
|
30
|
+
as_builtin, builtin_provider_for, close_deployed, close_replaced, deploy_refusal, deploy_through_provider, host_staging_refusal,
|
|
31
|
+
plugin_provider_like_a_builtin, provider_or_class,
|
|
32
|
+
)
|
|
33
|
+
from agent_env.env.envs.mcp_server import MCPServerEnv
|
|
34
|
+
from agent_env.env.envs.website import WebsiteEnv
|
|
35
|
+
from agent_env.store.routing import refuse_local_derivation
|
|
36
|
+
from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER
|
|
37
|
+
from agent_env.attribution import Attribution
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MultiEnv(Env):
|
|
41
|
+
type: ClassVar[str] = "multi"
|
|
42
|
+
description = "Multi environment combining multiple MCP servers and websites, deployed through the environment provider its env_provider_type names"
|
|
43
|
+
|
|
44
|
+
def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPServerEnv], website_envs: list[WebsiteEnv] | None = None, metadata: Optional[dict[str, str]] = None, name: Optional[str] = None, env_provider_type: str = "gateway"):
|
|
45
|
+
super().__init__(id, version, metadata=metadata)
|
|
46
|
+
if name is not None and (not name or any(ch.isspace() for ch in name)):
|
|
47
|
+
raise ValueError("name must be non-empty with no whitespace")
|
|
48
|
+
if not env_provider_type:
|
|
49
|
+
raise ValueError("env_provider_type cannot be empty")
|
|
50
|
+
self.name = name
|
|
51
|
+
self.env_provider_type = env_provider_type # the children's own types are ignored: this one deploys them all
|
|
52
|
+
self.mcp_server_envs = mcp_server_envs
|
|
53
|
+
self.website_envs = website_envs or []
|
|
54
|
+
self._sandbox = None
|
|
55
|
+
self._gateway_url = None
|
|
56
|
+
self._gateway_mode = GatewayMode.PERFORMANCE
|
|
57
|
+
self._env_provider = None
|
|
58
|
+
self._mcp_server_name: Optional[str] = None
|
|
59
|
+
self._deployed: Optional[DeployedEnv] = None
|
|
60
|
+
self._replaced: list[tuple] = [] # the provider and sandbox of each deployment a later deploy replaced, for close()
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
base = super().to_dict()
|
|
64
|
+
base["mcp_server_envs"] = [
|
|
65
|
+
{"id": env.id, "type": env.type, "version": env.version}
|
|
66
|
+
for env in self.mcp_server_envs
|
|
67
|
+
]
|
|
68
|
+
base["website_envs"] = [
|
|
69
|
+
{"id": env.id, "type": env.type, "version": env.version}
|
|
70
|
+
for env in self.website_envs
|
|
71
|
+
]
|
|
72
|
+
if self.name:
|
|
73
|
+
base["name"] = self.name
|
|
74
|
+
base["env_provider_type"] = self.env_provider_type
|
|
75
|
+
return base
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict) -> "MultiEnv":
|
|
79
|
+
mcp_server_envs = [
|
|
80
|
+
Env.get(ref["id"], version=ref["version"])
|
|
81
|
+
for ref in data.get("mcp_server_envs", [])
|
|
82
|
+
]
|
|
83
|
+
website_envs = [
|
|
84
|
+
Env.get(ref["id"], version=ref["version"])
|
|
85
|
+
for ref in data.get("website_envs", [])
|
|
86
|
+
]
|
|
87
|
+
return cls(id=data["id"], version=data.get("version"), mcp_server_envs=mcp_server_envs, website_envs=website_envs, metadata=data.get("metadata", {}),
|
|
88
|
+
name=data.get("name"), env_provider_type=data.get("env_provider_type", "gateway"))
|
|
89
|
+
|
|
90
|
+
async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, priority: Optional[int] = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv:
|
|
91
|
+
deployed_env = await deploy_through_provider(
|
|
92
|
+
self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type,
|
|
93
|
+
disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, priority=priority,
|
|
94
|
+
env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution,
|
|
95
|
+
)
|
|
96
|
+
self._gateway_mode = gateway_mode
|
|
97
|
+
self._mcp_server_name = deployed_env.mcp_server_name
|
|
98
|
+
_hand_provider_to_child_envs(self)
|
|
99
|
+
_hand_record_to_child_envs(self, deployed_env)
|
|
100
|
+
return deployed_env
|
|
101
|
+
|
|
102
|
+
def deploy_refusal(self, **options) -> str | None:
|
|
103
|
+
"""Why deploy() would refuse these options before building anything, or None; raises, as deploy() does, for a type this process can't find."""
|
|
104
|
+
return deploy_refusal(self, provider_or_class(self), options)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
async def from_deployed_env(cls, deployed: DeployedEnv) -> MultiEnv:
|
|
108
|
+
env = Env.get(deployed.env_id, deployed.env_version)
|
|
109
|
+
if not isinstance(env, MultiEnv):
|
|
110
|
+
raise TypeError(f"Expected MultiEnv, got {type(env).__name__}")
|
|
111
|
+
provider = builtin_provider_for(env.env_provider_type) or plugin_provider_like_a_builtin(env.env_provider_type)
|
|
112
|
+
if builtin := as_builtin(provider):
|
|
113
|
+
env._sandbox = await builtin._reattach(env, deployed)
|
|
114
|
+
# Any other plugin's deployment holds its own sandboxes: its children load through its card, and nothing is reattached.
|
|
115
|
+
env._env_provider = provider
|
|
116
|
+
env._gateway_url = gateway_url_of(deployed)
|
|
117
|
+
env._deployed = deployed
|
|
118
|
+
_hand_provider_to_child_envs(env)
|
|
119
|
+
_hand_record_to_child_envs(env, deployed)
|
|
120
|
+
env._instance_id = deployed.instance_id
|
|
121
|
+
env._mcp_server_name = deployed.mcp_server_name
|
|
122
|
+
return env
|
|
123
|
+
|
|
124
|
+
async def load_environment_artifact(self, environment_artifact: EnvironmentArtifact) -> None:
|
|
125
|
+
# A built-in's deploy, or a caller wiring one up by hand, leaves a gateway; a plugin's leaves only its record.
|
|
126
|
+
if self._gateway_url is None and self._deployed is None:
|
|
127
|
+
raise RuntimeError("Environment not deployed - call deploy() first")
|
|
128
|
+
# MCP servers are checked first; if a website shares the same environment_name, the MCP server wins.
|
|
129
|
+
for env in self.mcp_server_envs:
|
|
130
|
+
if env.environment_name == environment_artifact.environment_name:
|
|
131
|
+
await env.load_environment_artifact(environment_artifact)
|
|
132
|
+
return
|
|
133
|
+
for env in self.website_envs:
|
|
134
|
+
if env.environment_name == environment_artifact.environment_name:
|
|
135
|
+
await env.load_environment_artifact(environment_artifact)
|
|
136
|
+
return
|
|
137
|
+
logger.warning(f"No env with environment_name '{environment_artifact.environment_name}' — skipping")
|
|
138
|
+
|
|
139
|
+
async def load_environment_universe_artifact(
|
|
140
|
+
self,
|
|
141
|
+
environment_universe_artifact: EnvironmentUniverseArtifact,
|
|
142
|
+
snapshot_after_load: bool | None = None,
|
|
143
|
+
) -> LoadEnvironmentUniverseArtifactResult:
|
|
144
|
+
"""Seed this env from a universe artifact, preferring a pre-baked snapshot.
|
|
145
|
+
|
|
146
|
+
A snapshot restore swaps a pre-ingested servicedb image; a miss re-ingests every
|
|
147
|
+
service over HTTP under a fixed 600s per-service cap.
|
|
148
|
+
|
|
149
|
+
Restore is NOT reliably faster. Measured on a 13-service / ~8.7GB universe:
|
|
150
|
+
restore ~4min (an image pull plus recreating every service container) versus
|
|
151
|
+
~3.5min to re-ingest on 8 vCPU. What restore buys is a bound — no per-service
|
|
152
|
+
timeout to squeeze under — and far less CPU: the same re-ingest FAILS on the
|
|
153
|
+
1-vCPU sandbox default, where a 409MB service blew its cap while a 3.5GB one
|
|
154
|
+
succeeded. Re-ingest cost tracks record count, not bytes, so the busier the
|
|
155
|
+
universe the better restore looks.
|
|
156
|
+
|
|
157
|
+
``snapshot_after_load`` bakes a reusable snapshot after a successful re-ingest so
|
|
158
|
+
the next load of the same (images, universe) pair can restore instead. It defaults
|
|
159
|
+
to the ``AGENT_ENV_SNAPSHOT_AFTER_LOAD`` config flag (off), because a bake builds
|
|
160
|
+
and pushes a multi-GB servicedb image (~5min, ~2.3GB stored) — worth it to make a
|
|
161
|
+
universe cheap to re-serve, not worth it on a one-off deploy. The bake is
|
|
162
|
+
best-effort and never fails a load that already succeeded.
|
|
163
|
+
"""
|
|
164
|
+
import uuid
|
|
165
|
+
from agent_env.env.env import LoadEnvironmentUniverseArtifactResult
|
|
166
|
+
from agent_env.env.snapshot_store import compute_env_fingerprint, get_env_snapshot_store
|
|
167
|
+
|
|
168
|
+
# Refused before any service is touched, rather than after they all load.
|
|
169
|
+
if (refusal := host_staging_refusal(self, "Staging a universe's metadata files onto the env's host")) \
|
|
170
|
+
and environment_universe_artifact.get_metadata():
|
|
171
|
+
raise RuntimeError(refusal)
|
|
172
|
+
|
|
173
|
+
# Check for a clean snapshot before doing slow REST-based loading.
|
|
174
|
+
|
|
175
|
+
# Shortcutting data-load by using a snapshot is only supported when the env_state_type is local_postgres -
|
|
176
|
+
# a remote-backed deploy has no local servicedb container to swap, so it falls through to
|
|
177
|
+
# the normal load. So does a plugin's deployment, which has no state provider of ours.
|
|
178
|
+
state_provider = _gateway_state_provider(self)
|
|
179
|
+
can_restore = state_provider is not None and state_provider.supports_restore_from_snapshot
|
|
180
|
+
# Keyed on the service images rather than self.id, so re-registering the same
|
|
181
|
+
# servers under a new env id still hits (and a rebuilt server correctly misses).
|
|
182
|
+
env_fingerprint = compute_env_fingerprint(self) if can_restore else None
|
|
183
|
+
snapshot = get_env_snapshot_store().get_clean(
|
|
184
|
+
self.id,
|
|
185
|
+
environment_universe_artifact.id,
|
|
186
|
+
environment_universe_artifact.version,
|
|
187
|
+
env_fingerprint=env_fingerprint,
|
|
188
|
+
) if can_restore else None
|
|
189
|
+
restored_from_snapshot = False
|
|
190
|
+
snapshot_baked: bool | None = None
|
|
191
|
+
snapshot_bake_error: str | None = None
|
|
192
|
+
|
|
193
|
+
if snapshot:
|
|
194
|
+
logger.info(f"Found clean snapshot for env={self.id} universe={environment_universe_artifact.id}: artifact={snapshot.db_image_artifact_id} v{snapshot.db_image_artifact_version} instance={snapshot.instance_id}")
|
|
195
|
+
await self._load_from_snapshot(snapshot)
|
|
196
|
+
restored_from_snapshot = True
|
|
197
|
+
# A restore swaps the whole database at once, so per-service resume state from
|
|
198
|
+
# any earlier partial re-ingest no longer describes anything real.
|
|
199
|
+
if self._instance_id:
|
|
200
|
+
try:
|
|
201
|
+
from agent_env.env.store import get_env_instance_store
|
|
202
|
+
get_env_instance_store().clear_loaded_environments(self._instance_id)
|
|
203
|
+
except Exception as e: # noqa: BLE001
|
|
204
|
+
logger.warning("Could not clear resume state after a snapshot restore: %s", e)
|
|
205
|
+
else:
|
|
206
|
+
import asyncio
|
|
207
|
+
import time
|
|
208
|
+
environment_artifacts = environment_universe_artifact.get_environment_artifacts()
|
|
209
|
+
total = len(environment_artifacts)
|
|
210
|
+
# WARNING, not info: this is the path that can FAIL. Every service is
|
|
211
|
+
# re-ingested over HTTP under a fixed per-service timeout, and one service
|
|
212
|
+
# exceeding it fails the whole load -- which on an under-provisioned VM is the
|
|
213
|
+
# normal outcome, not the edge case. The fingerprint belongs in the log
|
|
214
|
+
# because "no snapshot was ever baked" and "one exists but the service images
|
|
215
|
+
# changed" look identical from the outside and call for different fixes.
|
|
216
|
+
logger.warning(
|
|
217
|
+
"No clean snapshot for env=%s (fingerprint=%s) universe=%s v%s — "
|
|
218
|
+
"re-ingesting all %d services over HTTP, each under its own timeout.%s",
|
|
219
|
+
self.id, env_fingerprint, environment_universe_artifact.id,
|
|
220
|
+
environment_universe_artifact.version, total,
|
|
221
|
+
" Bake a snapshot to make subsequent loads an image swap instead." if can_restore else "",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Bound how many load at once. Unbounded, every service races every other for
|
|
225
|
+
# the same cores, and since each one is separately capped at
|
|
226
|
+
# DATA_PLANE_LOAD_TIMEOUT_S, contention doesn't just slow the load down -- it
|
|
227
|
+
# fails it. Measured on this universe: 13-way on 1 vCPU gives each service
|
|
228
|
+
# ~1/13th of a core and a 409MB service exceeds its 600s cap, while a 3.5GB one
|
|
229
|
+
# finishes; the cost tracks record count, not bytes. Throttling trades total
|
|
230
|
+
# wall-clock (which nothing caps) for per-service headroom (which is capped).
|
|
231
|
+
# Resume an interrupted load instead of re-running all of it. A step retry
|
|
232
|
+
# re-enters this step from scratch, and because the load is destructive
|
|
233
|
+
# (reset-then-add per service) that meant re-wiping and re-ingesting every
|
|
234
|
+
# service -- three attempts x the full universe is how one slow service became
|
|
235
|
+
# a ~44 minute failure.
|
|
236
|
+
already = await self._resumable_environments(environment_universe_artifact, total)
|
|
237
|
+
if already:
|
|
238
|
+
environment_artifacts = [
|
|
239
|
+
a for a in environment_artifacts if a.environment_name not in already
|
|
240
|
+
]
|
|
241
|
+
logger.warning(
|
|
242
|
+
"Resuming an interrupted load of %s v%s: skipping %d already-loaded "
|
|
243
|
+
"service(s) %s, loading the remaining %d",
|
|
244
|
+
environment_universe_artifact.id, environment_universe_artifact.version,
|
|
245
|
+
len(already), sorted(already), len(environment_artifacts),
|
|
246
|
+
)
|
|
247
|
+
total = len(environment_artifacts)
|
|
248
|
+
if not environment_artifacts:
|
|
249
|
+
logger.info("Every service was already loaded; nothing to re-ingest")
|
|
250
|
+
|
|
251
|
+
limit = await self._load_concurrency_limit(total) if total else 1
|
|
252
|
+
semaphore = asyncio.Semaphore(limit)
|
|
253
|
+
|
|
254
|
+
async def _load_one(idx: int, artifact):
|
|
255
|
+
name = artifact.environment_name
|
|
256
|
+
async with semaphore:
|
|
257
|
+
# Timed inside the semaphore: the per-service timeout only starts once
|
|
258
|
+
# the work does, so queue time must not be charged against it. A
|
|
259
|
+
# duration here is comparable to DATA_PLANE_LOAD_TIMEOUT_S.
|
|
260
|
+
t0 = time.monotonic()
|
|
261
|
+
logger.info(f"[{idx}/{total}] loading {name} ...")
|
|
262
|
+
print(f"[{idx}/{total}] loading {name} ...", flush=True)
|
|
263
|
+
try:
|
|
264
|
+
await self.load_environment_artifact(artifact)
|
|
265
|
+
except BaseException as e: # noqa: BLE001 — includes CancelledError
|
|
266
|
+
dt = time.monotonic() - t0
|
|
267
|
+
logger.error(f"[{idx}/{total}] FAIL {name} after {dt:.0f}s: {type(e).__name__}: {e}")
|
|
268
|
+
print(f"[{idx}/{total}] ✗ {name} FAILED after {dt:.0f}s: {type(e).__name__}: {e}", flush=True)
|
|
269
|
+
raise
|
|
270
|
+
dt = time.monotonic() - t0
|
|
271
|
+
# Recorded per service, immediately, rather than once at the end: the
|
|
272
|
+
# whole point is that a load which dies partway leaves usable progress.
|
|
273
|
+
self._record_loaded_environment(environment_universe_artifact, name)
|
|
274
|
+
logger.info(f"[{idx}/{total}] OK {name} in {dt:.0f}s")
|
|
275
|
+
print(f"[{idx}/{total}] ✓ {name} loaded in {dt:.0f}s", flush=True)
|
|
276
|
+
return dt
|
|
277
|
+
|
|
278
|
+
# Key failures by position, not environment_name: a duplicate service
|
|
279
|
+
# must not let one success mask another's failure.
|
|
280
|
+
outcomes = await asyncio.gather(
|
|
281
|
+
*(_load_one(i, a) for i, a in enumerate(environment_artifacts, 1)),
|
|
282
|
+
return_exceptions=True,
|
|
283
|
+
)
|
|
284
|
+
failed = [
|
|
285
|
+
(i, environment_artifacts[i - 1].environment_name, outcome)
|
|
286
|
+
for i, outcome in enumerate(outcomes, 1)
|
|
287
|
+
if isinstance(outcome, BaseException)
|
|
288
|
+
]
|
|
289
|
+
if failed:
|
|
290
|
+
raise RuntimeError(
|
|
291
|
+
f"Failed to load {len(failed)}/{total} services: "
|
|
292
|
+
+ ", ".join(
|
|
293
|
+
f"[{i}/{total}] {name} ({type(e).__name__}: {e})"
|
|
294
|
+
for i, name, e in failed
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
if self._instance_id:
|
|
298
|
+
from agent_env.env.store import update_env_instance_environment_universe
|
|
299
|
+
update_env_instance_environment_universe(self._instance_id, environment_universe_artifact.id, environment_universe_artifact.version)
|
|
300
|
+
|
|
301
|
+
# Bake the snapshot that makes the NEXT load of this (images, universe) pair an
|
|
302
|
+
# image swap. Deliberately placed here: after the universe stamp, which
|
|
303
|
+
# EnvSnapshot.create reads back off the instance record, and before the metadata
|
|
304
|
+
# download, so the captured PGDATA reflects the data load and nothing else.
|
|
305
|
+
if not restored_from_snapshot and can_restore:
|
|
306
|
+
if snapshot_after_load is None:
|
|
307
|
+
snapshot_after_load = _snapshot_after_load_default()
|
|
308
|
+
if snapshot_after_load:
|
|
309
|
+
snapshot_baked, snapshot_bake_error = await self._bake_snapshot_after_load(
|
|
310
|
+
environment_universe_artifact
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
metadata_filepaths: dict[str, str] = {}
|
|
314
|
+
metadata_artifacts = environment_universe_artifact.get_metadata()
|
|
315
|
+
if metadata_artifacts:
|
|
316
|
+
if self._sandbox is None:
|
|
317
|
+
raise RuntimeError("Environment not deployed - call deploy() first")
|
|
318
|
+
dest_dir = f"/tmp/metadata-{uuid.uuid4().hex[:8]}"
|
|
319
|
+
if self._sandbox.mode != SANDBOX_MODE_CONTAINER:
|
|
320
|
+
await self._sandbox.exec_script(f"mkdir -p {dest_dir}")
|
|
321
|
+
for key, file_artifact in metadata_artifacts.items():
|
|
322
|
+
dest_path = f"{dest_dir}/{file_artifact.filename}"
|
|
323
|
+
logger.info(f"Downloading metadata '{key}' -> {dest_path}")
|
|
324
|
+
if self._sandbox.mode == SANDBOX_MODE_CONTAINER:
|
|
325
|
+
await self._sandbox.write_file_from_s3(file_artifact.object_url, dest_path)
|
|
326
|
+
else:
|
|
327
|
+
await self._sandbox.load_s3_file(file_artifact.object_url, dest_path)
|
|
328
|
+
metadata_filepaths[key] = dest_path
|
|
329
|
+
logger.info(f"Successfully downloaded metadata '{key}'")
|
|
330
|
+
return LoadEnvironmentUniverseArtifactResult(
|
|
331
|
+
metadata_filepaths=metadata_filepaths,
|
|
332
|
+
restored_from_snapshot=restored_from_snapshot,
|
|
333
|
+
snapshot_db_image_artifact_id=snapshot.db_image_artifact_id if snapshot else None,
|
|
334
|
+
snapshot_baked=snapshot_baked,
|
|
335
|
+
snapshot_bake_error=snapshot_bake_error,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async def _resumable_environments(self, environment_universe_artifact, total: int) -> set[str]:
|
|
339
|
+
"""Services safe to skip because a previous attempt already loaded them.
|
|
340
|
+
|
|
341
|
+
Gated on the changelog being EMPTY, which is what keeps this from quietly breaking
|
|
342
|
+
the documented contract that loading a universe is destructive. Two cases have to be
|
|
343
|
+
told apart, and the changelog is what distinguishes them:
|
|
344
|
+
|
|
345
|
+
* A retry of an interrupted load. Nothing has mutated the data (load triggers are
|
|
346
|
+
installed only after each service's own load), so the changelog is empty and the
|
|
347
|
+
recorded services are still exactly what the artifact says. Skipping is correct
|
|
348
|
+
and saves re-wiping them.
|
|
349
|
+
* A deliberate re-load to reset an env an agent has been working in. The changelog
|
|
350
|
+
is non-empty, so NOTHING is skipped and every service is wiped and re-seeded --
|
|
351
|
+
today's behaviour, which callers depend on.
|
|
352
|
+
|
|
353
|
+
Coarse on purpose: the changelog is global, not per-service, so any mutation
|
|
354
|
+
anywhere disables resume entirely. That errs toward doing redundant work rather than
|
|
355
|
+
toward leaving stale data in place, which is the right way to be wrong here.
|
|
356
|
+
"""
|
|
357
|
+
if not self._instance_id or self._sandbox is None:
|
|
358
|
+
return set()
|
|
359
|
+
if self._sandbox.mode == SANDBOX_MODE_CONTAINER:
|
|
360
|
+
return set()
|
|
361
|
+
try:
|
|
362
|
+
from agent_env.env.snapshot_store import _check_changelog_empty
|
|
363
|
+
from agent_env.env.store import get_env_instance_store
|
|
364
|
+
|
|
365
|
+
recorded = set(get_env_instance_store().get_loaded_environments(
|
|
366
|
+
self._instance_id, environment_universe_artifact.id, environment_universe_artifact.version,
|
|
367
|
+
))
|
|
368
|
+
if not recorded:
|
|
369
|
+
return set()
|
|
370
|
+
if not await _check_changelog_empty(self._sandbox):
|
|
371
|
+
logger.info(
|
|
372
|
+
"Not resuming: the changelog is non-empty, so this env's data has been "
|
|
373
|
+
"modified since it was loaded and every service must be re-seeded"
|
|
374
|
+
)
|
|
375
|
+
return set()
|
|
376
|
+
if len(recorded) >= total:
|
|
377
|
+
# A complete prior load with an untouched changelog. Re-seeding would be a
|
|
378
|
+
# no-op, but skipping everything would silently turn a caller's explicit
|
|
379
|
+
# load into nothing -- so do the work rather than guess at intent.
|
|
380
|
+
logger.info(
|
|
381
|
+
"All %d services are recorded as loaded and the changelog is clean; "
|
|
382
|
+
"re-loading anyway rather than turning an explicit load into a no-op",
|
|
383
|
+
total,
|
|
384
|
+
)
|
|
385
|
+
return set()
|
|
386
|
+
return recorded
|
|
387
|
+
except Exception as e: # noqa: BLE001 -- an optimisation, not a dependency
|
|
388
|
+
logger.warning("Could not determine resumable services (%s); loading all of them", e)
|
|
389
|
+
return set()
|
|
390
|
+
|
|
391
|
+
def _record_loaded_environment(self, environment_universe_artifact, environment_name: str) -> None:
|
|
392
|
+
"""Persist one service's completion. Best-effort: losing a record costs redundant
|
|
393
|
+
work on a retry, whereas raising here would fail a service that actually loaded."""
|
|
394
|
+
if not self._instance_id:
|
|
395
|
+
return
|
|
396
|
+
try:
|
|
397
|
+
from agent_env.env.store import get_env_instance_store
|
|
398
|
+
|
|
399
|
+
get_env_instance_store().record_loaded_environment(
|
|
400
|
+
self._instance_id, environment_universe_artifact.id,
|
|
401
|
+
environment_universe_artifact.version, environment_name,
|
|
402
|
+
)
|
|
403
|
+
except Exception as e: # noqa: BLE001
|
|
404
|
+
logger.warning(
|
|
405
|
+
"Could not record %s as loaded (%s); a retry will re-load it",
|
|
406
|
+
environment_name, e,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
async def _load_concurrency_limit(self, total: int) -> int:
|
|
410
|
+
"""How many services may load at once.
|
|
411
|
+
|
|
412
|
+
Derived from the sandbox's actual core count rather than a constant, because the
|
|
413
|
+
failure this prevents is contention-driven: the per-service data-plane timeout is
|
|
414
|
+
fixed, so what matters is how much CPU each concurrent load can get. Two in flight
|
|
415
|
+
per core keeps some I/O overlap while leaving each service a real share.
|
|
416
|
+
|
|
417
|
+
At >= ceil(total/2) cores this is a no-op -- a well-provisioned VM keeps loading
|
|
418
|
+
everything at once, which is what the measured 8 vCPU run did. It only bites on the
|
|
419
|
+
small boxes where unbounded concurrency was turning a slow load into a failed one.
|
|
420
|
+
|
|
421
|
+
``AGENT_ENV_LOAD_CONCURRENCY`` overrides it outright, for pinning the old behaviour
|
|
422
|
+
or bisecting a contention problem. Falls back to loading everything at once if the
|
|
423
|
+
core count can't be read: that is today's behaviour, so a probe failure must not
|
|
424
|
+
quietly change how a load runs.
|
|
425
|
+
"""
|
|
426
|
+
override = os.environ.get(_LOAD_CONCURRENCY_ENV_VAR, "").strip()
|
|
427
|
+
if override:
|
|
428
|
+
try:
|
|
429
|
+
value = int(override)
|
|
430
|
+
if value < 1:
|
|
431
|
+
raise ValueError(f"must be >= 1, got {value}")
|
|
432
|
+
except ValueError as e:
|
|
433
|
+
# Loud, not silent: a typo'd override that fell back to the default would
|
|
434
|
+
# make a contention experiment quietly measure the wrong thing.
|
|
435
|
+
raise ValueError(
|
|
436
|
+
f"{_LOAD_CONCURRENCY_ENV_VAR}={override!r} is not a positive integer: {e}"
|
|
437
|
+
) from None
|
|
438
|
+
limit = min(value, total)
|
|
439
|
+
logger.info("Load concurrency %d/%d (from %s)", limit, total, _LOAD_CONCURRENCY_ENV_VAR)
|
|
440
|
+
return limit
|
|
441
|
+
|
|
442
|
+
cores = None
|
|
443
|
+
if self._sandbox is not None:
|
|
444
|
+
try:
|
|
445
|
+
cores = int((await self._sandbox.exec_script("nproc")).strip())
|
|
446
|
+
except Exception as e: # noqa: BLE001 — a probe, not a dependency
|
|
447
|
+
logger.warning("Could not read sandbox core count (%s); loading all %d services at once", e, total)
|
|
448
|
+
if not cores or cores < 1:
|
|
449
|
+
return total
|
|
450
|
+
|
|
451
|
+
limit = max(1, min(total, cores * 2))
|
|
452
|
+
logger.info(
|
|
453
|
+
"Load concurrency %d/%d services (sandbox has %d core(s))", limit, total, cores,
|
|
454
|
+
)
|
|
455
|
+
return limit
|
|
456
|
+
|
|
457
|
+
async def _bake_snapshot_after_load(
|
|
458
|
+
self, environment_universe_artifact: EnvironmentUniverseArtifact
|
|
459
|
+
) -> tuple[bool, str | None]:
|
|
460
|
+
"""Capture a reusable clean snapshot of what was just loaded.
|
|
461
|
+
|
|
462
|
+
Best-effort by contract: the data load already succeeded, and the caller wants a
|
|
463
|
+
seeded env more than it wants a cache entry. Any failure here is logged and
|
|
464
|
+
reported in the result, never raised — otherwise adding a cache would turn
|
|
465
|
+
working loads into failures.
|
|
466
|
+
|
|
467
|
+
Returns ``(baked, error)``.
|
|
468
|
+
"""
|
|
469
|
+
import asyncio
|
|
470
|
+
|
|
471
|
+
from agent_env.env.snapshot_store import EnvSnapshot
|
|
472
|
+
|
|
473
|
+
if not self._instance_id:
|
|
474
|
+
# create() resolves the env and its loaded universe from the instance record,
|
|
475
|
+
# so an unregistered env (ad-hoc/local deploy) has nothing to key a snapshot on.
|
|
476
|
+
msg = "no instance_id on this env; snapshot capture needs a registered env instance"
|
|
477
|
+
logger.warning("Skipping snapshot bake for env=%s: %s", self.id, msg)
|
|
478
|
+
return False, msg
|
|
479
|
+
try:
|
|
480
|
+
logger.info(
|
|
481
|
+
"Baking clean snapshot for env=%s universe=%s v%s (instance=%s) so the "
|
|
482
|
+
"next load can skip the re-ingest...",
|
|
483
|
+
self.id, environment_universe_artifact.id,
|
|
484
|
+
environment_universe_artifact.version, self._instance_id,
|
|
485
|
+
)
|
|
486
|
+
snapshot = await EnvSnapshot.create(self._instance_id)
|
|
487
|
+
except asyncio.CancelledError:
|
|
488
|
+
# Cancellation is control flow, not a bake failure. Swallowing it here would
|
|
489
|
+
# let a cancelled/timed-out activity carry on and report success.
|
|
490
|
+
raise
|
|
491
|
+
except Exception as e: # noqa: BLE001 — a cache miss must not fail a good load
|
|
492
|
+
logger.warning(
|
|
493
|
+
"Snapshot bake failed for env=%s universe=%s: %s: %s (the load itself "
|
|
494
|
+
"succeeded; the next load will take the slow path)",
|
|
495
|
+
self.id, environment_universe_artifact.id, type(e).__name__, e,
|
|
496
|
+
exc_info=True,
|
|
497
|
+
)
|
|
498
|
+
return False, f"{type(e).__name__}: {e}"
|
|
499
|
+
if not snapshot.is_clean:
|
|
500
|
+
# create() derives is_clean from an empty changelog. Landing here means
|
|
501
|
+
# something mutated the data between load and capture, so the snapshot exists
|
|
502
|
+
# but get_clean will never serve it — say so rather than reporting success.
|
|
503
|
+
msg = (
|
|
504
|
+
"captured snapshot is dirty (changelog non-empty), so it will not be "
|
|
505
|
+
"reused; something wrote to the env between load and capture"
|
|
506
|
+
)
|
|
507
|
+
logger.warning("Snapshot bake for env=%s: %s", self.id, msg)
|
|
508
|
+
return False, msg
|
|
509
|
+
logger.info(
|
|
510
|
+
"Baked clean snapshot for env=%s universe=%s v%s: image=%s v%s",
|
|
511
|
+
self.id, environment_universe_artifact.id, environment_universe_artifact.version,
|
|
512
|
+
snapshot.db_image_artifact_id, snapshot.db_image_artifact_version,
|
|
513
|
+
)
|
|
514
|
+
return True, None
|
|
515
|
+
|
|
516
|
+
async def load_file_artifact_universe(self, file_artifact_universe: "Any", destination_path: Optional[str] = None,
|
|
517
|
+
) -> "LoadFileArtifactUniverseResult":
|
|
518
|
+
if refusal := host_staging_refusal(self, "Staging files onto the env's host"):
|
|
519
|
+
raise RuntimeError(refusal)
|
|
520
|
+
return await super().load_file_artifact_universe(file_artifact_universe, destination_path)
|
|
521
|
+
|
|
522
|
+
async def load_artifact(self, artifact):
|
|
523
|
+
from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact
|
|
524
|
+
if isinstance(artifact, EnvironmentUniverseArtifact):
|
|
525
|
+
return await self.load_environment_universe_artifact(artifact)
|
|
526
|
+
if isinstance(artifact, EnvironmentArtifact):
|
|
527
|
+
return await self.load_environment_artifact(artifact)
|
|
528
|
+
raise ValueError(f"{type(self).__name__} '{self.id}' cannot load artifact '{getattr(artifact, 'id', '?')}' of type '{getattr(artifact, 'type', '?')}' — expected a EnvironmentArtifact or EnvironmentUniverseArtifact")
|
|
529
|
+
|
|
530
|
+
async def _load_from_snapshot(self, snapshot) -> None:
|
|
531
|
+
"""Swap the servicedb container with a pre-loaded snapshot image. Only supported when both the current deploy and the snapshot has env_state_type set to local_postgres"""
|
|
532
|
+
import asyncio
|
|
533
|
+
from agent_env.artifact import Artifact, DockerImageArtifact
|
|
534
|
+
from agent_env.env.env import Env
|
|
535
|
+
from agent_env.env.envs.service_db import DB_USER
|
|
536
|
+
from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT
|
|
537
|
+
from agent_env.providers import EnvironmentGatewayProvider, MCPServerConfig, WebsiteConfig
|
|
538
|
+
from agent_env.env.envs.service_db import DATABASE_SERVICE_NAME, DB_MCP_SERVICE_NAME, PGWEB_SERVICE_NAME
|
|
539
|
+
from agent_env.providers.env_providers.constants import DOCKER_COMPOSE_PATH, GATEWAY_APP_DIR, GATEWAY_SERVICE_NAME
|
|
540
|
+
from agent_env.providers.env_state import LocalPostgresStateProvider
|
|
541
|
+
from agent_env.config import get_config
|
|
542
|
+
|
|
543
|
+
if self._sandbox is None:
|
|
544
|
+
raise RuntimeError("Environment not deployed - call deploy() first")
|
|
545
|
+
if self._sandbox.mode == SANDBOX_MODE_CONTAINER:
|
|
546
|
+
raise NotImplementedError("Snapshot loading not supported on container mode")
|
|
547
|
+
db_image = Artifact.get(snapshot.db_image_artifact_id, snapshot.db_image_artifact_version)
|
|
548
|
+
if not isinstance(db_image, DockerImageArtifact):
|
|
549
|
+
raise RuntimeError(f"Snapshot artifact {snapshot.db_image_artifact_id} is not a DockerImageArtifact")
|
|
550
|
+
logger.info(f"Loading snapshot image {db_image.image_name} into sandbox...")
|
|
551
|
+
await self._sandbox.load_docker_images([db_image])
|
|
552
|
+
|
|
553
|
+
# Regenerate docker-compose.yml with the snapshot image (no init-schemas volume needed)
|
|
554
|
+
logger.info("Regenerating docker-compose.yml with snapshot image...")
|
|
555
|
+
config = get_config()
|
|
556
|
+
gateway_env = Env.get(config.default_gateway_env_id)
|
|
557
|
+
service_db = Env.get(config.default_service_db_env_id)
|
|
558
|
+
service_db_config = service_db.to_config()
|
|
559
|
+
service_db_config.db_image = db_image.image_name
|
|
560
|
+
|
|
561
|
+
gw = EnvironmentGatewayProvider()
|
|
562
|
+
gw._sandbox = self._sandbox
|
|
563
|
+
gw._state_provider = LocalPostgresStateProvider(service_db_config=service_db_config)
|
|
564
|
+
gw._state_instance = LocalPostgresStateProvider.default_instance()
|
|
565
|
+
mcp_servers = [MCPServerConfig(image=env.docker_image_artifact.image_name, environment_name=env.environment_name) for env in self.mcp_server_envs]
|
|
566
|
+
website_configs = [
|
|
567
|
+
WebsiteConfig(backend_image=env.backend_docker_image_artifact.image_name, frontend_image=env.frontend_docker_image_artifact.image_name, environment_name=env.environment_name)
|
|
568
|
+
for env in self.website_envs
|
|
569
|
+
]
|
|
570
|
+
if website_configs:
|
|
571
|
+
website_browser_env = Env.get(config.default_website_browser_env_id)
|
|
572
|
+
mcp_servers = list(mcp_servers) + [MCPServerConfig(image=website_browser_env.docker_image_artifact.image_name, environment_name=website_browser_env.environment_name)]
|
|
573
|
+
|
|
574
|
+
compose_content = gw.create_docker_compose(
|
|
575
|
+
mcp_servers=mcp_servers,
|
|
576
|
+
gateway_image=gateway_env.docker_image_artifact.image_name,
|
|
577
|
+
gateway_port=AGENT_ENV_GATEWAY_MCP_PORT, website_configs=website_configs or None,
|
|
578
|
+
gateway_mode=self._gateway_mode,
|
|
579
|
+
state_provider=gw._state_provider, # local-only, set above
|
|
580
|
+
state_instance=gw._state_instance,
|
|
581
|
+
mcp_server_name=self._mcp_server_name or self.name,
|
|
582
|
+
)
|
|
583
|
+
compose_content = compose_content.replace(
|
|
584
|
+
" volumes:\n - ./init-schemas.sql:/docker-entrypoint-initdb.d/init-schemas.sql\n", "",
|
|
585
|
+
)
|
|
586
|
+
write_script = f'''cat > {DOCKER_COMPOSE_PATH} << 'COMPOSE_EOF'
|
|
587
|
+
{compose_content}
|
|
588
|
+
COMPOSE_EOF'''
|
|
589
|
+
await self._sandbox.exec_script(write_script)
|
|
590
|
+
|
|
591
|
+
# Remove old servicedb container and its anonymous volume, then start fresh from snapshot image
|
|
592
|
+
logger.info("Recreating servicedb container with snapshot image...")
|
|
593
|
+
await self._sandbox.exec_script(
|
|
594
|
+
f"cd {GATEWAY_APP_DIR} && docker compose rm -sf -v {DATABASE_SERVICE_NAME} && docker compose up -d {DATABASE_SERVICE_NAME} 2>&1"
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
# Wait for servicedb to be healthy
|
|
598
|
+
logger.info("Waiting for servicedb to be healthy...")
|
|
599
|
+
for i in range(60):
|
|
600
|
+
try:
|
|
601
|
+
await self._sandbox.exec_script(
|
|
602
|
+
f"docker compose -f {DOCKER_COMPOSE_PATH} exec -T {DATABASE_SERVICE_NAME} pg_isready -U {DB_USER}",
|
|
603
|
+
)
|
|
604
|
+
logger.info(f"servicedb healthy after {i + 1}s")
|
|
605
|
+
break
|
|
606
|
+
except Exception:
|
|
607
|
+
if i % 10 == 0:
|
|
608
|
+
logger.info(f"Still waiting for servicedb... ({i}s)")
|
|
609
|
+
await asyncio.sleep(1)
|
|
610
|
+
else:
|
|
611
|
+
raise RuntimeError("servicedb not healthy after 60s")
|
|
612
|
+
|
|
613
|
+
# Recreate non-servicedb services so they reconnect to the new servicedb.
|
|
614
|
+
# Uses `up -d --force-recreate` which respects depends_on ordering
|
|
615
|
+
# (MCP servers start before gateway), unlike `restart` which is unordered.
|
|
616
|
+
environment_names = list(dict.fromkeys(
|
|
617
|
+
[env.environment_name for env in self.mcp_server_envs] + [env.environment_name for env in self.website_envs]
|
|
618
|
+
))
|
|
619
|
+
svc_list = " ".join(environment_names + [GATEWAY_SERVICE_NAME, PGWEB_SERVICE_NAME, DB_MCP_SERVICE_NAME])
|
|
620
|
+
logger.info(f"Recreating services: {svc_list}")
|
|
621
|
+
await self._sandbox.exec_script(
|
|
622
|
+
f"cd {GATEWAY_APP_DIR} && docker compose up -d --force-recreate {svc_list}"
|
|
623
|
+
)
|
|
624
|
+
await gw._wait_for_gateway(self._sandbox, AGENT_ENV_GATEWAY_MCP_PORT)
|
|
625
|
+
|
|
626
|
+
# Install changelog triggers for each service
|
|
627
|
+
for name in environment_names:
|
|
628
|
+
await gw.install_changelog_triggers(name)
|
|
629
|
+
|
|
630
|
+
async def validate(self, on_progress: Callable[[str], None] | None = None) -> str:
|
|
631
|
+
"""Run the basic env-card validator task and return the task instance ID.
|
|
632
|
+
|
|
633
|
+
Deploys the env, fetches + persists its composed EnvironmentCard, then tears down.
|
|
634
|
+
"""
|
|
635
|
+
refuse_local_derivation(self.id, "env", "validating")
|
|
636
|
+
from agent_env.task import Task
|
|
637
|
+
from agent_env.task_step import DeployEnvTaskStep, VerifyEnvironmentCardStep
|
|
638
|
+
|
|
639
|
+
task_id = f"validate-{self.id}-v{self.version}"
|
|
640
|
+
task = Task.put(
|
|
641
|
+
id=task_id,
|
|
642
|
+
steps=[
|
|
643
|
+
DeployEnvTaskStep(id=f"{task_id}-deploy", version=None, env_id=self.id, env_version=self.version),
|
|
644
|
+
VerifyEnvironmentCardStep(id=f"{task_id}-envcard", version=None, env_id=self.id),
|
|
645
|
+
],
|
|
646
|
+
)
|
|
647
|
+
logger.info(f"Created validation task: {task.id} version={task.version}")
|
|
648
|
+
|
|
649
|
+
log = on_progress or (lambda msg: None)
|
|
650
|
+
on_start = lambda i, total, step, ctx: log(f"Running step [{i+1}/{total}]: {step.type}...")
|
|
651
|
+
on_complete = lambda i, total, step, ctx, dur: log(f"Completed step [{i+1}/{total}]: {step.type} [{dur:.1f}s]")
|
|
652
|
+
context = await task.run(on_step_start=on_start, on_step_complete=on_complete)
|
|
653
|
+
|
|
654
|
+
for deployed_env in context.deployed_envs:
|
|
655
|
+
try:
|
|
656
|
+
await close_deployed(deployed_env, MultiEnv)
|
|
657
|
+
except Exception as e:
|
|
658
|
+
logger.warning(f"Failed to clean up env {deployed_env.env_id}: {e}")
|
|
659
|
+
return context.instance_id
|
|
660
|
+
|
|
661
|
+
async def validate_universe_compatibility(self, universe_artifact_id: str, universe_artifact_version: int | None = None, on_progress: Callable[[str], None] | None = None) -> str:
|
|
662
|
+
"""Run universe compatibility validation and return the task instance ID.
|
|
663
|
+
|
|
664
|
+
Two verdicts are produced: the programmatic verifier (``compare_dicts``) and an LLM
|
|
665
|
+
agent-judge that diffs the original/export universes for *semantic* (major) differences the
|
|
666
|
+
programmatic comparison misses. The round-trip step bundles original/export1/export2 into a
|
|
667
|
+
FileArtifactUniverse, which is staged onto a judge agent. The judge's flagged differences are
|
|
668
|
+
merged into the ``UNIVERSE_COMPATIBILITY`` result as per-service critical issues — so they
|
|
669
|
+
surface (and gate ``compatible``) the same way programmatic issues do.
|
|
670
|
+
"""
|
|
671
|
+
refuse_local_derivation(self.id, "env", "validating")
|
|
672
|
+
from agent_env.task import Task
|
|
673
|
+
from agent_env.task_step import (
|
|
674
|
+
CombineUniverseVerdictsStep,
|
|
675
|
+
DeployAgentTaskStep,
|
|
676
|
+
DeployEnvTaskStep,
|
|
677
|
+
LoadArtifactTaskStep,
|
|
678
|
+
PromptAgentTaskStep,
|
|
679
|
+
RubricsVerifierTaskStep,
|
|
680
|
+
VerifyUniverseLoadExportRoundtripStep,
|
|
681
|
+
)
|
|
682
|
+
from agent_env.task_step.task_steps.multienv_validator.verify_universe_agent_judge import (
|
|
683
|
+
JUDGE_SYSTEM_PROMPT,
|
|
684
|
+
UNIVERSES_DIR,
|
|
685
|
+
build_criteria,
|
|
686
|
+
build_user_prompt,
|
|
687
|
+
)
|
|
688
|
+
from agent_env.artifact import EnvironmentUniverseArtifact
|
|
689
|
+
|
|
690
|
+
universe = EnvironmentUniverseArtifact.get(universe_artifact_id, universe_artifact_version)
|
|
691
|
+
task_id = f"validate-universe-compat-{self.id}-v{self.version}-{universe.id}-v{universe.version}"
|
|
692
|
+
environment_names = [sa.environment_name for sa in universe.get_environment_artifacts()]
|
|
693
|
+
fau_id = VerifyUniverseLoadExportRoundtripStep.file_artifact_universe_id(self.id, self.version, universe.id, universe.version)
|
|
694
|
+
judge_name = "universe-judge"
|
|
695
|
+
judge_prompt_id = f"{task_id}-judge-prompt"
|
|
696
|
+
judge_verifier_id = f"{task_id}-judge-rubric"
|
|
697
|
+
# Steps run sequentially (depends_on=None ⇒ depends on all prior steps), so the FAU exists
|
|
698
|
+
# before it is loaded, and the judge is prompted before it is graded. The round-trip step
|
|
699
|
+
# computes the programmatic verdict but does NOT persist it (persist_result=False); the final
|
|
700
|
+
# CombineUniverseVerdictsStep is the single authoritative writer, so a crash before the judge
|
|
701
|
+
# merges can't leave an ungated half-written result.
|
|
702
|
+
steps = [
|
|
703
|
+
DeployEnvTaskStep(id=f"{task_id}-deploy", version=None, env_id=self.id, env_version=self.version),
|
|
704
|
+
VerifyUniverseLoadExportRoundtripStep(id=f"{task_id}-roundtrip", version=None, env_id=self.id, universe_artifact_id=universe.id, universe_artifact_version=universe.version, emit_file_artifact_universe=True, persist_result=False),
|
|
705
|
+
DeployAgentTaskStep(id=f"{task_id}-judge-deploy", version=None, agent_name=judge_name, system_prompt=JUDGE_SYSTEM_PROMPT, ttl_seconds=1800),
|
|
706
|
+
LoadArtifactTaskStep(id=f"{task_id}-judge-load", version=None, agent_name=judge_name, artifact_id=fau_id, destination_path=UNIVERSES_DIR),
|
|
707
|
+
PromptAgentTaskStep(id=f"{task_id}-judge-prompt", version=None, agent_name=judge_name, prompt=build_user_prompt(), prompt_id=judge_prompt_id),
|
|
708
|
+
RubricsVerifierTaskStep(id=judge_verifier_id, version=None, criteria=build_criteria(environment_names), prompt_id=judge_prompt_id, agent_name=judge_name, verifier_id=judge_verifier_id),
|
|
709
|
+
CombineUniverseVerdictsStep(id=f"{task_id}-combine", version=None, env_id=self.id, universe_artifact_id=universe.id, universe_artifact_version=universe.version, judge_verifier_id=judge_verifier_id),
|
|
710
|
+
]
|
|
711
|
+
task = Task.put(id=task_id, steps=steps)
|
|
712
|
+
logger.info(f"Created validation task: {task.id} version={task.version}")
|
|
713
|
+
|
|
714
|
+
log = on_progress or (lambda msg: None)
|
|
715
|
+
on_start = lambda i, total, step, ctx: log(f"Running step [{i+1}/{total}]: {step.type}...")
|
|
716
|
+
on_complete = lambda i, total, step, ctx, dur: log(f"Completed step [{i+1}/{total}]: {step.type} [{dur:.1f}s]")
|
|
717
|
+
context = await task.run(on_step_start=on_start, on_step_complete=on_complete)
|
|
718
|
+
|
|
719
|
+
for deployed_env in context.deployed_envs:
|
|
720
|
+
try:
|
|
721
|
+
await close_deployed(deployed_env, MultiEnv)
|
|
722
|
+
except Exception as e:
|
|
723
|
+
logger.warning(f"Failed to clean up env {deployed_env.env_id}: {e}")
|
|
724
|
+
# Tear down any judge agent (ttl is a backstop if this is skipped).
|
|
725
|
+
for deployed_agent in context.deployed_agents:
|
|
726
|
+
if deployed_agent.sandbox_id:
|
|
727
|
+
try:
|
|
728
|
+
from agent_env.providers import build_sandbox_provider, get_agent_sandbox_provider
|
|
729
|
+
provider = build_sandbox_provider(deployed_agent.sandbox_type) if deployed_agent.sandbox_type else get_agent_sandbox_provider()
|
|
730
|
+
sandbox = await provider.get_sandbox(deployed_agent.sandbox_id)
|
|
731
|
+
await sandbox.terminate()
|
|
732
|
+
except Exception as e:
|
|
733
|
+
logger.warning(f"Failed to clean up agent sandbox {deployed_agent.sandbox_id}: {e}")
|
|
734
|
+
|
|
735
|
+
# Note: the merged UNIVERSE_COMPATIBILITY result is written by CombineUniverseVerdictsStep
|
|
736
|
+
# (the final task step), so it persists wherever the Task runs — not only via this method.
|
|
737
|
+
return context.instance_id
|
|
738
|
+
|
|
739
|
+
async def close(self) -> None:
|
|
740
|
+
await close_replaced(self)
|
|
741
|
+
if self._env_provider is not None:
|
|
742
|
+
try:
|
|
743
|
+
await self._env_provider.close()
|
|
744
|
+
self._env_provider = None
|
|
745
|
+
except Exception as e:
|
|
746
|
+
logger.warning(f"Failed to close env provider, kept for the next close(): {e}")
|
|
747
|
+
if self._sandbox is not None:
|
|
748
|
+
try:
|
|
749
|
+
await self._sandbox.terminate()
|
|
750
|
+
except BaseException as e:
|
|
751
|
+
logger.warning(f"Failed to terminate sandbox {self._sandbox.sandbox_id}: {e}")
|
|
752
|
+
self._sandbox = None
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _hand_provider_to_child_envs(env: MultiEnv) -> None:
|
|
756
|
+
"""Point each child env at the MultiEnv's deployment: the provider and URL its loads use, and behind our gateway the sandbox they
|
|
757
|
+
stage into. A plugin's provider leaves them no sandbox, so they load through the record's card."""
|
|
758
|
+
provider = env._env_provider
|
|
759
|
+
builtin = as_builtin(provider)
|
|
760
|
+
for child in env.mcp_server_envs:
|
|
761
|
+
child._sandbox = (builtin.environment_sandbox(child.environment_name) if builtin else None) or env._sandbox
|
|
762
|
+
child._gateway_url = env._gateway_url
|
|
763
|
+
child._env_provider = provider
|
|
764
|
+
for child in env.website_envs:
|
|
765
|
+
child._sandbox = env._sandbox
|
|
766
|
+
child._gateway_url = env._gateway_url
|
|
767
|
+
child._env_provider = provider
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _gateway_state_provider(env: MultiEnv) -> DatabaseStateProvider | None:
|
|
771
|
+
"""The state provider behind our gateway, which a snapshot restores into or captures from; None for a deployment without it."""
|
|
772
|
+
from agent_env.providers import EnvironmentGatewayProvider
|
|
773
|
+
return env._env_provider._state_provider if isinstance(env._env_provider, EnvironmentGatewayProvider) else None
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _hand_record_to_child_envs(env: MultiEnv, deployed: DeployedEnv) -> None:
|
|
777
|
+
"""Give each child env the record its loads read the card from; a name an MCP server and a website share keeps the live probe, since the card can't tell them apart by name."""
|
|
778
|
+
shared = {c.environment_name for c in env.mcp_server_envs} & {c.environment_name for c in env.website_envs}
|
|
779
|
+
for child in [*env.mcp_server_envs, *env.website_envs]:
|
|
780
|
+
child._deployed = None if child.environment_name in shared else deployed
|