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,486 @@
|
|
|
1
|
+
"""Verify a deployed MCP server conforms to its OpenAPI spec (request side).
|
|
2
|
+
|
|
3
|
+
Diffs the baked spec (served at ``/openapi.yaml``) against the live ``list_tools()``
|
|
4
|
+
surface: tool presence, parameter names, types, enums, required-ness. Response shape
|
|
5
|
+
is out of scope.
|
|
6
|
+
|
|
7
|
+
The spec is a FLOOR: what it promises must match, but a server may offer more
|
|
8
|
+
(``extra_tool`` / ``extra_param`` are reported, not blocking) — that keeps framework tools
|
|
9
|
+
like ``<service>_health`` from failing every server. The exception is an undeclared param
|
|
10
|
+
the tool *requires*, which no spec-following caller can satisfy.
|
|
11
|
+
|
|
12
|
+
Record-only, and not in ``DEFAULT_REQUIRED_GATES``: findings persist under
|
|
13
|
+
``mcp_spec_conformance`` so drift stays measurable, and a pipeline can opt back into
|
|
14
|
+
enforcement by passing ``spec_conformance`` in ``ValidationGateAggregatorStep``'s
|
|
15
|
+
``required_gates`` (a step arg, not env metadata). No baked spec = ``skipped``. Execution
|
|
16
|
+
errors (unreachable MCP) still raise.
|
|
17
|
+
|
|
18
|
+
Keep the diff logic in sync with the universe-generation pipeline's
|
|
19
|
+
``synthetic_mcp_server_generation/validation/spec_conformance.py`` (same diff at generation
|
|
20
|
+
time); tool shape differs — MCP objects here, dicts there.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, ClassVar, Optional
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
import yaml
|
|
30
|
+
from mcp import ClientSession
|
|
31
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
32
|
+
|
|
33
|
+
from agent_env.env import legacy_protocol
|
|
34
|
+
from agent_env.task_step.context import TaskStepContext
|
|
35
|
+
from agent_env.entity_refs import EntityRef
|
|
36
|
+
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
|
|
37
|
+
from agent_env.task_step.task_steps.mcp_env_validator.verify_mcp_tool_schema import (
|
|
38
|
+
resolve_defs_ref,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# OpenAPI HTTP methods that can carry an operationId → MCP tool.
|
|
44
|
+
_HTTP_METHODS = ("get", "post", "put", "delete", "patch", "head", "options", "trace")
|
|
45
|
+
|
|
46
|
+
# Shared by the spec fetch and the MCP tool-list read; httpx's default is too short.
|
|
47
|
+
_HTTP_TIMEOUT_S = 30.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_openapi_ref(node: Any, spec: dict) -> Any:
|
|
51
|
+
"""Follow ``#/components/...`` ``$ref`` pointers against the whole spec.
|
|
52
|
+
|
|
53
|
+
Sibling keys on the referencing node (e.g. ``description``) override the
|
|
54
|
+
resolved target. Bounded against ``$ref`` cycles; returns the node unchanged
|
|
55
|
+
if a pointer can't be resolved.
|
|
56
|
+
"""
|
|
57
|
+
seen: set[str] = set()
|
|
58
|
+
while isinstance(node, dict) and "$ref" in node:
|
|
59
|
+
ref = node["$ref"]
|
|
60
|
+
if not isinstance(ref, str) or not ref.startswith("#/") or ref in seen:
|
|
61
|
+
return node
|
|
62
|
+
seen.add(ref)
|
|
63
|
+
target: Any = spec
|
|
64
|
+
for part in ref[2:].split("/"):
|
|
65
|
+
if not isinstance(target, dict) or part not in target:
|
|
66
|
+
return node
|
|
67
|
+
target = target[part]
|
|
68
|
+
siblings = {k: v for k, v in node.items() if k != "$ref"}
|
|
69
|
+
node = {**target, **siblings} if isinstance(target, dict) else target
|
|
70
|
+
return node
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _schema_type_and_enum(schema: Any, spec: dict) -> tuple[Optional[str], Optional[list]]:
|
|
74
|
+
"""Extract a comparable (type, sorted-enum) pair from an OpenAPI schema node.
|
|
75
|
+
|
|
76
|
+
Handles ``$ref`` and ``anyOf``/``oneOf``/``allOf`` (first non-null typed variant).
|
|
77
|
+
"""
|
|
78
|
+
schema = _resolve_openapi_ref(schema, spec)
|
|
79
|
+
if not isinstance(schema, dict):
|
|
80
|
+
return None, None
|
|
81
|
+
typ = schema.get("type")
|
|
82
|
+
enum = schema.get("enum")
|
|
83
|
+
if typ is None:
|
|
84
|
+
for key in ("anyOf", "oneOf", "allOf"):
|
|
85
|
+
for variant in schema.get(key, []) or []:
|
|
86
|
+
v = _resolve_openapi_ref(variant, spec)
|
|
87
|
+
if isinstance(v, dict) and v.get("type") and v.get("type") != "null":
|
|
88
|
+
typ = v.get("type")
|
|
89
|
+
enum = enum or v.get("enum")
|
|
90
|
+
break
|
|
91
|
+
if typ:
|
|
92
|
+
break
|
|
93
|
+
return typ, (sorted(str(e) for e in enum) if enum else None)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def normalize_spec_params(spec: dict) -> dict[str, dict[str, dict]]:
|
|
97
|
+
"""Map each ``operationId`` → ``{param_name: {type, enum, required}}``.
|
|
98
|
+
|
|
99
|
+
Collects path-item-level and operation-level ``parameters`` plus ``requestBody``
|
|
100
|
+
JSON properties.
|
|
101
|
+
"""
|
|
102
|
+
result: dict[str, dict[str, dict]] = {}
|
|
103
|
+
paths = spec.get("paths", {}) or {}
|
|
104
|
+
for _path, methods in paths.items():
|
|
105
|
+
if not isinstance(methods, dict):
|
|
106
|
+
continue
|
|
107
|
+
# Path-item-level parameters apply to every operation on the path.
|
|
108
|
+
shared_params = methods.get("parameters", []) or []
|
|
109
|
+
for method, op in methods.items():
|
|
110
|
+
if method.lower() not in _HTTP_METHODS or not isinstance(op, dict):
|
|
111
|
+
continue
|
|
112
|
+
op_id = op.get("operationId")
|
|
113
|
+
if not op_id:
|
|
114
|
+
continue
|
|
115
|
+
params: dict[str, dict] = {}
|
|
116
|
+
|
|
117
|
+
# Operation-level last so it overrides a same-named path-item parameter.
|
|
118
|
+
for p in [*shared_params, *(op.get("parameters", []) or [])]:
|
|
119
|
+
p = _resolve_openapi_ref(p, spec)
|
|
120
|
+
if not isinstance(p, dict) or "name" not in p:
|
|
121
|
+
continue
|
|
122
|
+
typ, enum = _schema_type_and_enum(p.get("schema", {}) or {}, spec)
|
|
123
|
+
params[p["name"]] = {
|
|
124
|
+
"type": typ,
|
|
125
|
+
"enum": enum,
|
|
126
|
+
"required": bool(p.get("required", False)),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
body = op.get("requestBody")
|
|
130
|
+
if isinstance(body, dict):
|
|
131
|
+
body = _resolve_openapi_ref(body, spec)
|
|
132
|
+
schema = (
|
|
133
|
+
(body.get("content", {}) or {})
|
|
134
|
+
.get("application/json", {})
|
|
135
|
+
.get("schema", {})
|
|
136
|
+
)
|
|
137
|
+
schema = _resolve_openapi_ref(schema, spec) if schema else {}
|
|
138
|
+
if isinstance(schema, dict):
|
|
139
|
+
req = set(schema.get("required", []) or [])
|
|
140
|
+
for name, psch in (schema.get("properties", {}) or {}).items():
|
|
141
|
+
typ, enum = _schema_type_and_enum(psch or {}, spec)
|
|
142
|
+
entry = {"type": typ, "enum": enum, "required": name in req}
|
|
143
|
+
# A parameter and a body property can share a name: keep the
|
|
144
|
+
# parameter's type/enum, require if either side requires.
|
|
145
|
+
existing = params.get(name)
|
|
146
|
+
if existing is not None:
|
|
147
|
+
entry["type"] = existing["type"] if existing["type"] is not None else typ
|
|
148
|
+
entry["enum"] = existing["enum"] if existing["enum"] is not None else enum
|
|
149
|
+
entry["required"] = existing["required"] or entry["required"]
|
|
150
|
+
params[name] = entry
|
|
151
|
+
|
|
152
|
+
result[op_id] = params
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def normalize_tool_params(input_schema: dict) -> dict[str, dict]:
|
|
157
|
+
"""Map a live MCP tool's ``inputSchema`` → ``{param_name: {type, enum, required}}``.
|
|
158
|
+
|
|
159
|
+
Reuses ``resolve_defs_ref`` for ``#/$defs/``; handles ``anyOf``/``oneOf``/``allOf``
|
|
160
|
+
symmetrically with the spec side (``_schema_type_and_enum``).
|
|
161
|
+
"""
|
|
162
|
+
input_schema = input_schema or {}
|
|
163
|
+
props = input_schema.get("properties", {}) or {}
|
|
164
|
+
required = set(input_schema.get("required", []) or [])
|
|
165
|
+
out: dict[str, dict] = {}
|
|
166
|
+
for name, psch in props.items():
|
|
167
|
+
if not isinstance(psch, dict):
|
|
168
|
+
out[name] = {"type": None, "enum": None, "required": name in required}
|
|
169
|
+
continue
|
|
170
|
+
resolved = resolve_defs_ref(psch, input_schema)
|
|
171
|
+
typ = resolved.get("type")
|
|
172
|
+
enum = resolved.get("enum")
|
|
173
|
+
if typ is None:
|
|
174
|
+
for key in ("anyOf", "oneOf", "allOf"):
|
|
175
|
+
for v in resolved.get(key, []) or []:
|
|
176
|
+
rv = (
|
|
177
|
+
resolve_defs_ref(v, input_schema)
|
|
178
|
+
if isinstance(v, dict)
|
|
179
|
+
else v
|
|
180
|
+
)
|
|
181
|
+
if isinstance(rv, dict) and rv.get("type") and rv.get("type") != "null":
|
|
182
|
+
typ = rv.get("type")
|
|
183
|
+
enum = enum or rv.get("enum")
|
|
184
|
+
break
|
|
185
|
+
if typ:
|
|
186
|
+
break
|
|
187
|
+
out[name] = {
|
|
188
|
+
"type": typ,
|
|
189
|
+
"enum": sorted(str(e) for e in enum) if enum else None,
|
|
190
|
+
"required": name in required,
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Findings that break a spec-following caller. `extra_tool`/`extra_param` are omitted on
|
|
196
|
+
# purpose: a server offering more than it documents breaks nobody, so it's informational.
|
|
197
|
+
BLOCKING_FINDING_KINDS = frozenset({
|
|
198
|
+
"missing_tool",
|
|
199
|
+
"missing_param",
|
|
200
|
+
"type_mismatch",
|
|
201
|
+
"enum_mismatch",
|
|
202
|
+
"required_mismatch",
|
|
203
|
+
"ambiguous_tool", # can't tell two tools apart -> could be masking a missing one
|
|
204
|
+
# The one extra that isn't free: a caller reading only the spec omits an undeclared
|
|
205
|
+
# required param, so the call fails. Undeclared-and-optional stays informational.
|
|
206
|
+
"extra_required_param",
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def strip_service_prefix(name: str, service_name: Optional[str]) -> str:
|
|
211
|
+
"""Drop a leading ``<service_name>_`` to exclude the prefix from name comparison.
|
|
212
|
+
|
|
213
|
+
Tools are namespaced (one gateway fronts every service); spec ``operationId``s mostly
|
|
214
|
+
omit it. Stripped on BOTH sides because the prefix sits on the tool for some servers
|
|
215
|
+
(``city_get_crime_rate`` vs ``get_crime_rate``) and on the spec for others (``jira``
|
|
216
|
+
declares ``jira_add_comment`` for tool ``add_comment``). Symmetric stripping keeps
|
|
217
|
+
``city_hall_info`` matching itself. Params/types/enums still compared strictly.
|
|
218
|
+
"""
|
|
219
|
+
if not service_name:
|
|
220
|
+
return name
|
|
221
|
+
prefix = f"{service_name}_"
|
|
222
|
+
return name[len(prefix):] if name.startswith(prefix) else name
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _index_by_normalized(
|
|
226
|
+
pairs: list[tuple[str, dict]], service_name: Optional[str]
|
|
227
|
+
) -> tuple[dict[str, tuple[str, dict]], list[tuple[str, str, str]]]:
|
|
228
|
+
"""Key ``(original_name, params)`` pairs by prefix-stripped name.
|
|
229
|
+
|
|
230
|
+
Returns ``(index, collisions)``. On a collision (``health`` and ``<service>_health``)
|
|
231
|
+
the later name takes the slot and the earlier one drops out of the diff, so the pair is
|
|
232
|
+
also returned and reported as a blocking ``ambiguous_tool`` rather than lost silently.
|
|
233
|
+
"""
|
|
234
|
+
index: dict[str, tuple[str, dict]] = {}
|
|
235
|
+
collisions: list[tuple[str, str, str]] = []
|
|
236
|
+
for original, params in pairs:
|
|
237
|
+
key = strip_service_prefix(original, service_name)
|
|
238
|
+
if key in index:
|
|
239
|
+
collisions.append((key, index[key][0], original))
|
|
240
|
+
index[key] = (original, params)
|
|
241
|
+
return index, collisions
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _collision_reason(key: str, service_name: Optional[str]) -> str:
|
|
245
|
+
"""Why two names collided. Without a ``service_name`` nothing was stripped, so they are
|
|
246
|
+
literal duplicates — don't claim a prefix was involved."""
|
|
247
|
+
if not service_name:
|
|
248
|
+
return "are declared more than once"
|
|
249
|
+
return f"both reduce to '{key}' after stripping the '{service_name}_' prefix"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def diff_conformance(
|
|
253
|
+
spec: dict, tools: list, service_name: Optional[str] = None
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
"""Request-side conformance diff between an OpenAPI spec and live MCP tools.
|
|
256
|
+
|
|
257
|
+
``tools`` is a list of MCP SDK tool objects. ``service_name`` excludes the
|
|
258
|
+
``<service>_`` prefix from name comparison; omit it to compare verbatim.
|
|
259
|
+
|
|
260
|
+
Returns ``{passed, total_tools, spec_operations, findings, blocking_findings}``. Kinds:
|
|
261
|
+
``missing_tool``, ``extra_tool``, ``ambiguous_tool``, ``missing_param``,
|
|
262
|
+
``extra_param``, ``extra_required_param``, ``type_mismatch``, ``enum_mismatch``,
|
|
263
|
+
``required_mismatch``. Each finding carries ``blocking`` (see
|
|
264
|
+
:data:`BLOCKING_FINDING_KINDS`); ``passed`` reflects blocking findings only. Findings
|
|
265
|
+
name the ORIGINAL, unstripped tool.
|
|
266
|
+
"""
|
|
267
|
+
spec_params = normalize_spec_params(spec)
|
|
268
|
+
spec_ops, spec_collisions = _index_by_normalized(list(spec_params.items()), service_name)
|
|
269
|
+
live, live_collisions = _index_by_normalized(
|
|
270
|
+
[(t.name, normalize_tool_params(t.inputSchema or {})) for t in tools],
|
|
271
|
+
service_name,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
findings: list[dict] = []
|
|
275
|
+
spec_names, live_names = set(spec_ops), set(live)
|
|
276
|
+
|
|
277
|
+
for key, first, second in spec_collisions:
|
|
278
|
+
findings.append({
|
|
279
|
+
"kind": "ambiguous_tool", "tool": second,
|
|
280
|
+
"detail": f"spec operationIds '{first}' and '{second}' "
|
|
281
|
+
f"{_collision_reason(key, service_name)}",
|
|
282
|
+
})
|
|
283
|
+
for key, first, second in live_collisions:
|
|
284
|
+
findings.append({
|
|
285
|
+
"kind": "ambiguous_tool", "tool": second,
|
|
286
|
+
"detail": f"MCP tools '{first}' and '{second}' "
|
|
287
|
+
f"{_collision_reason(key, service_name)}",
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
for key in sorted(spec_names - live_names):
|
|
291
|
+
name = spec_ops[key][0]
|
|
292
|
+
findings.append({
|
|
293
|
+
"kind": "missing_tool", "tool": name,
|
|
294
|
+
"detail": f"operationId '{name}' in spec has no matching MCP tool",
|
|
295
|
+
})
|
|
296
|
+
for key in sorted(live_names - spec_names):
|
|
297
|
+
name = live[key][0]
|
|
298
|
+
findings.append({
|
|
299
|
+
"kind": "extra_tool", "tool": name,
|
|
300
|
+
"detail": f"MCP tool '{name}' is not declared in the spec",
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
for key in sorted(spec_names & live_names):
|
|
304
|
+
_spec_name, sp = spec_ops[key]
|
|
305
|
+
# Report against the live tool name — that's what a caller actually invokes.
|
|
306
|
+
name, lv = live[key]
|
|
307
|
+
for pname in sorted(set(sp) - set(lv)):
|
|
308
|
+
findings.append({
|
|
309
|
+
"kind": "missing_param", "tool": name, "param": pname,
|
|
310
|
+
"detail": f"spec param '{pname}' absent from tool inputSchema",
|
|
311
|
+
})
|
|
312
|
+
for pname in sorted(set(lv) - set(sp)):
|
|
313
|
+
required = lv[pname]["required"]
|
|
314
|
+
findings.append({
|
|
315
|
+
"kind": "extra_required_param" if required else "extra_param",
|
|
316
|
+
"tool": name, "param": pname,
|
|
317
|
+
"detail": f"tool param '{pname}' not declared in spec"
|
|
318
|
+
+ (" and is required, so a spec-following caller cannot call this"
|
|
319
|
+
" tool" if required else ""),
|
|
320
|
+
})
|
|
321
|
+
for pname in sorted(set(sp) & set(lv)):
|
|
322
|
+
s, l = sp[pname], lv[pname]
|
|
323
|
+
if s["type"] and l["type"] and s["type"] != l["type"]:
|
|
324
|
+
findings.append({
|
|
325
|
+
"kind": "type_mismatch", "tool": name, "param": pname,
|
|
326
|
+
"detail": f"spec type '{s['type']}' != tool type '{l['type']}'",
|
|
327
|
+
})
|
|
328
|
+
if s["enum"] is not None and l["enum"] is not None and s["enum"] != l["enum"]:
|
|
329
|
+
findings.append({
|
|
330
|
+
"kind": "enum_mismatch", "tool": name, "param": pname,
|
|
331
|
+
"detail": f"spec enum {s['enum']} != tool enum {l['enum']}",
|
|
332
|
+
})
|
|
333
|
+
if s["required"] != l["required"]:
|
|
334
|
+
findings.append({
|
|
335
|
+
"kind": "required_mismatch", "tool": name, "param": pname,
|
|
336
|
+
"detail": f"spec required={s['required']} != tool required={l['required']}",
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
for f in findings:
|
|
340
|
+
f["blocking"] = f["kind"] in BLOCKING_FINDING_KINDS
|
|
341
|
+
blocking = [f for f in findings if f["blocking"]]
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
"passed": len(blocking) == 0,
|
|
345
|
+
"total_tools": len(tools),
|
|
346
|
+
# Raw spec count, not the collision-collapsed index: it would otherwise under-report
|
|
347
|
+
# exactly when an `ambiguous_tool` finding says something is wrong.
|
|
348
|
+
"spec_operations": len(spec_params),
|
|
349
|
+
"findings": findings,
|
|
350
|
+
"blocking_findings": len(blocking),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def skipped_result(reason: str) -> dict[str, Any]:
|
|
355
|
+
"""Advisory 'nothing to check' verdict: ``skipped`` frees the gate, ``passed``
|
|
356
|
+
stays False so nothing reads it as a real conformance pass.
|
|
357
|
+
"""
|
|
358
|
+
return {
|
|
359
|
+
"passed": False, "skipped": True, "reason": reason,
|
|
360
|
+
"findings": [], "blocking_findings": 0,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# Statuses meaning "no spec is served here", not "the fetch broke".
|
|
365
|
+
_SPEC_ABSENT_STATUSES = (404, 405, 501)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _summarize(findings: list[dict], limit: int = 10) -> str:
|
|
369
|
+
"""One-line digest, blocking first so truncation drops noise rather than signal."""
|
|
370
|
+
ordered = sorted(findings, key=lambda f: not f.get("blocking"))
|
|
371
|
+
parts = []
|
|
372
|
+
for f in ordered[:limit]:
|
|
373
|
+
loc = f.get("tool", "")
|
|
374
|
+
if f.get("param"):
|
|
375
|
+
loc = f"{loc}.{f['param']}"
|
|
376
|
+
parts.append(f"{f['kind']}:{loc}")
|
|
377
|
+
extra = len(ordered) - limit
|
|
378
|
+
if extra > 0:
|
|
379
|
+
parts.append(f"(+{extra} more)")
|
|
380
|
+
return "; ".join(parts)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class VerifySpecConformanceTaskStep(TaskStep):
|
|
384
|
+
type: ClassVar[str] = "verify_spec_conformance"
|
|
385
|
+
entity_refs = (EntityRef.env("env_id"),)
|
|
386
|
+
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
id: str,
|
|
390
|
+
version: Optional[int],
|
|
391
|
+
env_id: str,
|
|
392
|
+
depends_on: Optional[list[TaskStepDependency]] = None,
|
|
393
|
+
fail_task_on_error: bool = True,
|
|
394
|
+
):
|
|
395
|
+
super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
|
|
396
|
+
self.env_id = env_id
|
|
397
|
+
|
|
398
|
+
def to_dict(self) -> dict:
|
|
399
|
+
base = super().to_dict()
|
|
400
|
+
base["env_id"] = self.env_id
|
|
401
|
+
return base
|
|
402
|
+
|
|
403
|
+
@classmethod
|
|
404
|
+
def from_dict(cls, data: dict) -> VerifySpecConformanceTaskStep:
|
|
405
|
+
return cls(**cls._base_from_dict(data), env_id=data["env_id"])
|
|
406
|
+
|
|
407
|
+
async def execute(self, context: TaskStepContext) -> TaskStepContext:
|
|
408
|
+
from agent_env.env.env import Env, gateway_url_of
|
|
409
|
+
|
|
410
|
+
deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None)
|
|
411
|
+
if deployed is None:
|
|
412
|
+
raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs")
|
|
413
|
+
|
|
414
|
+
env = Env.get(self.env_id, deployed.env_version)
|
|
415
|
+
environment_name = env.environment_name
|
|
416
|
+
|
|
417
|
+
# 1. Fetch the baked OpenAPI spec; no spec = skip, not block. A server the card doesn't list keeps today's path.
|
|
418
|
+
base_url = (await legacy_protocol.v1_base_url(deployed, gateway_url_of(deployed), environment_name)
|
|
419
|
+
or legacy_protocol.environment_base_url(gateway_url_of(deployed), environment_name))
|
|
420
|
+
spec_url = f"{base_url}/openapi.yaml"
|
|
421
|
+
logger.info(f"Fetching OpenAPI spec for '{environment_name}' at {spec_url}")
|
|
422
|
+
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
|
|
423
|
+
resp = await client.get(spec_url)
|
|
424
|
+
if resp.status_code in _SPEC_ABSENT_STATUSES:
|
|
425
|
+
return self._record(
|
|
426
|
+
context, env, environment_name,
|
|
427
|
+
skipped_result(f"no OpenAPI spec served at {spec_url} (HTTP {resp.status_code})"),
|
|
428
|
+
)
|
|
429
|
+
resp.raise_for_status()
|
|
430
|
+
spec = yaml.safe_load(resp.text)
|
|
431
|
+
if not isinstance(spec, dict) or not spec.get("paths"):
|
|
432
|
+
# Served but unusable (error page, empty doc): skip rather than emit a
|
|
433
|
+
# wall of bogus `extra_tool`s.
|
|
434
|
+
return self._record(
|
|
435
|
+
context, env, environment_name,
|
|
436
|
+
skipped_result(f"OpenAPI spec at {spec_url} has no usable 'paths' section"),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# 2. Read the live tool surface. Same explicit timeout as the spec fetch so a
|
|
440
|
+
# slow gateway can't trip httpx's short default and block the gate.
|
|
441
|
+
mcp_url = deployed.mcp_url
|
|
442
|
+
logger.info(f"Connecting to MCP at {mcp_url} to read live tools")
|
|
443
|
+
http_client = httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S)
|
|
444
|
+
try:
|
|
445
|
+
async with streamable_http_client(mcp_url, http_client=http_client) as (read_stream, write_stream, _):
|
|
446
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
447
|
+
await session.initialize()
|
|
448
|
+
tools_result = await session.list_tools()
|
|
449
|
+
finally:
|
|
450
|
+
await http_client.aclose()
|
|
451
|
+
tools = tools_result.tools
|
|
452
|
+
|
|
453
|
+
# 3. Diff. Without the tool-name prefix every tool reads as both missing and extra.
|
|
454
|
+
result = diff_conformance(spec, tools, service_name=environment_name)
|
|
455
|
+
logger.info(
|
|
456
|
+
f"Spec conformance for '{environment_name}': passed={result['passed']} "
|
|
457
|
+
f"tools={result['total_tools']} spec_ops={result['spec_operations']} "
|
|
458
|
+
f"blocking={result['blocking_findings']} findings={len(result['findings'])}"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
# 4. Persist (record-only): a failed verdict doesn't raise, so validate()
|
|
462
|
+
# completes and cleans up its sandboxes. `put` enforces.
|
|
463
|
+
return self._record(context, env, environment_name, result)
|
|
464
|
+
|
|
465
|
+
def _record(self, context: TaskStepContext, env, environment_name: str, result: dict) -> TaskStepContext:
|
|
466
|
+
"""Persist a verdict to env + context metadata and log it. ``merge_metadata``
|
|
467
|
+
because the schema verifier writes a sibling key on the same doc concurrently.
|
|
468
|
+
"""
|
|
469
|
+
env.merge_metadata({"mcp_spec_conformance": result})
|
|
470
|
+
context.metadata.setdefault("verifications", {})["spec_conformance"] = result
|
|
471
|
+
if result.get("skipped"):
|
|
472
|
+
logger.info(f"Spec conformance skipped for '{environment_name}': {result['reason']}")
|
|
473
|
+
elif result["findings"]:
|
|
474
|
+
# Logged whenever there is drift, not only on a failed verdict: informational-
|
|
475
|
+
# only findings still pass, and keying off `passed` silenced the normal case
|
|
476
|
+
# (every server reports its framework tools as `extra_*`). Advisory either
|
|
477
|
+
# way — this never blocks. Level tracks severity so expected framework-tool
|
|
478
|
+
# drift doesn't warn on every run and train operators to ignore it.
|
|
479
|
+
blocking = result["blocking_findings"]
|
|
480
|
+
log = logger.warning if blocking else logger.info
|
|
481
|
+
log(
|
|
482
|
+
f"Spec conformance drift for '{environment_name}' "
|
|
483
|
+
f"({blocking} blocking of {len(result['findings'])} "
|
|
484
|
+
f"finding(s)): {_summarize(result['findings'])}"
|
|
485
|
+
)
|
|
486
|
+
return context
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Task step that toggles role-based tool access on a deployed env's gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import ClassVar, Optional
|
|
7
|
+
|
|
8
|
+
from agent_env.env.gateway import TOOL_DISABLE_ACTION, TOOL_ENABLE_ACTION
|
|
9
|
+
from agent_env.env.gateway.constants import EXT_DISABLE_TOOL_URI, EXT_ENABLE_TOOL_URI
|
|
10
|
+
from agent_env.task_step.context import TaskStepContext
|
|
11
|
+
from agent_env.entity_refs import EntityRef
|
|
12
|
+
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ModifyEnvToolAccessStep(TaskStep):
|
|
18
|
+
type: ClassVar[str] = "modify_env_tool_access"
|
|
19
|
+
entity_refs = (EntityRef.env("env_id"),)
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
id: str,
|
|
24
|
+
version: Optional[int],
|
|
25
|
+
env_id: str,
|
|
26
|
+
action: str,
|
|
27
|
+
role: str,
|
|
28
|
+
tools: list[str],
|
|
29
|
+
depends_on: Optional[list[TaskStepDependency]] = None,
|
|
30
|
+
fail_task_on_error: bool = True,
|
|
31
|
+
):
|
|
32
|
+
super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
|
|
33
|
+
if action not in (TOOL_DISABLE_ACTION, TOOL_ENABLE_ACTION):
|
|
34
|
+
raise ValueError(f"action must be {TOOL_DISABLE_ACTION!r} or {TOOL_ENABLE_ACTION!r}, got {action!r}")
|
|
35
|
+
if not isinstance(role, str) or not role:
|
|
36
|
+
raise ValueError("role must be a non-empty string")
|
|
37
|
+
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
|
38
|
+
raise ValueError("tools must be a list of strings")
|
|
39
|
+
self.env_id = env_id
|
|
40
|
+
self.action = action
|
|
41
|
+
self.role = role
|
|
42
|
+
self.tools = tools
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict:
|
|
45
|
+
base = super().to_dict()
|
|
46
|
+
base["env_id"] = self.env_id
|
|
47
|
+
base["action"] = self.action
|
|
48
|
+
base["role"] = self.role
|
|
49
|
+
base["tools"] = self.tools
|
|
50
|
+
return base
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: dict) -> ModifyEnvToolAccessStep:
|
|
54
|
+
return cls(
|
|
55
|
+
**cls._base_from_dict(data),
|
|
56
|
+
env_id=data["env_id"],
|
|
57
|
+
action=data["action"],
|
|
58
|
+
role=data["role"],
|
|
59
|
+
tools=data["tools"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def execute(self, context: TaskStepContext) -> TaskStepContext:
|
|
63
|
+
deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None)
|
|
64
|
+
if deployed is None:
|
|
65
|
+
raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs")
|
|
66
|
+
body = await deployed.invoke(_TOOL_ACCESS_URIS[self.action], self.action, {"role": self.role, "tools": self.tools})
|
|
67
|
+
logger.info(f"{self.action} role={self.role!r} tools={self.tools} on env={self.env_id}: {body}")
|
|
68
|
+
context.metadata.setdefault("tool_access_changes", []).append({
|
|
69
|
+
"step_id": self.id,
|
|
70
|
+
"env_id": self.env_id,
|
|
71
|
+
"action": self.action,
|
|
72
|
+
"role": self.role,
|
|
73
|
+
"tools_requested": self.tools,
|
|
74
|
+
"role_state_after": {"disabled": body.get("disabled", []), "allowed": body.get("allowed", [])},
|
|
75
|
+
})
|
|
76
|
+
return context
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# The gateway extension each action is advertised under on the env card.
|
|
80
|
+
_TOOL_ACCESS_URIS = {TOOL_DISABLE_ACTION: EXT_DISABLE_TOOL_URI, TOOL_ENABLE_ACTION: EXT_ENABLE_TOOL_URI}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Combine the programmatic + agent-judge universe-compatibility verdicts.
|
|
2
|
+
|
|
3
|
+
This is the single authoritative writer of the ``UNIVERSE_COMPATIBILITY`` artifact. The round-trip
|
|
4
|
+
step computes the programmatic verdict into ``context.metadata`` (without persisting it), the rubrics
|
|
5
|
+
verifier records the agent-judge verdict, and this step — running last — merges them and persists the
|
|
6
|
+
result once, atomically. Keeping the write here (rather than in the round-trip step) means a crash or
|
|
7
|
+
judge failure before this step leaves NO half-written, ungated result masquerading as final.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import ClassVar, Optional
|
|
14
|
+
|
|
15
|
+
from agent_env.task_step.context import TaskStepContext
|
|
16
|
+
from agent_env.entity_refs import EntityRef
|
|
17
|
+
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CombineUniverseVerdictsStep(TaskStep):
|
|
23
|
+
type: ClassVar[str] = "combine_universe_verdicts"
|
|
24
|
+
entity_refs = (
|
|
25
|
+
EntityRef.env("env_id"),
|
|
26
|
+
EntityRef.artifact("universe_artifact_id", version_field="universe_artifact_version"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
id: str,
|
|
32
|
+
version: Optional[int],
|
|
33
|
+
env_id: str,
|
|
34
|
+
universe_artifact_id: str,
|
|
35
|
+
universe_artifact_version: int,
|
|
36
|
+
judge_verifier_id: str,
|
|
37
|
+
depends_on: Optional[list[TaskStepDependency]] = None,
|
|
38
|
+
fail_task_on_error: bool = True,
|
|
39
|
+
):
|
|
40
|
+
super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
|
|
41
|
+
self.env_id = env_id
|
|
42
|
+
self.universe_artifact_id = universe_artifact_id
|
|
43
|
+
self.universe_artifact_version = universe_artifact_version
|
|
44
|
+
self.judge_verifier_id = judge_verifier_id
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
base = super().to_dict()
|
|
48
|
+
base["env_id"] = self.env_id
|
|
49
|
+
base["universe_artifact_id"] = self.universe_artifact_id
|
|
50
|
+
base["universe_artifact_version"] = self.universe_artifact_version
|
|
51
|
+
base["judge_verifier_id"] = self.judge_verifier_id
|
|
52
|
+
return base
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, data: dict) -> CombineUniverseVerdictsStep:
|
|
56
|
+
return cls(
|
|
57
|
+
**cls._base_from_dict(data),
|
|
58
|
+
env_id=data["env_id"],
|
|
59
|
+
universe_artifact_id=data["universe_artifact_id"],
|
|
60
|
+
universe_artifact_version=data["universe_artifact_version"],
|
|
61
|
+
judge_verifier_id=data["judge_verifier_id"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def execute(self, context: TaskStepContext) -> TaskStepContext:
|
|
65
|
+
from agent_env.env.env_artifact_store import EnvArtifactType, get_env_artifact_store
|
|
66
|
+
from .verify_universe_agent_judge import apply_judge_verdict
|
|
67
|
+
|
|
68
|
+
verifs = context.metadata.get("verifications", {})
|
|
69
|
+
compat = verifs.get(EnvArtifactType.UNIVERSE_COMPATIBILITY)
|
|
70
|
+
if compat is None:
|
|
71
|
+
logger.warning("combine: no programmatic UNIVERSE_COMPATIBILITY result in context; nothing to persist")
|
|
72
|
+
return context
|
|
73
|
+
|
|
74
|
+
judge = verifs.get(self.judge_verifier_id)
|
|
75
|
+
if judge is not None:
|
|
76
|
+
apply_judge_verdict(compat, judge) # mutates compat in place; gates `compatible`
|
|
77
|
+
logger.info(f"combine: merged agent-judge verdict; compatible={compat.get('compatible')}")
|
|
78
|
+
else:
|
|
79
|
+
# Defensive: with a load-bearing judge (fail_task_on_error=True on the judge steps) we
|
|
80
|
+
# only reach here when the judge succeeded, so this is unexpected. Persist the
|
|
81
|
+
# programmatic result rather than drop it, but MARK it so downstream/UI can tell the
|
|
82
|
+
# verdict was NOT gated by the judge (don't mistake an ungated result for a complete one).
|
|
83
|
+
compat["agent_judge_skipped"] = True
|
|
84
|
+
logger.warning("combine: agent-judge verdict missing; persisting programmatic result UNGATED (agent_judge_skipped=True)")
|
|
85
|
+
|
|
86
|
+
# Re-point the dual-written "environments" twin at the merged "services" dict. The two are
|
|
87
|
+
# aliased when built, but serializing the context re-materializes them as separate
|
|
88
|
+
# dicts, and apply_judge_verdict only writes "services" — so without this the twin can
|
|
89
|
+
# persist stale.
|
|
90
|
+
if "services" in compat:
|
|
91
|
+
compat["environments"] = compat["services"]
|
|
92
|
+
|
|
93
|
+
# env_version must match what the round-trip step used so this upserts the same row.
|
|
94
|
+
deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None)
|
|
95
|
+
if deployed is None:
|
|
96
|
+
logger.warning(f"combine: env '{self.env_id}' not in deployed_envs; skipping persist")
|
|
97
|
+
return context
|
|
98
|
+
|
|
99
|
+
get_env_artifact_store().put(
|
|
100
|
+
env_id=self.env_id,
|
|
101
|
+
env_version=deployed.env_version,
|
|
102
|
+
artifact_id=self.universe_artifact_id,
|
|
103
|
+
artifact_version=self.universe_artifact_version,
|
|
104
|
+
type=EnvArtifactType.UNIVERSE_COMPATIBILITY,
|
|
105
|
+
data=compat,
|
|
106
|
+
)
|
|
107
|
+
return context
|