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,142 @@
|
|
|
1
|
+
"""Tear down the sandboxes a finished run deployed.
|
|
2
|
+
|
|
3
|
+
``teardown_run`` terminates every sandbox a run's context records: its bare sandboxes', its envs' and its
|
|
4
|
+
agents'. It removes compute only; the instance, its outputs and the artifacts it collected stay. A local
|
|
5
|
+
sandbox's work folder goes too, once the sandbox is down. ``agent-env run`` and ``agent-env eval run`` call
|
|
6
|
+
it after each run. ``Task.run`` never does, so a caller that keeps a run's environment up, as the hub and
|
|
7
|
+
the worker do, owns its teardown.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import NamedTuple, Optional
|
|
16
|
+
|
|
17
|
+
from agent_env.env.env import DeployedEnv
|
|
18
|
+
from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, remove_local_work_dir
|
|
19
|
+
from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider
|
|
20
|
+
from agent_env.task.interrupts import tearing_down
|
|
21
|
+
from agent_env.task_step.context import TaskStepContext
|
|
22
|
+
from agent_env.task_step.task_steps.teardown_sandboxes import _DEFAULT_PROVIDERS, TORN_DOWN_KEY, _env_sandbox_ids
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
TERMINATE_TIMEOUT_SECONDS = 120
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RecordedSandbox(NamedTuple):
|
|
30
|
+
sandbox_id: str
|
|
31
|
+
sandbox_type: Optional[str] # the backend that made it; None leaves it to the slot's configured provider
|
|
32
|
+
slot: str # "sandbox", "env" or "agent"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class TeardownReport:
|
|
37
|
+
terminated: tuple[RecordedSandbox, ...] = ()
|
|
38
|
+
failed: tuple[tuple[RecordedSandbox, str], ...] = () # each with why
|
|
39
|
+
left: tuple[RecordedSandbox, ...] = () # not reached, because the teardown was stopped
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def still_up(self) -> tuple[RecordedSandbox, ...]:
|
|
43
|
+
"""What may still be running: what failed to terminate and what the teardown didn't reach."""
|
|
44
|
+
return (*(sandbox for sandbox, _ in self.failed), *self.left)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def skipped(cls, context: TaskStepContext) -> TeardownReport:
|
|
48
|
+
"""A teardown that didn't start: everything ``context`` records that's still up is left."""
|
|
49
|
+
return cls(left=_pending(context))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def recorded_sandboxes(context: TaskStepContext) -> tuple[RecordedSandbox, ...]:
|
|
53
|
+
"""Every sandbox ``context`` records, once each. An agent placed on another record's sandbox shares its
|
|
54
|
+
id, so the sandbox is attributed to whichever made it."""
|
|
55
|
+
found: dict[str, RecordedSandbox] = {}
|
|
56
|
+
for sandbox in context.deployed_sandboxes:
|
|
57
|
+
found.setdefault(sandbox.sandbox_id, RecordedSandbox(sandbox.sandbox_id, sandbox.sandbox_type, "sandbox"))
|
|
58
|
+
for env in context.deployed_envs:
|
|
59
|
+
for sandbox_id, sandbox_type in _env_sandbox_ids(env):
|
|
60
|
+
found.setdefault(sandbox_id, RecordedSandbox(sandbox_id, sandbox_type, "env"))
|
|
61
|
+
for agent in context.deployed_agents:
|
|
62
|
+
if agent.sandbox_id:
|
|
63
|
+
found.setdefault(agent.sandbox_id, RecordedSandbox(agent.sandbox_id, agent.sandbox_type, "agent"))
|
|
64
|
+
return tuple(found.values())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sandbox_ids(record) -> list[str]:
|
|
68
|
+
"""The ids of the sandboxes behind one deployed env, agent or bare sandbox record."""
|
|
69
|
+
if isinstance(record, DeployedEnv):
|
|
70
|
+
return [sandbox_id for sandbox_id, _ in _env_sandbox_ids(record)]
|
|
71
|
+
return [record.sandbox_id] if record.sandbox_id else []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def teardown_run(context: TaskStepContext, *, timeout: float = TERMINATE_TIMEOUT_SECONDS) -> TeardownReport:
|
|
75
|
+
"""Terminate every sandbox ``context`` records, all at once, each within ``timeout`` seconds, and remove a
|
|
76
|
+
local one's work folder once it's down.
|
|
77
|
+
|
|
78
|
+
Best-effort: a failure is logged and reported, never raised. A sandbox already torn down, by a
|
|
79
|
+
``teardown_sandboxes`` step or an earlier call, isn't terminated again, though a local one's folder
|
|
80
|
+
still goes. Under ``Interrupts.gather`` the first signal spares it. Cancelling it stops it early: it
|
|
81
|
+
returns what it reached, with the rest in ``left``, rather than raising."""
|
|
82
|
+
tearing_down()
|
|
83
|
+
pending = _pending(context)
|
|
84
|
+
terminated, failed = [], []
|
|
85
|
+
|
|
86
|
+
async def take_down(sandbox: RecordedSandbox) -> None:
|
|
87
|
+
if sandbox not in pending: # torn down already: only a local one's folder can be left
|
|
88
|
+
if why := await _remove_folder(sandbox):
|
|
89
|
+
failed.append((sandbox, why))
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
async with asyncio.timeout(timeout):
|
|
93
|
+
handle = await _terminate(sandbox)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
reason = f"timed out after {timeout:g}s" if isinstance(e, TimeoutError) else _described(e)
|
|
96
|
+
logger.warning("teardown: %s (%s) was not terminated: %s", sandbox.sandbox_id, kind(sandbox), reason)
|
|
97
|
+
failed.append((sandbox, reason))
|
|
98
|
+
return
|
|
99
|
+
context.metadata[TORN_DOWN_KEY] = [*context.metadata.get(TORN_DOWN_KEY, []), sandbox.sandbox_id]
|
|
100
|
+
if isinstance(handle, LocalSandbox) and (why := await _remove_folder(sandbox)):
|
|
101
|
+
failed.append((sandbox, why))
|
|
102
|
+
else:
|
|
103
|
+
terminated.append(sandbox)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
await asyncio.gather(*(take_down(sandbox) for sandbox in recorded_sandboxes(context)))
|
|
107
|
+
except asyncio.CancelledError:
|
|
108
|
+
pass
|
|
109
|
+
reached = {sandbox.sandbox_id for sandbox in terminated} | {sandbox.sandbox_id for sandbox, _ in failed}
|
|
110
|
+
return TeardownReport(tuple(terminated), tuple(failed), tuple(s for s in pending if s.sandbox_id not in reached))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _pending(context: TaskStepContext) -> tuple[RecordedSandbox, ...]:
|
|
114
|
+
torn_down = set(context.metadata.get(TORN_DOWN_KEY) or [])
|
|
115
|
+
return tuple(sandbox for sandbox in recorded_sandboxes(context) if sandbox.sandbox_id not in torn_down)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _remove_folder(sandbox: RecordedSandbox) -> str | None:
|
|
119
|
+
"""Remove a local sandbox's work folder, and say why when it can't be."""
|
|
120
|
+
try:
|
|
121
|
+
await asyncio.to_thread(remove_local_work_dir, sandbox.sandbox_id)
|
|
122
|
+
except OSError as e:
|
|
123
|
+
logger.warning("teardown: %s's work folder was not removed: %s", sandbox.sandbox_id, e)
|
|
124
|
+
return f"terminated, but its work folder wasn't removed: {e}"
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _terminate(sandbox: RecordedSandbox):
|
|
129
|
+
provider = (build_sandbox_provider(sandbox.sandbox_type) if sandbox.sandbox_type
|
|
130
|
+
else _DEFAULT_PROVIDERS[sandbox.slot]())
|
|
131
|
+
handle = await provider.get_sandbox(sandbox.sandbox_id)
|
|
132
|
+
await handle.terminate()
|
|
133
|
+
return handle
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _described(error: Exception) -> str:
|
|
137
|
+
return f"{type(error).__name__}: {error}" if str(error) else type(error).__name__
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def kind(sandbox: RecordedSandbox) -> str:
|
|
141
|
+
"""The backend a sandbox is on, as the CLI prints it."""
|
|
142
|
+
return sandbox.sandbox_type or f"the configured {sandbox.slot} provider"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""TaskStep module for AgentEnv."""
|
|
2
|
+
|
|
3
|
+
from agent_env.env.env import DeployedEnv
|
|
4
|
+
from .context import DeployedAgent, PromptResponse, TaskStepContext
|
|
5
|
+
from .task_step import TaskStep
|
|
6
|
+
from .task_steps import AddSkillsTaskStep, AgentPromptResponseVerifierTaskStep, BuildMcpCliTaskStep, CombineUniverseVerdictsStep, DeployAgentTaskStep, DeployEnvTaskStep, EnvOutcomeVerifierTaskStep, LoadArtifactTaskStep, ModifyEnvToolAccessStep, PromptAgentTaskStep, RubricsVerifierTaskStep, ScoreAggregator, ValidationGateAggregatorStep, VerifyCoreA2AProtocolStep, VerifyCoreEnvironmentProtocolStep, VerifyEnvironmentCardStep, VerifyMCPEnvAssessmentStep, VerifyMCPToolSchemaTaskStep, VerifySpecConformanceTaskStep, VerifyUniverseLoadExportRoundtripStep, VerifyA2AAgentCardStep, VerifyA2AAgentMCPStep, VerifyA2ASkillConfigStep, VerifyA2ATrajectoryStep
|
|
7
|
+
from .task_steps.verifiers.judge_utils.judge_output_format import JudgeOutputFormat, get_judge_output_format_spec
|
|
8
|
+
from .store import TaskStepStore, TaskStepQuery, get_task_step_store, set_task_step_store, reset_task_step_store
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DeployedAgent",
|
|
12
|
+
"DeployedEnv",
|
|
13
|
+
"JudgeOutputFormat",
|
|
14
|
+
"get_judge_output_format_spec",
|
|
15
|
+
"PromptResponse",
|
|
16
|
+
"ScoreAggregator",
|
|
17
|
+
"TaskStepContext",
|
|
18
|
+
"TaskStep",
|
|
19
|
+
"AddSkillsTaskStep",
|
|
20
|
+
"AgentPromptResponseVerifierTaskStep",
|
|
21
|
+
"BuildMcpCliTaskStep",
|
|
22
|
+
"DeployAgentTaskStep",
|
|
23
|
+
"DeployEnvTaskStep",
|
|
24
|
+
"EnvOutcomeVerifierTaskStep",
|
|
25
|
+
"LoadArtifactTaskStep",
|
|
26
|
+
"ModifyEnvToolAccessStep",
|
|
27
|
+
"PromptAgentTaskStep",
|
|
28
|
+
"RubricsVerifierTaskStep",
|
|
29
|
+
"VerifyMCPEnvAssessmentStep",
|
|
30
|
+
"VerifyMCPToolSchemaTaskStep",
|
|
31
|
+
"VerifySpecConformanceTaskStep",
|
|
32
|
+
"ValidationGateAggregatorStep",
|
|
33
|
+
"VerifyUniverseLoadExportRoundtripStep",
|
|
34
|
+
"CombineUniverseVerdictsStep",
|
|
35
|
+
"VerifyEnvironmentCardStep",
|
|
36
|
+
"VerifyCoreEnvironmentProtocolStep",
|
|
37
|
+
"VerifyA2AAgentCardStep",
|
|
38
|
+
"VerifyA2AAgentMCPStep",
|
|
39
|
+
"VerifyCoreA2AProtocolStep",
|
|
40
|
+
"VerifyA2ASkillConfigStep",
|
|
41
|
+
"VerifyA2ATrajectoryStep",
|
|
42
|
+
"TaskStepStore",
|
|
43
|
+
"TaskStepQuery",
|
|
44
|
+
"get_task_step_store",
|
|
45
|
+
"set_task_step_store",
|
|
46
|
+
"reset_task_step_store",
|
|
47
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Context dataclass for task step execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agent_env.env.env import DeployedEnv
|
|
10
|
+
|
|
11
|
+
_REDACTED_KEYS = {
|
|
12
|
+
"litellm_api_key", "judge_litellm_api_key", "usersim_api_key", "remote_tokens", "cf_access_client_secret",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _holds_redacted_key(value: Any) -> bool:
|
|
17
|
+
"""Whether ``value`` carries a ``_REDACTED_KEYS`` entry at any depth."""
|
|
18
|
+
return isinstance(value, dict) and any(
|
|
19
|
+
k in _REDACTED_KEYS or _holds_redacted_key(v) for k, v in value.items()
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def regraft_redacted_keys(source: dict, target: dict) -> None:
|
|
24
|
+
"""Copy every ``_REDACTED_KEYS`` value in ``source`` into ``target`` in place; the inverse
|
|
25
|
+
of ``_strip_redacted_keys`` for a context rebuilt from a stored document."""
|
|
26
|
+
for k, v in source.items():
|
|
27
|
+
if k in _REDACTED_KEYS:
|
|
28
|
+
target[k] = v
|
|
29
|
+
elif isinstance(v, dict):
|
|
30
|
+
child = target.get(k)
|
|
31
|
+
if not isinstance(child, dict):
|
|
32
|
+
# A secrets-only parent leaves nothing to rebuild from, so recreate it —
|
|
33
|
+
# but never over a value the rebuild did write.
|
|
34
|
+
if target.get(k) is not None or not _holds_redacted_key(v):
|
|
35
|
+
continue
|
|
36
|
+
child = target[k] = {}
|
|
37
|
+
regraft_redacted_keys(v, child)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _strip_redacted_keys(value: Any) -> Any:
|
|
41
|
+
"""Recursively strip `_REDACTED_KEYS` from any nested dicts in `value`.
|
|
42
|
+
|
|
43
|
+
Single source of truth used by both `to_safe_dict` (full-blob writes via
|
|
44
|
+
`record_task_failure`) and the path-level diff in `context_ops` so secrets
|
|
45
|
+
never reach Mongo regardless of which code path persists them.
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
return {k: _strip_redacted_keys(v) for k, v in value.items() if k not in _REDACTED_KEYS}
|
|
49
|
+
if isinstance(value, list):
|
|
50
|
+
return [_strip_redacted_keys(v) for v in value]
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DeployedAgent:
|
|
56
|
+
agent_name: str
|
|
57
|
+
api_url: str
|
|
58
|
+
sandbox_id: str | None = None
|
|
59
|
+
sandbox_type: str | None = None
|
|
60
|
+
a2a_url: str | None = None
|
|
61
|
+
a2a_card: dict | None = None
|
|
62
|
+
instance_id: str | None = None
|
|
63
|
+
role: str | None = None
|
|
64
|
+
network_policy: dict | None = None
|
|
65
|
+
# Installed straight onto the sandbox host (install_agent host mode), not into a container.
|
|
66
|
+
on_host: bool = False
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, data: dict) -> DeployedAgent:
|
|
70
|
+
return cls(
|
|
71
|
+
agent_name=data["agent_name"],
|
|
72
|
+
api_url=data["api_url"],
|
|
73
|
+
sandbox_id=data.get("sandbox_id"),
|
|
74
|
+
sandbox_type=data.get("sandbox_type"),
|
|
75
|
+
a2a_url=data.get("a2a_url"),
|
|
76
|
+
a2a_card=data.get("a2a_card"),
|
|
77
|
+
instance_id=data.get("instance_id"),
|
|
78
|
+
role=data.get("role"),
|
|
79
|
+
network_policy=data.get("network_policy"),
|
|
80
|
+
on_host=bool(data.get("on_host", False)),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class DeployedSandbox:
|
|
86
|
+
sandbox_name: str
|
|
87
|
+
sandbox_id: str
|
|
88
|
+
sandbox_mode: str
|
|
89
|
+
sandbox_type: str | None = None
|
|
90
|
+
tunnel_urls: dict[str, str] | None = None
|
|
91
|
+
vnc_url: str | None = None
|
|
92
|
+
instance_id: str | None = None
|
|
93
|
+
created_at_utc: str | None = None
|
|
94
|
+
expires_at_utc: str | None = None
|
|
95
|
+
network_policy: dict | None = None
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, data: dict) -> DeployedSandbox:
|
|
99
|
+
return cls(
|
|
100
|
+
sandbox_name=data["sandbox_name"],
|
|
101
|
+
sandbox_id=data["sandbox_id"],
|
|
102
|
+
sandbox_mode=data["sandbox_mode"],
|
|
103
|
+
sandbox_type=data.get("sandbox_type"),
|
|
104
|
+
tunnel_urls=data.get("tunnel_urls"),
|
|
105
|
+
vnc_url=data.get("vnc_url"),
|
|
106
|
+
instance_id=data.get("instance_id"),
|
|
107
|
+
created_at_utc=data.get("created_at_utc"),
|
|
108
|
+
expires_at_utc=data.get("expires_at_utc"),
|
|
109
|
+
network_policy=data.get("network_policy"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class PromptResponse:
|
|
115
|
+
prompt_id: str
|
|
116
|
+
response: str
|
|
117
|
+
prompt_text: str | None = None
|
|
118
|
+
agent_trajectory_s3_uri: str | None = None
|
|
119
|
+
agent_trajectory_s3_prefix: str | None = None
|
|
120
|
+
agent_trajectory_file_path: str | None = None
|
|
121
|
+
target_agent_per_turn_trajectory_s3_uris: list[str | None] | None = None
|
|
122
|
+
# A None entry means "identical to prompt_text" — the first turn of a
|
|
123
|
+
# prompt-mode step is not stored twice.
|
|
124
|
+
source_agent_per_turn_prompt_parts: list[list[dict] | None] | None = None
|
|
125
|
+
compact_trajectory_s3_uri: str | None = None
|
|
126
|
+
tool_call_count: int | None = None
|
|
127
|
+
model: str | None = None
|
|
128
|
+
error_type: str | None = None
|
|
129
|
+
error_code: str | None = None
|
|
130
|
+
error_message: str | None = None
|
|
131
|
+
agent_session_id: str = ""
|
|
132
|
+
a2a_context_id: str | None = None
|
|
133
|
+
agent_name: str | None = None
|
|
134
|
+
step_id: str | None = None
|
|
135
|
+
structured_output: Any = None
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_dict(cls, data: dict) -> PromptResponse:
|
|
139
|
+
return cls(
|
|
140
|
+
prompt_id=data["prompt_id"],
|
|
141
|
+
response=data["response"],
|
|
142
|
+
prompt_text=data.get("prompt_text"),
|
|
143
|
+
agent_trajectory_s3_uri=data.get("agent_trajectory_s3_uri"),
|
|
144
|
+
agent_trajectory_s3_prefix=data.get("agent_trajectory_s3_prefix"),
|
|
145
|
+
agent_trajectory_file_path=data.get("agent_trajectory_file_path"),
|
|
146
|
+
target_agent_per_turn_trajectory_s3_uris=data.get("target_agent_per_turn_trajectory_s3_uris"),
|
|
147
|
+
source_agent_per_turn_prompt_parts=data.get("source_agent_per_turn_prompt_parts"),
|
|
148
|
+
compact_trajectory_s3_uri=data.get("compact_trajectory_s3_uri"),
|
|
149
|
+
tool_call_count=data.get("tool_call_count"),
|
|
150
|
+
model=data.get("model"),
|
|
151
|
+
error_type=data.get("error_type"),
|
|
152
|
+
error_code=data.get("error_code"),
|
|
153
|
+
error_message=data.get("error_message"),
|
|
154
|
+
agent_session_id=data.get("agent_session_id", ""),
|
|
155
|
+
a2a_context_id=data.get("a2a_context_id"),
|
|
156
|
+
agent_name=data.get("agent_name"),
|
|
157
|
+
step_id=data.get("step_id"),
|
|
158
|
+
structured_output=data.get("structured_output"),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class TaskStepContext:
|
|
164
|
+
deployed_envs: list[DeployedEnv] = field(default_factory=list)
|
|
165
|
+
deployed_agents: list[DeployedAgent] = field(default_factory=list)
|
|
166
|
+
deployed_sandboxes: list[DeployedSandbox] = field(default_factory=list)
|
|
167
|
+
prompt_responses: list[PromptResponse] = field(default_factory=list)
|
|
168
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
169
|
+
agent_model: str | None = None
|
|
170
|
+
default_agent_model: str | None = None
|
|
171
|
+
agent_artifact_id: str | None = None
|
|
172
|
+
agent_harness: str | None = None
|
|
173
|
+
instance_id: str | None = None
|
|
174
|
+
|
|
175
|
+
def to_safe_dict(self) -> dict[str, Any]:
|
|
176
|
+
"""Return a dict representation with sensitive keys recursively removed."""
|
|
177
|
+
d = dataclasses.asdict(self)
|
|
178
|
+
if "metadata" in d:
|
|
179
|
+
d["metadata"] = _strip_redacted_keys(d["metadata"])
|
|
180
|
+
return d
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_dict(cls, data: dict[str, Any]) -> TaskStepContext:
|
|
184
|
+
return cls(
|
|
185
|
+
deployed_envs=[DeployedEnv.from_dict(e) for e in data.get("deployed_envs", [])],
|
|
186
|
+
deployed_agents=[DeployedAgent.from_dict(a) for a in data.get("deployed_agents", [])],
|
|
187
|
+
deployed_sandboxes=[DeployedSandbox.from_dict(s) for s in data.get("deployed_sandboxes", [])],
|
|
188
|
+
prompt_responses=[PromptResponse.from_dict(p) for p in data.get("prompt_responses", [])],
|
|
189
|
+
metadata=data.get("metadata", {}),
|
|
190
|
+
agent_model=data.get("agent_model"),
|
|
191
|
+
default_agent_model=data.get("default_agent_model"),
|
|
192
|
+
agent_artifact_id=data.get("agent_artifact_id"),
|
|
193
|
+
agent_harness=data.get("agent_harness"),
|
|
194
|
+
instance_id=data.get("instance_id"),
|
|
195
|
+
)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Build MongoDB update specs from TaskStepContext diffs.
|
|
2
|
+
|
|
3
|
+
Replaces the read-merge-write CAS pattern with path-level Mongo ops. A single
|
|
4
|
+
document's disjoint-path writes are atomic in Mongo, so concurrent workers no
|
|
5
|
+
longer need application-layer merging or a retry loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import types
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Union, get_args, get_origin, get_type_hints
|
|
14
|
+
|
|
15
|
+
from agent_env.task_step.context import TaskStepContext, _REDACTED_KEYS, _strip_redacted_keys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ContextUpdateOps:
|
|
20
|
+
"""A backend-agnostic update spec describing a single step's changes to TaskInstance.context.
|
|
21
|
+
|
|
22
|
+
Dot-paths are rooted at the TaskInstance document (they include the
|
|
23
|
+
``context.`` prefix). Compile to a backend-agnostic ``UpdateSpec`` via `to_update_spec`.
|
|
24
|
+
|
|
25
|
+
Lists are set-semantic: when a step's "post" snapshot reads the shared in-memory
|
|
26
|
+
context it sees concurrent siblings' appends too, so the diff's tail may include
|
|
27
|
+
their items and the write must dedupe (``$addToSet`` via ``to_update_spec``, or
|
|
28
|
+
the Python ``_union`` the completion CAS in ``task/store.py`` applies). **Constraint:**
|
|
29
|
+
items in any list must be uniquely identifiable by value, or duplicates are silently
|
|
30
|
+
dropped on write; ``deployed_envs`` are told apart by ``instance_id`` (``step_journal._item_key``).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
sets: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
unsets: set[str] = field(default_factory=set)
|
|
35
|
+
add_to_sets: dict[str, list[Any]] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
def is_empty(self) -> bool:
|
|
38
|
+
return not (self.sets or self.unsets or self.add_to_sets)
|
|
39
|
+
|
|
40
|
+
def to_update_spec(self) -> "UpdateSpec":
|
|
41
|
+
"""Convert to a backend-agnostic UpdateSpec (1:1 field mapping)."""
|
|
42
|
+
from agent_env.store.document_store import UpdateSpec
|
|
43
|
+
|
|
44
|
+
return UpdateSpec(
|
|
45
|
+
set=dict(self.sets),
|
|
46
|
+
unset=set(self.unsets),
|
|
47
|
+
add_to_set={p: list(v) for p, v in self.add_to_sets.items()},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def to_journal_dict(self) -> dict:
|
|
51
|
+
"""Mongo-safe encoding: dotted paths as values, never as keys."""
|
|
52
|
+
return {
|
|
53
|
+
"sets": [{"path": p, "value": v} for p, v in self.sets.items()],
|
|
54
|
+
"unsets": sorted(self.unsets),
|
|
55
|
+
"add_to_sets": [{"path": p, "items": list(v)} for p, v in self.add_to_sets.items()],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_journal_dict(cls, d: dict) -> "ContextUpdateOps":
|
|
60
|
+
return cls(
|
|
61
|
+
sets={e["path"]: e["value"] for e in d.get("sets", [])},
|
|
62
|
+
unsets=set(d.get("unsets", [])),
|
|
63
|
+
add_to_sets={e["path"]: list(e["items"]) for e in d.get("add_to_sets", [])},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class _FieldClassification:
|
|
69
|
+
scalars: tuple[str, ...]
|
|
70
|
+
lists: tuple[str, ...]
|
|
71
|
+
dicts: tuple[str, ...]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _classify_context_fields() -> _FieldClassification:
|
|
75
|
+
"""Partition TaskStepContext fields into scalar/list/dict via type introspection.
|
|
76
|
+
|
|
77
|
+
Lists are assumed to be set-semantic (emitted via `$addToSet`). Dicts are
|
|
78
|
+
walked recursively for leaf-level path diffs. If a future field needs
|
|
79
|
+
different semantics, classification must be made explicit here.
|
|
80
|
+
"""
|
|
81
|
+
scalars: list[str] = []
|
|
82
|
+
lists: list[str] = []
|
|
83
|
+
dicts: list[str] = []
|
|
84
|
+
hints = get_type_hints(TaskStepContext)
|
|
85
|
+
for f in dataclasses.fields(TaskStepContext):
|
|
86
|
+
kind = _origin_kind(hints[f.name])
|
|
87
|
+
if kind is list:
|
|
88
|
+
lists.append(f.name)
|
|
89
|
+
elif kind is dict:
|
|
90
|
+
dicts.append(f.name)
|
|
91
|
+
else:
|
|
92
|
+
scalars.append(f.name)
|
|
93
|
+
return _FieldClassification(tuple(scalars), tuple(lists), tuple(dicts))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _origin_kind(tp: Any) -> Any:
|
|
97
|
+
"""Resolve ``tp`` to list/dict/other, stripping Optional/Union where needed."""
|
|
98
|
+
origin = get_origin(tp)
|
|
99
|
+
if origin is Union or origin is types.UnionType:
|
|
100
|
+
non_none = [a for a in get_args(tp) if a is not type(None)]
|
|
101
|
+
if len(non_none) == 1:
|
|
102
|
+
return _origin_kind(non_none[0])
|
|
103
|
+
return None
|
|
104
|
+
return origin
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_FIELDS = _classify_context_fields()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_context_update_ops(
|
|
111
|
+
pre: TaskStepContext | None,
|
|
112
|
+
post: TaskStepContext,
|
|
113
|
+
) -> ContextUpdateOps:
|
|
114
|
+
"""Diff two context snapshots into a path-level Mongo update spec.
|
|
115
|
+
|
|
116
|
+
- Scalar fields: `$set` on change, `$unset` on value→None.
|
|
117
|
+
- List fields (set-semantic): new items (by equality) go to `$addToSet`.
|
|
118
|
+
- Dict fields (e.g. `metadata`): walked recursively so only leaf-level
|
|
119
|
+
differences emit. Concurrent writers to sibling leaves of the same
|
|
120
|
+
parent don't overwrite each other. Lists inside dicts use `$addToSet`
|
|
121
|
+
for prefix-extending writes (so concurrent appenders don't double-add);
|
|
122
|
+
non-prefix mutations (reorder, pop, replacement) emit wholesale `$set`.
|
|
123
|
+
- Redaction (`_REDACTED_KEYS`) applied at every emission point.
|
|
124
|
+
|
|
125
|
+
Dict keys containing ``.`` or starting with ``$`` fall back to a
|
|
126
|
+
wholesale ``$set`` of the nearest enclosing subtree (see ``_diff_dict``).
|
|
127
|
+
"""
|
|
128
|
+
ops = ContextUpdateOps()
|
|
129
|
+
pre_ctx = pre if pre is not None else TaskStepContext()
|
|
130
|
+
|
|
131
|
+
for name in _FIELDS.scalars:
|
|
132
|
+
pre_v = getattr(pre_ctx, name)
|
|
133
|
+
post_v = getattr(post, name)
|
|
134
|
+
if pre_v == post_v:
|
|
135
|
+
continue
|
|
136
|
+
path = f"context.{name}"
|
|
137
|
+
if post_v is None:
|
|
138
|
+
ops.unsets.add(path)
|
|
139
|
+
else:
|
|
140
|
+
ops.sets[path] = post_v
|
|
141
|
+
|
|
142
|
+
for name in _FIELDS.lists:
|
|
143
|
+
pre_items = getattr(pre_ctx, name)
|
|
144
|
+
post_items = getattr(post, name)
|
|
145
|
+
new_items = [x for x in post_items if x not in pre_items]
|
|
146
|
+
if new_items:
|
|
147
|
+
ops.add_to_sets[f"context.{name}"] = [
|
|
148
|
+
dataclasses.asdict(x) if dataclasses.is_dataclass(x) else x
|
|
149
|
+
for x in new_items
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
for name in _FIELDS.dicts:
|
|
153
|
+
_diff_dict(f"context.{name}", getattr(pre_ctx, name), getattr(post, name), ops)
|
|
154
|
+
|
|
155
|
+
return ops
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _diff_dict(
|
|
159
|
+
path: str, pre: dict[str, Any], post: dict[str, Any], ops: ContextUpdateOps,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Recursively diff `pre` vs `post`, emitting path-level ops:
|
|
162
|
+
|
|
163
|
+
- Keys removed from pre → `$unset`.
|
|
164
|
+
- New dict-valued keys → recurse against `{}` so concurrent writers of
|
|
165
|
+
disjoint sub-keys emit distinct leaf paths (not the whole subtree).
|
|
166
|
+
- New list-valued keys → `$addToSet` (creates array if missing; dedupes
|
|
167
|
+
against what concurrent first-creators already wrote).
|
|
168
|
+
- New scalar keys → `$set`.
|
|
169
|
+
- Both dicts → recurse.
|
|
170
|
+
- Both lists, pre is prefix of post → `$addToSet` of the tail.
|
|
171
|
+
- Lists otherwise (reorder, pop, replacement) → wholesale `$set` (last write wins).
|
|
172
|
+
- Scalars differ → `$set`.
|
|
173
|
+
|
|
174
|
+
Redacted keys (`_REDACTED_KEYS`) are skipped at every level; redacted
|
|
175
|
+
descendants of emitted subtrees are stripped via `_strip_redacted_keys`.
|
|
176
|
+
|
|
177
|
+
If immediate keys on either side are unmappable to Mongo paths
|
|
178
|
+
(contain ``.`` or start with ``$``), emit a wholesale ``$set`` of the
|
|
179
|
+
whole dict at ``path`` instead of recursing.
|
|
180
|
+
"""
|
|
181
|
+
if _has_unmappable_keys(pre) or _has_unmappable_keys(post):
|
|
182
|
+
ops.sets[path] = _strip_redacted_keys(post)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
for k in pre.keys() - post.keys():
|
|
186
|
+
if k in _REDACTED_KEYS:
|
|
187
|
+
continue
|
|
188
|
+
ops.unsets.add(f"{path}.{k}")
|
|
189
|
+
|
|
190
|
+
for k, post_v in post.items():
|
|
191
|
+
if k in _REDACTED_KEYS:
|
|
192
|
+
continue
|
|
193
|
+
sub_path = f"{path}.{k}"
|
|
194
|
+
if k not in pre:
|
|
195
|
+
if isinstance(post_v, dict):
|
|
196
|
+
_diff_dict(sub_path, {}, post_v, ops)
|
|
197
|
+
elif isinstance(post_v, list):
|
|
198
|
+
if post_v:
|
|
199
|
+
ops.add_to_sets[sub_path] = [_strip_redacted_keys(x) for x in post_v]
|
|
200
|
+
else:
|
|
201
|
+
ops.sets[sub_path] = _strip_redacted_keys(post_v)
|
|
202
|
+
continue
|
|
203
|
+
pre_v = pre[k]
|
|
204
|
+
if isinstance(pre_v, dict) and isinstance(post_v, dict):
|
|
205
|
+
_diff_dict(sub_path, pre_v, post_v, ops)
|
|
206
|
+
elif isinstance(pre_v, list) and isinstance(post_v, list):
|
|
207
|
+
if _is_prefix(pre_v, post_v):
|
|
208
|
+
# A re-append is a no-op on the write, so the journaled diff must be one too.
|
|
209
|
+
tail = [x for x in post_v[len(pre_v):] if x not in pre_v]
|
|
210
|
+
if tail:
|
|
211
|
+
ops.add_to_sets[sub_path] = [_strip_redacted_keys(x) for x in tail]
|
|
212
|
+
elif pre_v != post_v:
|
|
213
|
+
ops.sets[sub_path] = _strip_redacted_keys(post_v)
|
|
214
|
+
elif pre_v != post_v:
|
|
215
|
+
ops.sets[sub_path] = _strip_redacted_keys(post_v)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_prefix(pre: list[Any], post: list[Any]) -> bool:
|
|
219
|
+
return len(post) >= len(pre) and post[: len(pre)] == pre
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _has_unmappable_keys(d: dict[str, Any]) -> bool:
|
|
223
|
+
"""True if any immediate key in `d` can't be a Mongo dot-notation path component."""
|
|
224
|
+
return any(not isinstance(k, str) or "." in k or k.startswith("$") for k in d)
|