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,380 @@
|
|
|
1
|
+
"""Run a Docker container task step — builds a user-provided Dockerfile on a
|
|
2
|
+
pre-deployed VM sandbox and runs the resulting container in the background."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import posixpath
|
|
8
|
+
import shlex
|
|
9
|
+
from typing import ClassVar, Optional
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from agent_env.task_step.context import TaskStepContext
|
|
13
|
+
from agent_env.entity_refs import EntityRef
|
|
14
|
+
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
|
|
15
|
+
from agent_env.utils.paths import validate_relative_filename
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RunDockerContainerTaskStep(TaskStep):
|
|
21
|
+
type: ClassVar[str] = "run_docker_container"
|
|
22
|
+
entity_refs = (
|
|
23
|
+
EntityRef.artifact(
|
|
24
|
+
"docker_context_artifact_id",
|
|
25
|
+
version_field="docker_context_artifact_version",
|
|
26
|
+
artifact_type="file_artifact_universe",
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
id: str,
|
|
33
|
+
version: Optional[int],
|
|
34
|
+
sandbox_name: str,
|
|
35
|
+
docker_context_artifact_id: Optional[str] = None,
|
|
36
|
+
docker_context_artifact_version: Optional[int] = None,
|
|
37
|
+
docker_context_url: Optional[str] = None,
|
|
38
|
+
dockerfile_path: str = "Dockerfile",
|
|
39
|
+
container_name: str = "task-container",
|
|
40
|
+
image_tag: Optional[str] = None,
|
|
41
|
+
ports: Optional[list[int]] = None,
|
|
42
|
+
env_vars: Optional[dict[str, str]] = None,
|
|
43
|
+
build_args: Optional[dict[str, str]] = None,
|
|
44
|
+
command_override: Optional[str] = None,
|
|
45
|
+
keep_alive_with_base_command: bool = False,
|
|
46
|
+
network: Optional[str] = None,
|
|
47
|
+
extra_networks: Optional[list[str]] = None,
|
|
48
|
+
ready_command: Optional[str] = None,
|
|
49
|
+
devices: Optional[list[str]] = None,
|
|
50
|
+
cap_add: Optional[list[str]] = None,
|
|
51
|
+
privileged: bool = False,
|
|
52
|
+
volumes: Optional[list[str]] = None,
|
|
53
|
+
shm_size: Optional[str] = None,
|
|
54
|
+
depends_on: Optional[list[TaskStepDependency]] = None,
|
|
55
|
+
fail_task_on_error: bool = True,
|
|
56
|
+
):
|
|
57
|
+
super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
|
|
58
|
+
modes = sum(x is not None for x in (docker_context_artifact_id, docker_context_url))
|
|
59
|
+
if modes != 1:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"Must pass exactly one of `docker_context_artifact_id` or `docker_context_url`"
|
|
62
|
+
)
|
|
63
|
+
if command_override and keep_alive_with_base_command:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"Pass either `command_override` or `keep_alive_with_base_command`, not both"
|
|
66
|
+
)
|
|
67
|
+
self.sandbox_name = sandbox_name
|
|
68
|
+
self.docker_context_artifact_id = docker_context_artifact_id
|
|
69
|
+
self.docker_context_artifact_version = docker_context_artifact_version
|
|
70
|
+
self.docker_context_url = docker_context_url
|
|
71
|
+
self.dockerfile_path = dockerfile_path
|
|
72
|
+
self.container_name = container_name
|
|
73
|
+
self.image_tag = image_tag
|
|
74
|
+
self.ports = ports
|
|
75
|
+
self.env_vars = env_vars
|
|
76
|
+
self.build_args = build_args
|
|
77
|
+
self.command_override = command_override
|
|
78
|
+
self.keep_alive_with_base_command = keep_alive_with_base_command
|
|
79
|
+
self.network = network
|
|
80
|
+
self.extra_networks = list(extra_networks or [])
|
|
81
|
+
# host / none / container:<name> replace the container's whole netns, so they can't be attached as extra networks.
|
|
82
|
+
netns_extra = [n for n in self.extra_networks if n in ("host", "none") or n.startswith("container:")]
|
|
83
|
+
if netns_extra:
|
|
84
|
+
raise ValueError(f"extra_networks cannot contain netns-replacing modes {netns_extra}; use user-defined bridges")
|
|
85
|
+
self.ready_command = ready_command
|
|
86
|
+
self.devices = list(devices or [])
|
|
87
|
+
self.cap_add = list(cap_add or [])
|
|
88
|
+
self.privileged = privileged
|
|
89
|
+
self.volumes = list(volumes or [])
|
|
90
|
+
self.shm_size = shm_size
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict:
|
|
93
|
+
base = super().to_dict()
|
|
94
|
+
base["sandbox_name"] = self.sandbox_name
|
|
95
|
+
base["docker_context_artifact_id"] = self.docker_context_artifact_id
|
|
96
|
+
base["docker_context_artifact_version"] = self.docker_context_artifact_version
|
|
97
|
+
base["docker_context_url"] = self.docker_context_url
|
|
98
|
+
base["dockerfile_path"] = self.dockerfile_path
|
|
99
|
+
base["container_name"] = self.container_name
|
|
100
|
+
base["image_tag"] = self.image_tag
|
|
101
|
+
base["ports"] = self.ports
|
|
102
|
+
base["env_vars"] = self.env_vars
|
|
103
|
+
base["build_args"] = self.build_args
|
|
104
|
+
base["command_override"] = self.command_override
|
|
105
|
+
base["keep_alive_with_base_command"] = self.keep_alive_with_base_command
|
|
106
|
+
base["network"] = self.network
|
|
107
|
+
base["extra_networks"] = self.extra_networks
|
|
108
|
+
base["ready_command"] = self.ready_command
|
|
109
|
+
base["devices"] = self.devices
|
|
110
|
+
base["cap_add"] = self.cap_add
|
|
111
|
+
base["privileged"] = self.privileged
|
|
112
|
+
base["volumes"] = self.volumes
|
|
113
|
+
base["shm_size"] = self.shm_size
|
|
114
|
+
return base
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_dict(cls, data: dict) -> RunDockerContainerTaskStep:
|
|
118
|
+
return cls(
|
|
119
|
+
**cls._base_from_dict(data),
|
|
120
|
+
sandbox_name=data["sandbox_name"],
|
|
121
|
+
docker_context_artifact_id=data.get("docker_context_artifact_id"),
|
|
122
|
+
docker_context_artifact_version=data.get("docker_context_artifact_version"),
|
|
123
|
+
docker_context_url=data.get("docker_context_url"),
|
|
124
|
+
dockerfile_path=data.get("dockerfile_path", "Dockerfile"),
|
|
125
|
+
container_name=data.get("container_name", "task-container"),
|
|
126
|
+
image_tag=data.get("image_tag"),
|
|
127
|
+
ports=data.get("ports"),
|
|
128
|
+
env_vars=data.get("env_vars"),
|
|
129
|
+
build_args=data.get("build_args"),
|
|
130
|
+
command_override=data.get("command_override"),
|
|
131
|
+
keep_alive_with_base_command=data.get("keep_alive_with_base_command", False),
|
|
132
|
+
network=data.get("network"),
|
|
133
|
+
extra_networks=data.get("extra_networks"),
|
|
134
|
+
ready_command=data.get("ready_command"),
|
|
135
|
+
devices=data.get("devices"),
|
|
136
|
+
cap_add=data.get("cap_add"),
|
|
137
|
+
privileged=data.get("privileged", False),
|
|
138
|
+
volumes=data.get("volumes"),
|
|
139
|
+
shm_size=data.get("shm_size"),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def execute(self, context: TaskStepContext) -> TaskStepContext:
|
|
143
|
+
from agent_env.providers.sandbox_providers.sandbox_provider import (
|
|
144
|
+
SANDBOX_MODE_VM,
|
|
145
|
+
build_sandbox_provider,
|
|
146
|
+
get_sandbox_provider,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
ds = next(
|
|
150
|
+
(s for s in context.deployed_sandboxes if s.sandbox_name == self.sandbox_name),
|
|
151
|
+
None,
|
|
152
|
+
)
|
|
153
|
+
if ds is None:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
f"Sandbox '{self.sandbox_name}' not found in context.deployed_sandboxes; "
|
|
156
|
+
f"deploy it with DeploySandboxTaskStep before this step"
|
|
157
|
+
)
|
|
158
|
+
if ds.sandbox_mode != SANDBOX_MODE_VM:
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
f"Cannot run docker container on sandbox '{self.sandbox_name}' "
|
|
161
|
+
f"(mode={ds.sandbox_mode!r}); only VM-mode sandboxes support docker build/run"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
provider = (
|
|
165
|
+
build_sandbox_provider(ds.sandbox_type) if ds.sandbox_type else get_sandbox_provider()
|
|
166
|
+
)
|
|
167
|
+
sandbox = await provider.get_sandbox(ds.sandbox_id)
|
|
168
|
+
|
|
169
|
+
image_tag = self.image_tag or f"{self.container_name}:latest"
|
|
170
|
+
|
|
171
|
+
work_dir = f"/tmp/docker-context-{self.container_name}"
|
|
172
|
+
await sandbox.exec_script(
|
|
173
|
+
f"rm -rf {shlex.quote(work_dir)} && mkdir -p {shlex.quote(work_dir)}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if self.docker_context_artifact_id is not None:
|
|
177
|
+
await self._stage_from_universe(sandbox, ds.sandbox_id, work_dir)
|
|
178
|
+
else:
|
|
179
|
+
await self._stage_zip_from_url(sandbox, self.docker_context_url, work_dir)
|
|
180
|
+
|
|
181
|
+
dockerfile_abs = posixpath.join(work_dir, self.dockerfile_path)
|
|
182
|
+
await sandbox.exec_script(
|
|
183
|
+
f"test -f {shlex.quote(dockerfile_abs)} "
|
|
184
|
+
f"|| (echo 'Dockerfile not found at {self.dockerfile_path} inside the build context' >&2; exit 1)"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
build_arg_flags = ""
|
|
188
|
+
if self.build_args:
|
|
189
|
+
build_arg_flags = " ".join(
|
|
190
|
+
f"--build-arg {shlex.quote(f'{k}={v}')}" for k, v in self.build_args.items()
|
|
191
|
+
)
|
|
192
|
+
build_cmd = (
|
|
193
|
+
f"cd {shlex.quote(work_dir)} && "
|
|
194
|
+
f"docker build --platform linux/amd64 "
|
|
195
|
+
f"-f {shlex.quote(self.dockerfile_path)} "
|
|
196
|
+
f"-t {shlex.quote(image_tag)} "
|
|
197
|
+
f"{build_arg_flags} ."
|
|
198
|
+
)
|
|
199
|
+
logger.info(
|
|
200
|
+
f"Building docker image '{image_tag}' on sandbox {ds.sandbox_id} "
|
|
201
|
+
f"(dockerfile={self.dockerfile_path})"
|
|
202
|
+
)
|
|
203
|
+
await sandbox.exec_script(build_cmd)
|
|
204
|
+
|
|
205
|
+
network_flag = ""
|
|
206
|
+
if self.network:
|
|
207
|
+
network_flag = f"--network {await self._ensure_network(sandbox, self.network)} "
|
|
208
|
+
|
|
209
|
+
port_flags = " ".join(f"-p {p}:{p}" for p in (self.ports or []))
|
|
210
|
+
env_flag_parts = []
|
|
211
|
+
if self.env_vars:
|
|
212
|
+
for k, v in self.env_vars.items():
|
|
213
|
+
escaped = v.replace("'", "'\\''")
|
|
214
|
+
env_flag_parts.append(f"-e {k}='{escaped}'")
|
|
215
|
+
env_flags = " ".join(env_flag_parts)
|
|
216
|
+
device_flags = " ".join(f"--device {shlex.quote(d)}" for d in self.devices)
|
|
217
|
+
cap_flags = " ".join(f"--cap-add {shlex.quote(c)}" for c in self.cap_add)
|
|
218
|
+
volume_flags = " ".join(f"-v {shlex.quote(v)}" for v in self.volumes)
|
|
219
|
+
extra_flags = " ".join(f for f in (
|
|
220
|
+
"--privileged" if self.privileged else "",
|
|
221
|
+
f"--shm-size {shlex.quote(self.shm_size)}" if self.shm_size else "",
|
|
222
|
+
device_flags, cap_flags, volume_flags,
|
|
223
|
+
) if f)
|
|
224
|
+
entrypoint_flag = ""
|
|
225
|
+
if self.command_override:
|
|
226
|
+
command_tail = f" {self.command_override}"
|
|
227
|
+
elif self.keep_alive_with_base_command:
|
|
228
|
+
# Keep the container alive for install/exec WITHOUT discarding the
|
|
229
|
+
# image's own startup (a `sleep infinity` override would stop a base
|
|
230
|
+
# CMD that launches a DB/server the task needs). Replay the recorded
|
|
231
|
+
# ENTRYPOINT+CMD in the background, then hold PID 1 open.
|
|
232
|
+
base_cmd = await self._inspect_base_command(sandbox, image_tag)
|
|
233
|
+
if base_cmd:
|
|
234
|
+
base_str = " ".join(shlex.quote(part) for part in base_cmd)
|
|
235
|
+
script = f"{base_str} & exec sleep infinity"
|
|
236
|
+
entrypoint_flag = "--entrypoint /bin/sh "
|
|
237
|
+
command_tail = f" -c {shlex.quote(script)}"
|
|
238
|
+
else:
|
|
239
|
+
command_tail = " sleep infinity"
|
|
240
|
+
else:
|
|
241
|
+
command_tail = ""
|
|
242
|
+
run_cmd = (
|
|
243
|
+
f"docker run -d --name {shlex.quote(self.container_name)} "
|
|
244
|
+
f"{network_flag}{entrypoint_flag}{extra_flags} {port_flags} {env_flags} "
|
|
245
|
+
f"{shlex.quote(image_tag)}{command_tail}"
|
|
246
|
+
)
|
|
247
|
+
logger.info(
|
|
248
|
+
f"Starting container '{self.container_name}' from image '{image_tag}' "
|
|
249
|
+
f"on sandbox {ds.sandbox_id}"
|
|
250
|
+
)
|
|
251
|
+
await sandbox.exec_script(run_cmd)
|
|
252
|
+
|
|
253
|
+
# Dual-home the container onto additional networks (e.g. a jump host straddling segments).
|
|
254
|
+
# Dedup and skip the primary: `docker network connect` errors on an already-attached endpoint.
|
|
255
|
+
for extra in dict.fromkeys(self.extra_networks):
|
|
256
|
+
if extra == self.network:
|
|
257
|
+
continue
|
|
258
|
+
enet = await self._ensure_network(sandbox, extra)
|
|
259
|
+
await sandbox.exec_script(f"docker network connect {enet} {shlex.quote(self.container_name)}")
|
|
260
|
+
logger.info(f"Connected container '{self.container_name}' to extra network '{extra}'")
|
|
261
|
+
|
|
262
|
+
if self.ready_command:
|
|
263
|
+
ready = shlex.quote(self.ready_command)
|
|
264
|
+
logger.info(f"Waiting for container '{self.container_name}' readiness: {self.ready_command}")
|
|
265
|
+
await sandbox.exec_script(
|
|
266
|
+
f"for i in $(seq 1 60); do "
|
|
267
|
+
f"if docker exec {shlex.quote(self.container_name)} sh -c {ready} >/dev/null 2>&1; then exit 0; fi; "
|
|
268
|
+
f"sleep 2; done; "
|
|
269
|
+
f"echo 'container {self.container_name} failed readiness check within 120s' >&2; exit 1"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
context.metadata.setdefault("deployed_docker_containers", []).append({
|
|
273
|
+
"container_name": self.container_name,
|
|
274
|
+
"sandbox_name": self.sandbox_name,
|
|
275
|
+
"sandbox_id": ds.sandbox_id,
|
|
276
|
+
"image_tag": image_tag,
|
|
277
|
+
"ports": self.ports,
|
|
278
|
+
"network": self.network,
|
|
279
|
+
})
|
|
280
|
+
return context
|
|
281
|
+
|
|
282
|
+
async def _ensure_network(self, sandbox, name: str) -> str:
|
|
283
|
+
net = shlex.quote(name)
|
|
284
|
+
# host / none / bridge / container:<name> are Docker built-in modes, not user bridges -- nothing to create.
|
|
285
|
+
if name in ("host", "none", "bridge") or name.startswith("container:"):
|
|
286
|
+
return net
|
|
287
|
+
await sandbox.exec_script(
|
|
288
|
+
f"docker network inspect {net} >/dev/null 2>&1 " # already exists -> reuse it
|
|
289
|
+
f"|| docker network create {net} >/dev/null 2>&1 " # else create the user bridge
|
|
290
|
+
f"|| docker network inspect {net} >/dev/null" # lost a create race -> confirm it exists
|
|
291
|
+
)
|
|
292
|
+
return net
|
|
293
|
+
|
|
294
|
+
async def _inspect_base_command(self, sandbox, image_tag: str) -> list[str]:
|
|
295
|
+
"""Return the built image's ENTRYPOINT+CMD as a single argv list (or [])."""
|
|
296
|
+
import json
|
|
297
|
+
|
|
298
|
+
out = await sandbox.exec_script(
|
|
299
|
+
"docker inspect --format "
|
|
300
|
+
"'{{json .Config.Entrypoint}}|{{json .Config.Cmd}}' "
|
|
301
|
+
f"{shlex.quote(image_tag)}"
|
|
302
|
+
)
|
|
303
|
+
ep_raw, _, cmd_raw = out.strip().partition("|")
|
|
304
|
+
entrypoint = json.loads(ep_raw) if ep_raw and ep_raw != "null" else []
|
|
305
|
+
cmd = json.loads(cmd_raw) if cmd_raw and cmd_raw != "null" else []
|
|
306
|
+
return list(entrypoint or []) + list(cmd or [])
|
|
307
|
+
|
|
308
|
+
async def _stage_from_universe(self, sandbox, sandbox_id: str, work_dir: str) -> None:
|
|
309
|
+
from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse
|
|
310
|
+
|
|
311
|
+
universe = FileArtifactUniverse.get(
|
|
312
|
+
self.docker_context_artifact_id, self.docker_context_artifact_version,
|
|
313
|
+
)
|
|
314
|
+
file_artifacts = universe.get_file_artifacts()
|
|
315
|
+
if not file_artifacts:
|
|
316
|
+
raise RuntimeError(
|
|
317
|
+
f"FileArtifactUniverse '{universe.id}' v{universe.version} has no files; "
|
|
318
|
+
f"nothing to use as docker build context"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
dirs_to_make: set[str] = set()
|
|
322
|
+
loaded: dict[str, str] = {}
|
|
323
|
+
for filename in file_artifacts:
|
|
324
|
+
validate_relative_filename(filename)
|
|
325
|
+
dest_path = posixpath.join(work_dir, filename)
|
|
326
|
+
parent = posixpath.dirname(dest_path)
|
|
327
|
+
if parent and parent != work_dir:
|
|
328
|
+
dirs_to_make.add(parent)
|
|
329
|
+
loaded[filename] = dest_path
|
|
330
|
+
if dirs_to_make:
|
|
331
|
+
mkdir_cmd = " && ".join(f"mkdir -p {shlex.quote(d)}" for d in sorted(dirs_to_make))
|
|
332
|
+
await sandbox.exec_script(mkdir_cmd)
|
|
333
|
+
|
|
334
|
+
total = len(file_artifacts)
|
|
335
|
+
logger.info(
|
|
336
|
+
f"Loading docker context universe '{universe.id}' v{universe.version} "
|
|
337
|
+
f"({total} file(s)) onto sandbox {sandbox_id} at {work_dir}"
|
|
338
|
+
)
|
|
339
|
+
for idx, (filename, fa) in enumerate(file_artifacts.items(), 1):
|
|
340
|
+
dest_path = loaded[filename]
|
|
341
|
+
logger.info(f" [{idx}/{total}] {fa.object_url} -> {dest_path}")
|
|
342
|
+
await sandbox.load_s3_file(fa.object_url, dest_path)
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
async def _stage_zip_from_url(sandbox, url: str, work_dir: str) -> None:
|
|
346
|
+
"""http(s) urls are fetched with curl; any other url is read through the configured
|
|
347
|
+
object store, which reaches whatever its backend can."""
|
|
348
|
+
parsed = urlparse(url)
|
|
349
|
+
if not parsed.scheme:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"docker_context_url={url!r} must be an object store url or an http(s) url"
|
|
352
|
+
)
|
|
353
|
+
from_store = parsed.scheme not in ("http", "https")
|
|
354
|
+
path = url.rsplit("/", 1)[-1] if from_store else parsed.path
|
|
355
|
+
if path.endswith(".zip"):
|
|
356
|
+
pass
|
|
357
|
+
elif path.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar")):
|
|
358
|
+
raise NotImplementedError(
|
|
359
|
+
f"tar archives are not yet supported (URL: {url}); only .zip is supported today"
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
raise ValueError(
|
|
363
|
+
f"Unsupported archive format for docker_context_url={url!r}; "
|
|
364
|
+
f"only .zip is supported"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
archive_path = posixpath.join(work_dir, "_context.zip")
|
|
368
|
+
logger.info(f"Downloading docker context from {url} into {work_dir}")
|
|
369
|
+
if from_store:
|
|
370
|
+
await sandbox.load_s3_file(url, archive_path)
|
|
371
|
+
else:
|
|
372
|
+
await sandbox.exec_script(
|
|
373
|
+
f"curl -fsSL {shlex.quote(url)} -o {shlex.quote(archive_path)}"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
await sandbox.exec_script(
|
|
377
|
+
f"unzip -q -o {shlex.quote(archive_path)} -d {shlex.quote(work_dir)} && "
|
|
378
|
+
f"rm -f {shlex.quote(archive_path)}"
|
|
379
|
+
)
|
|
380
|
+
logger.info(f"Extracted {url} into {work_dir}")
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Sandbox diagnostics shared by the steps that drive an A2A agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider, get_sandbox_provider
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Room for the agent's own account of a failure (its protocol ``error_message``).
|
|
13
|
+
_AGENT_ERROR_TEXT_LIMIT = 2000
|
|
14
|
+
# Containers tailed when none is the agent's own, e.g. an agent installed into an env's containers.
|
|
15
|
+
_FALLBACK_CONTAINER_LIMIT = 3
|
|
16
|
+
# Core names the agent containers it starts ``agent-api``, ``agent-<id>`` or ``a2a-agent-*``.
|
|
17
|
+
_AGENT_CONTAINER_MARKER = "agent"
|
|
18
|
+
# Per stream, so a long stdout cannot push the stderr error out of the window.
|
|
19
|
+
_STREAM_TAIL_CHARS = 500
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def agent_error_text(error_message: Optional[str], response_text: Optional[str]) -> str:
|
|
23
|
+
"""The agent's own failure text, bounded: its ``error_message``, else its response."""
|
|
24
|
+
return (error_message or response_text or "")[:_AGENT_ERROR_TEXT_LIMIT]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def fetch_container_logs(agent, tail: int = 500) -> Optional[str]:
|
|
28
|
+
"""Tail the docker logs of the agent's container, so a failed A2A task stays diagnosable
|
|
29
|
+
after its sandbox is reaped.
|
|
30
|
+
|
|
31
|
+
The agent's own container is the one named ``sandbox.container_name``. When none is, the
|
|
32
|
+
containers are tailed up to ``_FALLBACK_CONTAINER_LIMIT``, agent-named ones first. Best
|
|
33
|
+
effort: None on any failure, including a sandbox with no docker, so it never masks the
|
|
34
|
+
task's own failure.
|
|
35
|
+
"""
|
|
36
|
+
sandbox_id = getattr(agent, "sandbox_id", None)
|
|
37
|
+
if not sandbox_id:
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
sandbox_type = getattr(agent, "sandbox_type", None)
|
|
41
|
+
provider = (
|
|
42
|
+
build_sandbox_provider(sandbox_type) if sandbox_type else get_sandbox_provider()
|
|
43
|
+
)
|
|
44
|
+
sandbox = await provider.get_sandbox(sandbox_id)
|
|
45
|
+
chunks: list[str] = []
|
|
46
|
+
try:
|
|
47
|
+
_, ps_out, _ = await sandbox.exec_with_output(
|
|
48
|
+
"sudo", "docker", "ps", "-a", "--format", "{{.ID}}|{{.Names}}",
|
|
49
|
+
)
|
|
50
|
+
rows = [
|
|
51
|
+
(cid.strip(), name.strip())
|
|
52
|
+
for cid, _, name in (line.partition("|") for line in (ps_out or "").splitlines())
|
|
53
|
+
if cid.strip()
|
|
54
|
+
]
|
|
55
|
+
own = [cid for cid, name in rows if name == sandbox.container_name]
|
|
56
|
+
by_agent_name = sorted(rows, key=lambda row: _AGENT_CONTAINER_MARKER not in row[1])
|
|
57
|
+
cids = own or [cid for cid, _ in by_agent_name][:_FALLBACK_CONTAINER_LIMIT]
|
|
58
|
+
for cid in cids:
|
|
59
|
+
_, out, err = await sandbox.exec_with_output(
|
|
60
|
+
"sudo", "docker", "logs", "--tail", str(tail), cid,
|
|
61
|
+
)
|
|
62
|
+
parts = []
|
|
63
|
+
out_tail = (out or "").strip()[-_STREAM_TAIL_CHARS:]
|
|
64
|
+
err_tail = (err or "").strip()[-_STREAM_TAIL_CHARS:]
|
|
65
|
+
if out_tail:
|
|
66
|
+
parts.append(f"[stdout]\n{out_tail}")
|
|
67
|
+
if err_tail:
|
|
68
|
+
parts.append(f"[stderr]\n{err_tail}")
|
|
69
|
+
if parts:
|
|
70
|
+
chunks.append(f"[container {cid}]\n" + "\n".join(parts))
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.debug("docker logs unavailable on sandbox %s: %s", sandbox_id, e)
|
|
73
|
+
return "\n\n".join(chunks) or None
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.warning(
|
|
76
|
+
"Could not fetch agent container logs from sandbox %s: %s",
|
|
77
|
+
sandbox_id, e, exc_info=True,
|
|
78
|
+
)
|
|
79
|
+
return None
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Snapshot a deployed A2A agent's conversation state to a FileArtifactUniverse."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import ClassVar, Optional
|
|
8
|
+
|
|
9
|
+
from agent_env.task_step.context import TaskStepContext
|
|
10
|
+
from agent_env.entity_refs import EntityRef, RefRole
|
|
11
|
+
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
|
|
12
|
+
from agent_env.task_step.snapshot_utils import agent_state_capture
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SnapshotAgentStateTaskStep(TaskStep):
|
|
18
|
+
"""Capture a deployed agent's conversation state and persist it as a
|
|
19
|
+
``FileArtifactUniverse``.
|
|
20
|
+
|
|
21
|
+
Looks up the deployed agent (by ``agent_name``) and the source ``context_id``
|
|
22
|
+
via a previously-recorded ``PromptResponse`` (by ``prompt_id``). Calls the
|
|
23
|
+
agent's ``/ext/snapshot`` extension to write the conversation transcript to
|
|
24
|
+
S3, then wraps that S3 prefix as a ``FileArtifactUniverse`` via
|
|
25
|
+
``put_existing`` (no download/re-upload). Appends an entry recording
|
|
26
|
+
``{id, version, bundle_object_url, source_agent_name, source_context_id}`` to
|
|
27
|
+
``context.metadata['agent_snapshots']`` (a list of such entries).
|
|
28
|
+
|
|
29
|
+
When ``env_id`` is set, the step ALSO dumps each MCP service's current
|
|
30
|
+
state by hitting ``{gateway_url}/svc/mcp-{service}/export-state`` on the
|
|
31
|
+
deployed env, uploads each as JSON alongside the workspace tarball, and
|
|
32
|
+
writes a JSON-stringified ``{service: s3_url}`` (unsigned ``s3://`` object
|
|
33
|
+
references) to ``context.metadata['snapshot_json_url']``. A downstream
|
|
34
|
+
verifier re-signs each value before handing the map to its test script, so
|
|
35
|
+
the script can fetch each service's state and assert against it. Without this, only the
|
|
36
|
+
agent's *workspace files* are visible to the verifier — not per-MCP-service
|
|
37
|
+
DB state.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
type: ClassVar[str] = "snapshot_agent_state"
|
|
41
|
+
entity_refs = (
|
|
42
|
+
EntityRef.artifact("artifact_id", role=RefRole.OUTPUT, artifact_type="file_artifact_universe"),
|
|
43
|
+
EntityRef.env("env_id"),
|
|
44
|
+
EntityRef.artifact("universe_artifact_id", artifact_type="environment_universe"),
|
|
45
|
+
)
|
|
46
|
+
DEFAULT_TIMEOUT_SECONDS: ClassVar[int] = 120
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
id: str,
|
|
51
|
+
version: Optional[int],
|
|
52
|
+
artifact_id: str,
|
|
53
|
+
prompt_id: str,
|
|
54
|
+
agent_name: Optional[str] = None,
|
|
55
|
+
env_id: Optional[str] = None,
|
|
56
|
+
universe_artifact_id: Optional[str] = None,
|
|
57
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
58
|
+
depends_on: Optional[list[TaskStepDependency]] = None,
|
|
59
|
+
fail_task_on_error: bool = True,
|
|
60
|
+
):
|
|
61
|
+
super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
|
|
62
|
+
self.artifact_id = artifact_id
|
|
63
|
+
self.prompt_id = prompt_id
|
|
64
|
+
self.agent_name = agent_name or TaskStep.DEFAULT_AGENT_NAME
|
|
65
|
+
# Set both `env_id` (which deployed env to query for /export-state) AND
|
|
66
|
+
# `universe_artifact_id` (to enumerate the env's service names) to
|
|
67
|
+
# enable per-MCP-service capture. Leaving either unset captures only
|
|
68
|
+
# the agent's workspace (legacy behaviour).
|
|
69
|
+
self.env_id = env_id
|
|
70
|
+
self.universe_artifact_id = universe_artifact_id
|
|
71
|
+
self.timeout_seconds = timeout_seconds
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict:
|
|
74
|
+
base = super().to_dict()
|
|
75
|
+
base["artifact_id"] = self.artifact_id
|
|
76
|
+
base["prompt_id"] = self.prompt_id
|
|
77
|
+
base["agent_name"] = self.agent_name
|
|
78
|
+
base["env_id"] = self.env_id
|
|
79
|
+
base["universe_artifact_id"] = self.universe_artifact_id
|
|
80
|
+
base["timeout_seconds"] = self.timeout_seconds
|
|
81
|
+
return base
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: dict) -> SnapshotAgentStateTaskStep:
|
|
85
|
+
return cls(
|
|
86
|
+
**cls._base_from_dict(data),
|
|
87
|
+
artifact_id=data["artifact_id"],
|
|
88
|
+
prompt_id=data["prompt_id"],
|
|
89
|
+
agent_name=data.get("agent_name"),
|
|
90
|
+
env_id=data.get("env_id"),
|
|
91
|
+
universe_artifact_id=data.get("universe_artifact_id"),
|
|
92
|
+
timeout_seconds=data.get("timeout_seconds", cls.DEFAULT_TIMEOUT_SECONDS),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def execute(self, context: TaskStepContext) -> TaskStepContext:
|
|
96
|
+
deployed = next((a for a in context.deployed_agents if a.agent_name == self.agent_name), None)
|
|
97
|
+
if deployed is None:
|
|
98
|
+
raise RuntimeError(f"No deployed agent named '{self.agent_name}' found in context")
|
|
99
|
+
a2a_url = deployed.a2a_url or deployed.api_url
|
|
100
|
+
if not a2a_url:
|
|
101
|
+
raise RuntimeError(f"Deployed agent '{self.agent_name}' has no a2a_url")
|
|
102
|
+
|
|
103
|
+
prompt = next((p for p in context.prompt_responses if p.prompt_id == self.prompt_id), None)
|
|
104
|
+
if prompt is None:
|
|
105
|
+
raise RuntimeError(f"No PromptResponse with prompt_id '{self.prompt_id}' in context")
|
|
106
|
+
a2a_context_id = prompt.a2a_context_id
|
|
107
|
+
if not a2a_context_id:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"PromptResponse for '{self.prompt_id}' has no a2a_context_id "
|
|
110
|
+
"(prompt step did not capture one — make sure the agent is using a recent gateway)"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
workspace = await agent_state_capture.capture_workspace(
|
|
114
|
+
a2a_url=a2a_url,
|
|
115
|
+
a2a_card=deployed.a2a_card or {},
|
|
116
|
+
agent_name=self.agent_name,
|
|
117
|
+
a2a_context_id=a2a_context_id,
|
|
118
|
+
artifact_id=self.artifact_id,
|
|
119
|
+
timeout_seconds=self.timeout_seconds,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
snapshots = context.metadata.setdefault("agent_snapshots", [])
|
|
123
|
+
snapshots.append({
|
|
124
|
+
"id": workspace.universe_id,
|
|
125
|
+
"version": workspace.universe_version,
|
|
126
|
+
"bundle_object_url": workspace.bundle_object_url,
|
|
127
|
+
"source_agent_name": self.agent_name,
|
|
128
|
+
"source_context_id": a2a_context_id,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
# Per-MCP-service state dump. Only runs when env_id +
|
|
132
|
+
# universe_artifact_id are both configured. Mirrors the export-state
|
|
133
|
+
# pattern in `multienv_validator/verify_universe_roundtrip.py:_export_all`.
|
|
134
|
+
# Result lands in `context.metadata['snapshot_json_url']` as a
|
|
135
|
+
# JSON-stringified `{service: s3_url}` (unsigned `s3://` references).
|
|
136
|
+
# A downstream verifier re-signs each value and hands the map to its test
|
|
137
|
+
# script, which accepts either a single URL or this map.
|
|
138
|
+
if self.env_id and self.universe_artifact_id:
|
|
139
|
+
await self._capture_universe_state(context, workspace.capture_prefix)
|
|
140
|
+
|
|
141
|
+
return context
|
|
142
|
+
|
|
143
|
+
async def _capture_universe_state(
|
|
144
|
+
self, context: TaskStepContext, capture_prefix: str
|
|
145
|
+
) -> None:
|
|
146
|
+
"""GET /svc/mcp-<name>/export-state for each service, upload, publish `s3://` refs."""
|
|
147
|
+
from agent_env.artifact import EnvironmentUniverseArtifact
|
|
148
|
+
from agent_env.config import get_config
|
|
149
|
+
|
|
150
|
+
deployed_env = next(
|
|
151
|
+
(d for d in context.deployed_envs if d.env_id == self.env_id), None
|
|
152
|
+
)
|
|
153
|
+
if deployed_env is None:
|
|
154
|
+
logger.warning(
|
|
155
|
+
"snapshot_agent_state: env_id=%s not found in deployed_envs (%s); "
|
|
156
|
+
"skipping per-service capture",
|
|
157
|
+
self.env_id,
|
|
158
|
+
[d.env_id for d in context.deployed_envs],
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
universe = EnvironmentUniverseArtifact.get(self.universe_artifact_id)
|
|
164
|
+
environment_names = [sa.environment_name for sa in universe.get_environment_artifacts()]
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"snapshot_agent_state: could not enumerate services from universe %s: %s",
|
|
168
|
+
self.universe_artifact_id, exc,
|
|
169
|
+
)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# Reuse the workspace capture's S3 prefix — services land in a
|
|
173
|
+
# `services/` subdir alongside the workspace tarball.
|
|
174
|
+
store = get_config().get_object_store()
|
|
175
|
+
key_prefix = store.get_object_key(capture_prefix).rstrip("/") + "/"
|
|
176
|
+
|
|
177
|
+
from agent_env.env import legacy_protocol
|
|
178
|
+
from agent_env.env.env import gateway_url_of
|
|
179
|
+
from agentenv_protocol import client as protocol_v1
|
|
180
|
+
urls: dict[str, str] = {}
|
|
181
|
+
for name in environment_names:
|
|
182
|
+
try:
|
|
183
|
+
base_url = await legacy_protocol.v1_base_url(deployed_env, gateway_url_of(deployed_env), name, mcp=True)
|
|
184
|
+
if base_url is not None:
|
|
185
|
+
resp = await protocol_v1.get_data(base_url)
|
|
186
|
+
state = resp.parts[0].data if resp.parts else {}
|
|
187
|
+
else:
|
|
188
|
+
state = await legacy_protocol.export_state(gateway_url_of(deployed_env), name)
|
|
189
|
+
urls[name] = await asyncio.to_thread(
|
|
190
|
+
store.put, f"{key_prefix}services/{name}.json", json.dumps(state).encode(),
|
|
191
|
+
content_type="application/json", allow_overwrite=True,
|
|
192
|
+
)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
logger.warning(
|
|
195
|
+
"snapshot_agent_state: capturing %s state failed: %s — skipping",
|
|
196
|
+
name, exc,
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if urls:
|
|
201
|
+
context.metadata["snapshot_json_url"] = json.dumps(urls)
|
|
202
|
+
logger.info(
|
|
203
|
+
"snapshot_agent_state: captured %d service states → snapshot_json_url",
|
|
204
|
+
len(urls),
|
|
205
|
+
)
|