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,184 @@
|
|
|
1
|
+
"""Wall-clock-derived virtual clock for the env gateway (urn:agentenv:clock/v1).
|
|
2
|
+
|
|
3
|
+
Reads compute t0 + real_elapsed * rate, so virtual time advances on its own (independent of
|
|
4
|
+
tool calls) and reads never mutate it. Off by default: an unarmed clock 404s.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClockError(ValueError):
|
|
16
|
+
"""Invalid clock configuration (surfaced as HTTP 400)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MAX_RATE = 86400.0 # 1 real second = 1 virtual day (the max virtual-time speed)
|
|
20
|
+
|
|
21
|
+
# Fast-fail a t0 that would overflow within a real day of advancement at `rate` (frozen clocks
|
|
22
|
+
# exempt). Not the overflow guarantee — reads still saturate (_advance) if the gateway outlives it.
|
|
23
|
+
_OVERFLOW_HORIZON_S = 86400.0
|
|
24
|
+
_MAX_T0 = datetime.max.replace(tzinfo=timezone.utc)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_rate(rate) -> float:
|
|
28
|
+
if isinstance(rate, bool) or not isinstance(rate, (int, float)):
|
|
29
|
+
raise ClockError(f"rate must be a number, got {type(rate).__name__}")
|
|
30
|
+
# NaN passes both ordered bounds below, then poisons every later read with an uncatchable 500.
|
|
31
|
+
if not math.isfinite(rate):
|
|
32
|
+
raise ClockError(f"rate must be a finite number, got {rate}")
|
|
33
|
+
if rate < 0:
|
|
34
|
+
raise ClockError("rate must be non-negative")
|
|
35
|
+
if rate > MAX_RATE:
|
|
36
|
+
raise ClockError(f"rate {rate:g} exceeds max {MAX_RATE:g} (1 real s = 1 virtual day)")
|
|
37
|
+
return float(rate)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# fromisoformat is looser than RFC3339 (accepts basic format, week/ordinal dates, sub-minute
|
|
41
|
+
# offsets), so gate on the grammar first and let it do only the arithmetic. A space separator is ok.
|
|
42
|
+
_RFC3339_RE = re.compile(
|
|
43
|
+
r"""^\d{4}-\d{2}-\d{2}
|
|
44
|
+
[Tt ]
|
|
45
|
+
\d{2}:\d{2}:\d{2} # seconds are mandatory
|
|
46
|
+
(?:\.\d+)?
|
|
47
|
+
(?:[Zz]|[+-]\d{2}:\d{2})$ # offset is hours:minutes only, or Z
|
|
48
|
+
""",
|
|
49
|
+
re.VERBOSE,
|
|
50
|
+
)
|
|
51
|
+
_RFC3339_SHAPE = "YYYY-MM-DDThh:mm:ss[.fff](Z|+hh:mm)"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _parse_rfc3339(text: str) -> datetime:
|
|
55
|
+
if not isinstance(text, str):
|
|
56
|
+
raise ClockError(f"virtual_time must be an RFC3339 string, got {type(text).__name__}")
|
|
57
|
+
# Don't strip: a whitespace-wrapped value would slip through the anchored grammar (a 200 the contract calls a 400).
|
|
58
|
+
raw = text
|
|
59
|
+
if not _RFC3339_RE.match(raw):
|
|
60
|
+
raise ClockError(f"invalid RFC3339 virtual_time {text!r}: expected {_RFC3339_SHAPE}")
|
|
61
|
+
if raw.endswith(("Z", "z")):
|
|
62
|
+
raw = raw[:-1] + "+00:00"
|
|
63
|
+
try:
|
|
64
|
+
dt = datetime.fromisoformat(raw)
|
|
65
|
+
except ValueError as e:
|
|
66
|
+
raise ClockError(f"invalid RFC3339 virtual_time {text!r}: {e}")
|
|
67
|
+
try:
|
|
68
|
+
return dt.astimezone(timezone.utc)
|
|
69
|
+
except OverflowError:
|
|
70
|
+
raise ClockError(f"invalid RFC3339 virtual_time {text!r}: out of representable range")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _iso(dt: datetime) -> str:
|
|
74
|
+
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Calendar Y/M are absent on purpose: a month is not a fixed span. Empty ("P"/"PT") is caught by the
|
|
78
|
+
# any-group check in _parse_duration, not here.
|
|
79
|
+
_ISO8601_DURATION_RE = re.compile(
|
|
80
|
+
r"""^P (?:(\d+)W)? (?:(\d+)D)? # weeks / days
|
|
81
|
+
(?: T (?:(\d+)H)? (?:(\d+)M)? # hours / minutes ('M' is months before T, minutes after)
|
|
82
|
+
(?:(\d+(?:\.\d+)?)S)? )? $ # seconds, optionally fractional
|
|
83
|
+
""",
|
|
84
|
+
re.VERBOSE,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_duration(text) -> timedelta:
|
|
89
|
+
if not isinstance(text, str):
|
|
90
|
+
raise ClockError(f"duration must be an ISO-8601 string, got {type(text).__name__}")
|
|
91
|
+
m = _ISO8601_DURATION_RE.match(text)
|
|
92
|
+
if m is None or not any(m.groups()):
|
|
93
|
+
raise ClockError(f"invalid ISO-8601 duration {text!r}: expected e.g. 'PT24H', 'P1DT6H', 'PT30M'")
|
|
94
|
+
weeks, days, hours, minutes, seconds = m.groups()
|
|
95
|
+
try:
|
|
96
|
+
return timedelta(weeks=int(weeks or 0), days=int(days or 0), hours=int(hours or 0),
|
|
97
|
+
minutes=int(minutes or 0), seconds=float(seconds or 0))
|
|
98
|
+
except (OverflowError, ValueError):
|
|
99
|
+
raise ClockError(f"invalid ISO-8601 duration {text!r}: out of representable range")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _advance(t0: datetime, seconds: float) -> datetime:
|
|
103
|
+
"""t0 + seconds, saturating at datetime.min/max instead of raising.
|
|
104
|
+
|
|
105
|
+
set_time's horizon check can't bound a gateway that outlives _OVERFLOW_HORIZON_S, so reads
|
|
106
|
+
saturate rather than 500 — an armed clock always returns a (monotonic) time.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
return t0 + timedelta(seconds=seconds)
|
|
110
|
+
except (OverflowError, ValueError, OSError):
|
|
111
|
+
return _MAX_T0 if seconds > 0 else datetime.min.replace(tzinfo=timezone.utc)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Clock:
|
|
115
|
+
"""Gateway-owned virtual clock — wall-clock-driven, monotonic, threadsafe; `now_fn` injectable for tests."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, now_fn=time.monotonic):
|
|
118
|
+
self._now = now_fn
|
|
119
|
+
self._lock = threading.Lock()
|
|
120
|
+
self._armed = False
|
|
121
|
+
self._t0: datetime | None = None
|
|
122
|
+
self._anchor: float | None = None
|
|
123
|
+
self._rate = 1.0
|
|
124
|
+
self._generation = 0
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def armed(self) -> bool:
|
|
128
|
+
return self._armed
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def generation(self) -> int:
|
|
132
|
+
"""Bumped on every arm/clear — lets consumers detect a re-anchor and drop stale derived state."""
|
|
133
|
+
return self._generation
|
|
134
|
+
|
|
135
|
+
def t0(self) -> datetime | None:
|
|
136
|
+
"""The armed anchor virtual time (None when unarmed) — for time-trigger mark resolution."""
|
|
137
|
+
with self._lock:
|
|
138
|
+
return self._t0
|
|
139
|
+
|
|
140
|
+
def set_time(self, virtual_time: str, rate=1.0) -> dict:
|
|
141
|
+
"""Arm/re-arm: anchor t0=virtual_time to now, advancing at `rate` virtual s per real s."""
|
|
142
|
+
t0 = _parse_rfc3339(virtual_time)
|
|
143
|
+
r = _parse_rate(1.0 if rate is None else rate)
|
|
144
|
+
if t0 > _MAX_T0 - timedelta(seconds=_OVERFLOW_HORIZON_S * r):
|
|
145
|
+
raise ClockError(f"virtual_time {virtual_time!r} at rate {r:g} would overflow datetime near the max")
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._t0 = t0
|
|
148
|
+
self._anchor = self._now()
|
|
149
|
+
self._rate = r
|
|
150
|
+
self._armed = True
|
|
151
|
+
self._generation += 1
|
|
152
|
+
return self.state()
|
|
153
|
+
|
|
154
|
+
def clear(self) -> dict:
|
|
155
|
+
"""Disarm (idempotent). Afterwards reads 404."""
|
|
156
|
+
with self._lock:
|
|
157
|
+
self._armed = False
|
|
158
|
+
self._t0 = None
|
|
159
|
+
self._anchor = None
|
|
160
|
+
self._rate = 1.0
|
|
161
|
+
self._generation += 1
|
|
162
|
+
return {"ok": True, "armed": False}
|
|
163
|
+
|
|
164
|
+
def now(self) -> datetime | None:
|
|
165
|
+
with self._lock:
|
|
166
|
+
if not self._armed or self._t0 is None:
|
|
167
|
+
return None
|
|
168
|
+
elapsed = self._now() - self._anchor
|
|
169
|
+
return _advance(self._t0, elapsed * self._rate)
|
|
170
|
+
|
|
171
|
+
def read(self) -> dict:
|
|
172
|
+
"""The frozen consumer contract: {"virtual_time": "<RFC3339>"} — pure read, never advances."""
|
|
173
|
+
current = self.now()
|
|
174
|
+
if current is None:
|
|
175
|
+
raise ClockError("clock is not armed")
|
|
176
|
+
return {"virtual_time": _iso(current)}
|
|
177
|
+
|
|
178
|
+
def state(self) -> dict:
|
|
179
|
+
with self._lock:
|
|
180
|
+
if not self._armed or self._t0 is None:
|
|
181
|
+
return {"armed": False}
|
|
182
|
+
elapsed = self._now() - self._anchor
|
|
183
|
+
return {"armed": True, "t0": _iso(self._t0), "virtual_seconds_per_real_second": self._rate,
|
|
184
|
+
"virtual_time": _iso(_advance(self._t0, elapsed * self._rate))}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Gateway constants shared with the data-plane client call sites.
|
|
2
|
+
|
|
3
|
+
Lives inside the gateway package so gateway.py can reach it with a relative
|
|
4
|
+
import — the gateway runs as a standalone container with no `agent_env` package.
|
|
5
|
+
Client call sites (MCPServerEnv/WebsiteEnv) import it by absolute path. One
|
|
6
|
+
source of truth keeps the layers ordered: client add_data >= gateway REST
|
|
7
|
+
proxy >= actual load time.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
import secrets
|
|
14
|
+
|
|
15
|
+
# Max end-to-end time for a single service's data load (seconds).
|
|
16
|
+
#
|
|
17
|
+
# The previous value of 600 was sized above a then-slowest prod load of ~430s.
|
|
18
|
+
# That is no longer the ceiling: a large universe's gmail component now takes 498s and slack
|
|
19
|
+
# 499s, leaving under 20% headroom on a number chosen to have plenty.
|
|
20
|
+
#
|
|
21
|
+
# The margin matters more than the average because of how a load fails. A
|
|
22
|
+
# component that exceeds this does not retry — the environment comes up, reports
|
|
23
|
+
# healthy, and serves zero rows, which is indistinguishable from a component that
|
|
24
|
+
# is legitimately empty. Paying a few extra minutes on a genuine hang is much
|
|
25
|
+
# cheaper than that.
|
|
26
|
+
#
|
|
27
|
+
# The real fix is capacity, not patience: a universe loads all its components
|
|
28
|
+
# through one asyncio.gather onto a VM defaulting to a single vCPU, and the
|
|
29
|
+
# loaders are CPU-bound. This value should come back down once that is sized
|
|
30
|
+
# properly.
|
|
31
|
+
DATA_PLANE_LOAD_TIMEOUT_S = 900
|
|
32
|
+
|
|
33
|
+
# Extra seconds granted per GB of payload, on top of the floor.
|
|
34
|
+
#
|
|
35
|
+
# Explicitly a heuristic, not a model. Measured load cost tracks RECORD COUNT, not bytes:
|
|
36
|
+
# github moved 3.5GB in 89s while gmail took 206s for 409MB and slack 207s for 86.6MB (~20x
|
|
37
|
+
# and ~94x worse per byte). So size is a weak proxy -- it over-grants to the big-blob
|
|
38
|
+
# services that never needed it and under-grants to the small record-heavy ones that do.
|
|
39
|
+
# It is still strictly better than a flat cap, because the flat cap's failure mode was
|
|
40
|
+
# killing healthy work. A real fix is a stall timeout (fail on no progress rather than on
|
|
41
|
+
# elapsed time), which needs the server to report progress mid-ingest.
|
|
42
|
+
DATA_PLANE_LOAD_TIMEOUT_PER_GB_S = 1200
|
|
43
|
+
|
|
44
|
+
# Ceiling, so a genuinely hung load on a huge payload still surfaces in bounded time
|
|
45
|
+
# instead of holding a worker slot and a billable VM for hours.
|
|
46
|
+
DATA_PLANE_LOAD_TIMEOUT_MAX_S = 3600
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def data_plane_load_timeout_s(size_bytes: int | None) -> int:
|
|
50
|
+
"""Read timeout for one service's data load, scaled by payload size.
|
|
51
|
+
|
|
52
|
+
``None``/0 (size unknown) yields the floor, which is today's behaviour -- an
|
|
53
|
+
unmeasurable payload must not silently get an unbounded wait.
|
|
54
|
+
"""
|
|
55
|
+
if not size_bytes or size_bytes <= 0:
|
|
56
|
+
return DATA_PLANE_LOAD_TIMEOUT_S
|
|
57
|
+
gb = size_bytes / 1_000_000_000
|
|
58
|
+
return int(min(
|
|
59
|
+
DATA_PLANE_LOAD_TIMEOUT_MAX_S,
|
|
60
|
+
DATA_PLANE_LOAD_TIMEOUT_S + gb * DATA_PLANE_LOAD_TIMEOUT_PER_GB_S,
|
|
61
|
+
))
|
|
62
|
+
|
|
63
|
+
# Hand-rolled mirror of agentenv_protocol.types — the gateway container has no
|
|
64
|
+
# agentenv_protocol dependency. Keep byte-identical to that module.
|
|
65
|
+
WELL_KNOWN_PATH = "/.well-known/agent-env.json"
|
|
66
|
+
RPC_PATH = "/agentenv"
|
|
67
|
+
PROTOCOL_VERSION = "1.0"
|
|
68
|
+
METHOD_RESET = "data/reset"
|
|
69
|
+
METHOD_ADD = "data/add"
|
|
70
|
+
METHOD_GET = "data/get"
|
|
71
|
+
MCP_TRANSPORT = "mcp"
|
|
72
|
+
|
|
73
|
+
# Env/gateway-level extensions the gateway itself owns (tool gating + triggers).
|
|
74
|
+
EXT_DISABLE_TOOL_URI = "urn:agentenv:disable-tool/v1"
|
|
75
|
+
EXT_ENABLE_TOOL_URI = "urn:agentenv:enable-tool/v1"
|
|
76
|
+
EXT_TRIGGERS_URI = "urn:agentenv:triggers/v1"
|
|
77
|
+
EXT_CLOCK_URI = "urn:agentenv:clock/v1"
|
|
78
|
+
EXT_TRAJECTORY_URI = "urn:agentenv:trajectory/v1"
|
|
79
|
+
EXT_STEP_URI = "urn:agentenv:step/v1"
|
|
80
|
+
EXT_STATE_URI = "urn:agentenv:state/v1"
|
|
81
|
+
|
|
82
|
+
# Gateway's compose service name — how backing servers reach it to build env_get_time_url (compose path).
|
|
83
|
+
GATEWAY_COMPOSE_HOST = "gateway"
|
|
84
|
+
|
|
85
|
+
# Per-child card fetch budget (seconds) when composing the env card.
|
|
86
|
+
CARD_FETCH_TIMEOUT_S = 5.0
|
|
87
|
+
|
|
88
|
+
# Trigger status vocabulary, part of the GET /triggers/state contract; `state` asserts against it.
|
|
89
|
+
TRIGGER_STATUSES = ("armed", "firing", "queued", "fired", "failed")
|
|
90
|
+
|
|
91
|
+
# A fire is in flight; `queued` also has further provoking calls already waiting to be drained.
|
|
92
|
+
TRIGGER_IN_FLIGHT_STATUSES = ("firing", "queued")
|
|
93
|
+
|
|
94
|
+
GATEWAY_TRAJECTORY_FILE = os.environ.get(
|
|
95
|
+
"GATEWAY_TRAJECTORY_FILE",
|
|
96
|
+
f"/tmp/agentenv/{uuid4().hex[:8]}-trajectory.jsonl",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
AGENT_ENV_ROLE_HEADER = "AgentEnv-Role"
|
|
100
|
+
DEFAULT_ROLE = "default"
|
|
101
|
+
WILDCARD = "*"
|
|
102
|
+
TOOL_DISABLE_ACTION = "disable"
|
|
103
|
+
TOOL_ENABLE_ACTION = "enable"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class GatewayMode(str, Enum):
|
|
107
|
+
PERFORMANCE = "performance"
|
|
108
|
+
CONSISTENT = "consistent"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
DEFAULT_MCP_SERVER_NAME = "env"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def random_mcp_server_name() -> str:
|
|
115
|
+
"""`env` + 4 random digits: undeclared envs on one agent never collide; declare a MultiEnv name for a stable one."""
|
|
116
|
+
return f"env{secrets.randbelow(10000):04d}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# The gateway's own extensions, as its env card advertises them; here so kernel code can read them without importing gateway.py.
|
|
120
|
+
_TOOLS_REQUEST = {
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"role": {"type": "string"},
|
|
124
|
+
"tools": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"const": WILDCARD}]},
|
|
125
|
+
},
|
|
126
|
+
"required": ["role", "tools"],
|
|
127
|
+
}
|
|
128
|
+
# call_tool also needs a tool_name; if/then rather than oneOf keeps the fields top-level, where card viewers read them
|
|
129
|
+
_STEP_REQUEST = {
|
|
130
|
+
"type": "object",
|
|
131
|
+
"properties": {"action": {"enum": ["list_tools", "call_tool"]}, "tool_name": {"type": "string", "minLength": 1}, "arguments": {"type": "object"}},
|
|
132
|
+
"required": ["action"],
|
|
133
|
+
"if": {"properties": {"action": {"const": "call_tool"}}},
|
|
134
|
+
"then": {"required": ["tool_name"]},
|
|
135
|
+
}
|
|
136
|
+
GATEWAY_EXTENSIONS = [
|
|
137
|
+
{
|
|
138
|
+
"uri": EXT_DISABLE_TOOL_URI,
|
|
139
|
+
"description": "Disable tools for a role.",
|
|
140
|
+
"params": {"endpoint": f"/tools/{TOOL_DISABLE_ACTION}", "methods": {TOOL_DISABLE_ACTION: {"method": "POST", "request": _TOOLS_REQUEST}}},
|
|
141
|
+
"required": False,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"uri": EXT_ENABLE_TOOL_URI,
|
|
145
|
+
"description": "Enable tools for a role.",
|
|
146
|
+
"params": {"endpoint": f"/tools/{TOOL_ENABLE_ACTION}", "methods": {TOOL_ENABLE_ACTION: {"method": "POST", "request": _TOOLS_REQUEST}}},
|
|
147
|
+
"required": False,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"uri": EXT_TRIGGERS_URI,
|
|
151
|
+
"description": "Register (additive add-by-id), remove, clear, or read (state) typed Action/State triggers + the firing log (harness control path).",
|
|
152
|
+
"params": {"endpoint": "/triggers/register", "methods": {
|
|
153
|
+
"register": {"method": "POST", "endpoint": "/triggers/register", "request": {"type": "object", "properties": {"watch_roles": {"type": "array", "items": {"type": "string"}}, "executor": {"type": "object"}, "triggers": {"type": "array"}}, "required": ["triggers"]}},
|
|
154
|
+
"remove": {"method": "POST", "endpoint": "/triggers/remove", "request": {"type": "object", "properties": {"ids": {"type": "array", "items": {"type": "string"}}}, "required": ["ids"]}},
|
|
155
|
+
"clear": {"method": "POST", "endpoint": "/triggers/clear", "request": {"type": "object", "properties": {}}},
|
|
156
|
+
"state": {"method": "GET", "endpoint": "/triggers/state", "request": {"type": "object", "properties": {}}}}},
|
|
157
|
+
"required": False,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
"uri": EXT_CLOCK_URI,
|
|
161
|
+
"description": "Virtual clock (urn:agentenv:clock/v1): read the gateway's virtual time (wall-clock-derived, rate-scaled) via get_time; set-time/clear are the harness control path.",
|
|
162
|
+
"params": {"endpoint": "/clock/time", "methods": {
|
|
163
|
+
"get_time": {"method": "GET", "endpoint": "/clock/time"},
|
|
164
|
+
"set_time": {"method": "PUT", "endpoint": "/clock/set-time", "request": {"type": "object", "properties": {"virtual_time": {"type": "string"}, "virtual_seconds_per_real_second": {"type": "number"}}, "required": ["virtual_time"]}},
|
|
165
|
+
"clear": {"method": "POST", "endpoint": "/clock/clear", "request": {"type": "object", "properties": {}}},
|
|
166
|
+
"state": {"method": "GET", "endpoint": "/clock/state", "request": {"type": "object", "properties": {}}}}},
|
|
167
|
+
"required": False,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"uri": EXT_TRAJECTORY_URI,
|
|
171
|
+
"description": "Env trajectory (urn:agentenv:trajectory/v1): the env's append-only JSONL record of tool calls, results, and trigger firings, virtual_time-stamped while the clock is armed.",
|
|
172
|
+
"params": {"endpoint": "/trajectory", "methods": {
|
|
173
|
+
"get": {"method": "GET", "endpoint": "/trajectory"}}},
|
|
174
|
+
"required": False,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"uri": EXT_STEP_URI,
|
|
178
|
+
"description": "Step (urn:agentenv:step/v1): list or call the env's tools over REST, filtered by the AgentEnv-Role header.",
|
|
179
|
+
"params": {"endpoint": "/step", "methods": {
|
|
180
|
+
"step": {"method": "POST", "endpoint": "/step", "request": _STEP_REQUEST}}},
|
|
181
|
+
"required": False,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"uri": EXT_STATE_URI,
|
|
185
|
+
"description": "State (urn:agentenv:state/v1): the env's MCP servers and their tools, its changelog id and its role rules.",
|
|
186
|
+
"params": {"endpoint": "/state", "methods": {
|
|
187
|
+
"get": {"method": "GET", "endpoint": "/state"}}},
|
|
188
|
+
"required": False,
|
|
189
|
+
},
|
|
190
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Entry point for the Gateway container."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode, InternalMCPServer
|
|
10
|
+
from .constants import DEFAULT_MCP_SERVER_NAME
|
|
11
|
+
from .gateway import Gateway
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_internal_servers(spec: str, port: int) -> list[InternalMCPServer]:
|
|
17
|
+
servers: list[InternalMCPServer] = []
|
|
18
|
+
seen_names: set[str] = set()
|
|
19
|
+
if not spec.strip():
|
|
20
|
+
return servers
|
|
21
|
+
for entry in spec.split(","):
|
|
22
|
+
entry = entry.strip()
|
|
23
|
+
if not entry:
|
|
24
|
+
continue
|
|
25
|
+
if "=" in entry:
|
|
26
|
+
name, url = entry.split("=", 1)
|
|
27
|
+
name = name.strip()
|
|
28
|
+
url = url.strip()
|
|
29
|
+
if not name or not url:
|
|
30
|
+
raise ValueError(f"Invalid INTERNAL_MCP_SERVERS entry: '{entry}' (expected 'name=url' or 'name')")
|
|
31
|
+
else:
|
|
32
|
+
name = entry
|
|
33
|
+
url = f"http://{name}:{port}/mcp"
|
|
34
|
+
if name in seen_names:
|
|
35
|
+
raise ValueError(f"Duplicate internal server name: '{name}'")
|
|
36
|
+
seen_names.add(name)
|
|
37
|
+
servers.append(InternalMCPServer(name=name, mcp_url=url))
|
|
38
|
+
return servers
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# How long to wait for each internal MCP server to become ready before giving
|
|
42
|
+
# up. Generous + configurable so a slow-starting server (image pull, DB
|
|
43
|
+
# migration, warmup) doesn't trip a premature failure that crash-loops the
|
|
44
|
+
# gateway. In Modal/container mode this is the *only* readiness gate (there's no
|
|
45
|
+
# docker-compose `depends_on`), so the old 30s ceiling was far too short.
|
|
46
|
+
_INTERNAL_SERVER_TIMEOUT_S = int(os.environ.get("GATEWAY_INTERNAL_SERVER_TIMEOUT_S", "180"))
|
|
47
|
+
_INTERNAL_SERVER_POLL_S = 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ready_url(mcp_url: str) -> str:
|
|
51
|
+
"""The server's /ready URL (root sibling of /mcp)."""
|
|
52
|
+
base = mcp_url.rstrip("/")
|
|
53
|
+
if base.endswith("/mcp"):
|
|
54
|
+
base = base[: -len("/mcp")]
|
|
55
|
+
return f"{base}/ready"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _wait_for_server(name: str, mcp_url: str, timeout_s: int = _INTERNAL_SERVER_TIMEOUT_S) -> bool:
|
|
59
|
+
"""Wait until an internal MCP server is ready.
|
|
60
|
+
|
|
61
|
+
Prefer the server's ``/ready`` endpoint (HTTP 200 = fully initialised). If
|
|
62
|
+
the server has no ``/ready`` (older image → 404), fall back to the ``/mcp``
|
|
63
|
+
endpoint merely responding (<500). Poll up to ``timeout_s`` so a slow server
|
|
64
|
+
doesn't make the gateway give up and crash-loop.
|
|
65
|
+
"""
|
|
66
|
+
ready_url = _ready_url(mcp_url)
|
|
67
|
+
deadline = time.monotonic() + timeout_s
|
|
68
|
+
last = "no response"
|
|
69
|
+
async with httpx.AsyncClient() as client:
|
|
70
|
+
while time.monotonic() < deadline:
|
|
71
|
+
try:
|
|
72
|
+
r = await client.get(ready_url, timeout=5)
|
|
73
|
+
if r.status_code == 200:
|
|
74
|
+
logger.info(f" {name} ready via /ready (200)")
|
|
75
|
+
return True
|
|
76
|
+
# No usable /ready: 404 = no such route; some MCP servers (e.g.
|
|
77
|
+
# @playwright/mcp, used by the website-browser companion) answer
|
|
78
|
+
# 400 to a plain GET /ready. In every non-200 case fall back to
|
|
79
|
+
# /mcp liveness and keep waiting.
|
|
80
|
+
m = await client.get(mcp_url, timeout=5)
|
|
81
|
+
if m.status_code < 500:
|
|
82
|
+
logger.info(f" {name} ready via /mcp fallback (/ready={r.status_code}, /mcp={m.status_code})")
|
|
83
|
+
return True
|
|
84
|
+
last = f"/ready={r.status_code} /mcp={m.status_code}"
|
|
85
|
+
except Exception as e:
|
|
86
|
+
last = type(e).__name__
|
|
87
|
+
logger.info(f" {name} not ready yet ({last}); retrying...")
|
|
88
|
+
await asyncio.sleep(_INTERNAL_SERVER_POLL_S)
|
|
89
|
+
logger.error(f"{name} not ready after {timeout_s}s (last: {last})")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def wait_for_internal_servers(servers: list[InternalMCPServer]) -> None:
|
|
94
|
+
"""Wait for all internal servers to be ready. Raises if any never come up."""
|
|
95
|
+
t0 = time.monotonic()
|
|
96
|
+
for server in servers:
|
|
97
|
+
logger.info(f"Waiting for {server.name} at {server.mcp_url} (timeout {_INTERNAL_SERVER_TIMEOUT_S}s)...")
|
|
98
|
+
results = await asyncio.gather(*[_wait_for_server(s.name, s.mcp_url) for s in servers])
|
|
99
|
+
for server, ready in zip(servers, results):
|
|
100
|
+
if not ready:
|
|
101
|
+
raise RuntimeError(f"Internal server '{server.name}' not ready at {server.mcp_url}")
|
|
102
|
+
logger.info(f"Internal MCP server {server.name} is ready")
|
|
103
|
+
logger.info(f"All {len(servers)} internal MCP server(s) ready in {time.monotonic() - t0:.1f}s")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_name_url_map(env_str: str) -> dict[str, str]:
|
|
107
|
+
# Expected format: 'slack=http://slack-website-backend:8000,email=http://email-website-backend:8000'
|
|
108
|
+
urls: dict[str, str] = {}
|
|
109
|
+
if not env_str.strip():
|
|
110
|
+
return urls
|
|
111
|
+
for entry in env_str.split(","):
|
|
112
|
+
entry = entry.strip()
|
|
113
|
+
if not entry:
|
|
114
|
+
continue
|
|
115
|
+
if "=" not in entry:
|
|
116
|
+
raise ValueError(f"Invalid REST_PROXY_URLS entry: '{entry}' (expected 'name=url')")
|
|
117
|
+
name, url = entry.split("=", 1)
|
|
118
|
+
name = name.strip()
|
|
119
|
+
url = url.strip()
|
|
120
|
+
if not name:
|
|
121
|
+
raise ValueError(f"Empty service name in REST_PROXY_URLS entry: '{entry}'")
|
|
122
|
+
if not url.startswith(("http://", "https://")):
|
|
123
|
+
raise ValueError(f"Invalid URL in REST_PROXY_URLS: '{url}' (must start with http:// or https://)")
|
|
124
|
+
if name in urls:
|
|
125
|
+
raise ValueError(f"Duplicate service name in REST_PROXY_URLS: '{name}'")
|
|
126
|
+
urls[name] = url
|
|
127
|
+
return urls
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
logging.basicConfig(
|
|
132
|
+
level=logging.INFO,
|
|
133
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
internal_mcp_servers = parse_internal_servers(
|
|
137
|
+
spec=os.environ.get("INTERNAL_MCP_SERVERS", ""),
|
|
138
|
+
port=int(os.environ.get("INTERNAL_MCP_PORT", str(AGENT_ENV_GATEWAY_MCP_PORT))),
|
|
139
|
+
)
|
|
140
|
+
asyncio.run(wait_for_internal_servers(internal_mcp_servers))
|
|
141
|
+
|
|
142
|
+
website_urls = parse_name_url_map(os.environ.get("WEBSITE_URLS", ""))
|
|
143
|
+
rest_proxy_urls = parse_name_url_map(os.environ.get("REST_PROXY_URLS", ""))
|
|
144
|
+
gateway_mode = GatewayMode(os.environ.get("GATEWAY_MODE", GatewayMode.PERFORMANCE.value))
|
|
145
|
+
service_db_url = os.environ.get("SERVICE_DB_URL")
|
|
146
|
+
gateway = Gateway(
|
|
147
|
+
host=os.environ.get("MCP_HOST", "0.0.0.0"),
|
|
148
|
+
port=int(os.environ.get("MCP_PORT", str(AGENT_ENV_GATEWAY_MCP_PORT))),
|
|
149
|
+
server_name=os.environ.get("MCP_SERVER_NAME") or DEFAULT_MCP_SERVER_NAME,
|
|
150
|
+
internal_mcp_servers=internal_mcp_servers,
|
|
151
|
+
website_urls=website_urls,
|
|
152
|
+
rest_proxy_urls=rest_proxy_urls,
|
|
153
|
+
gateway_mode=gateway_mode,
|
|
154
|
+
service_db_url=service_db_url,
|
|
155
|
+
)
|
|
156
|
+
gateway.run()
|