Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions src/agent_env/env/legacy_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

import asyncio
import logging
from typing import TYPE_CHECKING, Optional
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Optional

import httpx
from agentenv_protocol import RPC_PATH, DataPart, client as protocol_v1
Expand All @@ -33,6 +34,41 @@ def environment_base_url(gateway_url: Optional[str], environment_name: str, mcp:
return f"{gateway_url}/svc/{prefix}{environment_name}"


@dataclass(frozen=True)
class BackingServer:
"""A server behind an env's gateway, by its environment_name: an MCP server, at ``/svc/mcp-{name}``, or a website,
at ``/svc/{name}``."""

environment_name: str
mcp: bool = True


def backing_servers(env: Any) -> list[BackingServer]:
"""The servers behind env's gateway: a MultiEnv's MCP servers then its websites, a WebsiteEnv's website, or else
the env's one MCP server; empty for an env with none."""
from agent_env.env.envs.website import WebsiteEnv

if isinstance(env, WebsiteEnv):
return [BackingServer(env.environment_name, mcp=False)]
servers = [BackingServer(e.environment_name) for e in getattr(env, "mcp_server_envs", None) or []]
servers += [BackingServer(e.environment_name, mcp=False) for e in getattr(env, "website_envs", None) or []]
if not servers and getattr(env, "environment_name", None):
servers = [BackingServer(env.environment_name)]
return servers


def shared_names(servers: list[BackingServer]) -> set[str]:
"""The environment_names an MCP server and a website in ``servers`` share, which a stored env card can't tell apart."""
return {s.environment_name for s in servers if s.mcp} & {s.environment_name for s in servers if not s.mcp}


def mcp_wins(servers: list[BackingServer]) -> list[BackingServer]:
"""``servers`` without each website that shares its environment_name with an MCP server, as a MultiEnv routes an
artifact of that name to the MCP server."""
shared = shared_names(servers)
return [s for s in servers if s.mcp or s.environment_name not in shared]


async def v1_base_url(deployed: Optional[DeployedEnv], gateway_url: Optional[str], environment_name: str, mcp: bool = True) -> Optional[str]:
"""A child env's v1 data-plane base from the stored env card, or None for legacy; without a stored card, the live probe decides."""
if deployed is None or not deployed.environment_card:
Expand All @@ -43,10 +79,12 @@ async def v1_base_url(deployed: Optional[DeployedEnv], gateway_url: Optional[str



async def child_env_card(deployed: Optional[DeployedEnv], gateway_url: Optional[str], environment_name: str, timeout: int = 10) -> tuple[str, Optional[dict]]:
"""An MCP child env's card and the base its endpoints resolve against: from the stored env card, or without one a live read (None on 404)."""
async def child_env_card(deployed: Optional[DeployedEnv], gateway_url: Optional[str], environment_name: str, timeout: int = 10,
mcp: bool = True) -> tuple[str, Optional[dict]]:
"""A child env's card and the base its endpoints resolve against: from the stored env card, or without one a live read
at the MCP server's gateway path, or with ``mcp=False`` the website's (None on 404)."""
if deployed is None or not deployed.environment_card:
base_url = environment_base_url((gateway_url or "").rstrip("/"), environment_name)
base_url = environment_base_url((gateway_url or "").rstrip("/"), environment_name, mcp=mcp)
try:
return base_url, await protocol_v1.get_card(base_url, timeout=timeout)
except httpx.HTTPStatusError as e:
Expand Down
75 changes: 42 additions & 33 deletions src/agent_env/task_step/task_steps/apply_server_config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
"""Task step that applies a task's behavior config to deployed servers.

At setup (after the env is deployed + universe loaded, before the agent runs)
this step invokes each backing MCP server's ``set_*`` config extensions over the
gateway. Extensions are advertised on the server's ``EnvironmentCard`` (served at
this step invokes each backing MCP server's or website's ``set_*`` config extensions
over the gateway. Extensions are advertised on the server's ``EnvironmentCard`` (served at
``/.well-known/agent-env.json``) under ``capabilities.extensions[]`` and invoked
at their advertised REST endpoint (e.g. ``/agentenv/ext/set_errors``).

Config lives on the backing server, not on the gateway itself. Each server's card
comes from the env card stored on the deployed record, whose extension endpoints the
gateway has already rewritten to its ``/svc/mcp-{service}`` proxy; a record without a
stored card reads it live there. The agent never sees the card or these endpoints;
gateway has already rewritten to its ``/svc/mcp-{service}`` (or a website's ``/svc/{service}``)
proxy; a record without a stored card reads it live there. The agent never sees the card or these endpoints;
only this harness step invokes them.
"""

Expand Down Expand Up @@ -76,7 +76,7 @@ class ApplyServerConfigStep(TaskStep):
entity_refs = (EntityRef.env("env_id"),)

# A directive whose ``service`` is this sentinel is a broadcast: at execution
# it expands to one directive per MCP server in the env (same uri + args).
# it expands to one directive per MCP server and website in the env (same uri + args).
# Lets a caller apply an env-wide config (e.g. set_acting_user across every
# server) without enumerating services itself. Pairs naturally with
# ``tolerate_unadvertised`` so servers that don't opt in are simply skipped.
Expand Down Expand Up @@ -131,44 +131,51 @@ def from_dict(cls, data: dict) -> "ApplyServerConfigStep":
tolerate_unadvertised=data.get("tolerate_unadvertised", False),
)

def _expand_directives(self, deployed) -> list[ConfigDirective]:
def _expand_directives(self, deployed) -> tuple[list[ConfigDirective], set[str], set[int]]:
"""Expand any broadcast directive (service == ``*``) into one directive
per MCP server in the env, resolved from the env definition. The env's
child service names aren't otherwise enumerable here (the deployed card
list carries no roster), so we read them from ``Env.get`` — matching how
deploy_env / load_artifact resolve the env. Non-broadcast directives
pass through untouched."""
per MCP server and website in the env, resolved from the env definition,
and return them with the names that are websites, whose cards a live read
finds at ``/svc/{name}``, and the positions of the directives a broadcast
made for a website. Those are skipped, as under ``tolerate_unadvertised``,
when the website has no card or doesn't advertise the URI, so a website
the task didn't name never fails it; a directive naming a website, and
every MCP server, stays strict. The env's child service names aren't otherwise
enumerable here (the deployed card list carries no roster), so we read
them from ``Env.get`` — matching how deploy_env / load_artifact resolve
the env. A website sharing its name with an MCP server is left to the MCP
server, as the stored env card can't tell the two apart. Non-broadcast
directives pass through untouched."""
if not any(d.service == self._BROADCAST for d in self.directives):
return self.directives
return self.directives, set(), set()

from agent_env.env import legacy_protocol
from agent_env.env.env import Env

env = Env.get(deployed.env_id, deployed.env_version)
environment_names = [
e.environment_name for e in (getattr(env, "mcp_server_envs", None) or [])
]
# A single-server env (a bare MCPServerEnv) exposes `environment_name` and no
# `mcp_server_envs`; mirror snapshot_env / exporter enumeration and fall
# back to it, else a broadcast silently matches nothing on single-server envs.
if not environment_names and getattr(env, "environment_name", None):
environment_names = [env.environment_name]
# `mcp_server_envs`; backing_servers falls back to it, as snapshot_env does,
# else a broadcast silently matches nothing on single-server envs.
servers = legacy_protocol.mcp_wins(legacy_protocol.backing_servers(env))
# A broadcast that resolves to zero services would apply nothing while the
# step reports success: the exact silent misconfig this step prevents. Fail
# loud (tolerate_unadvertised governs advertisement, not missing servers).
if not environment_names:
if not servers:
raise ApplyServerConfigError(
f"apply_server_config: broadcast directive matched no MCP servers "
f"in env {self.env_id!r} (env exposes neither mcp_server_envs nor a "
f"apply_server_config: broadcast directive matched no MCP servers or websites "
f"in env {self.env_id!r} (env exposes neither mcp_server_envs, website_envs nor a "
f"environment_name); nothing to apply, likely a task authoring error"
)
expanded: list[ConfigDirective] = []
lenient: set[int] = set()
for d in self.directives:
if d.service != self._BROADCAST:
expanded.append(d)
continue
for svc in environment_names:
expanded.append(ConfigDirective(service=svc, uri=d.uri, args=d.args))
return expanded
for server in servers:
if not server.mcp:
lenient.add(len(expanded))
expanded.append(ConfigDirective(service=server.environment_name, uri=d.uri, args=d.args))
return expanded, {server.environment_name for server in servers if not server.mcp}, lenient

async def execute(self, context: TaskStepContext) -> TaskStepContext:
deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None)
Expand All @@ -180,15 +187,15 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
# actually armed with — a partial apply must not look like a no-op.
changes = context.metadata.setdefault("server_config_changes", [])
# Directives skipped because the target server doesn't advertise the
# extension (only when tolerate_unadvertised). Kept separate from
# extension (when tolerate_unadvertised, or for a website a broadcast reached). Kept separate from
# `changes` so the audit trail never shows a skip as an applied change.
skipped = context.metadata.setdefault("server_config_skipped", [])
# Expand broadcast (service="*") directives to one-per-env-service.
# Env.get inside _expand_directives can raise (missing env / version /
# network), so wrap it to keep the broadcast path's failure contract the
# same ApplyServerConfigError as the per-directive HTTP calls below.
try:
directives = self._expand_directives(deployed)
directives, websites, lenient = self._expand_directives(deployed)
except ApplyServerConfigError:
raise
except Exception as e:
Expand All @@ -204,23 +211,25 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
# card: a server predating env-card support, one whose card name differs, or one
# whose card the deploy couldn't read.
cards: dict[str, tuple[str, Optional[dict]]] = {}
for directive in directives:
for position, directive in enumerate(directives):
tolerate = self.tolerate_unadvertised or position in lenient
try:
if directive.service not in cards:
cards[directive.service] = await legacy_protocol.child_env_card(
deployed, gateway_url_of(deployed), directive.service, timeout=self.timeout_seconds
deployed, gateway_url_of(deployed), directive.service, timeout=self.timeout_seconds,
mcp=directive.service not in websites,
)
base_url, card = cards[directive.service]
if card is None:
if not self.tolerate_unadvertised:
if not tolerate:
raise ApplyServerConfigError(
f"Service {directive.service!r} has no env card (env={self.env_id}), so {directive.uri!r} "
f"can't be applied: it serves none, its card isn't named {directive.service!r}, or the deploy couldn't read it"
)
logger.warning(
f"apply_server_config: service {directive.service!r} serves no "
f"environment card (env={self.env_id}); skipping {directive.uri!r} "
f"(tolerate_unadvertised)"
f"({'tolerate_unadvertised' if self.tolerate_unadvertised else 'broadcast to a website'})"
)
skipped.append({
"step_id": self.id,
Expand All @@ -233,11 +242,11 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
continue
ext = protocol_v1.find_extension(card, directive.uri)
if ext is None:
if self.tolerate_unadvertised:
if tolerate:
logger.warning(
f"apply_server_config: {directive.uri!r} not advertised on "
f"service {directive.service!r} (env={self.env_id}); skipping "
f"(tolerate_unadvertised)"
f"({'tolerate_unadvertised' if self.tolerate_unadvertised else 'broadcast to a website'})"
)
skipped.append({
"step_id": self.id,
Expand Down
Loading
Loading