Skip to content
Merged
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
16 changes: 15 additions & 1 deletion packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ protocol. `TaskRequest` and `TaskResult` are framework boundary types; the
executor maps them to and from the wire-level `a2a.types.Task` lifecycle.

`TaskRequest` is a frozen record whose nested JSON values are detached copies.
Its `config`, `mcp_servers`, `skills`, `metadata`, and inbound `DataPart.data`
Its `config`, `mcp_servers`, `cdp_endpoints`, `skills`, `metadata`, and inbound `DataPart.data`
retain their declared `dict`/`list` types, so normal Pydantic serialization,
copying, and `json.dumps(...)` work. `TaskRequest.trajectory` is the exception:
it is the task's live trajectory log, described below. Within `tasks.v1`, new
Expand Down Expand Up @@ -1172,6 +1172,20 @@ tools with (agent-env relays the env card's name: the MultiEnv's declared name,
`env` + 4 random digits, giving e.g. `mcp__env4821__<tool>`); when absent the agent mints
`mcp_<8 hex>`.

`CDP_CONFIG_V1` gives an agent the CDP endpoints it drives itself. `add` takes
`name`, `http_url` (`http` or `https`, for clients that discover through
`/json/version`) and `ws_url` (`ws` or `wss`, for clients that connect
directly), with optional `headers` and `metadata`; adding a name again replaces
it. `GET /ext/cdp-config` returns `{"cdp_endpoints": {name: {"has_headers",
"metadata"}}}`: neither the URLs nor the header values, since either can carry
the endpoint's credential. `DELETE /ext/cdp-config` with `{"name"}` removes one;
removing a name that isn't there is not an error. Every task's
`TaskRequest.cdp_endpoints` holds them in full. `TaskRequest.cdp_endpoint(name=None)`
returns the named endpoint, or the only one, and otherwise fails the task as an
`infra_error` with `cdp.no_endpoint` (none, or none by that name) or
`cdp.ambiguous` (several and no name). It fails the task by raising, so call it
before any handler of your own that catches every exception.

`PEER_AGENTS_V1` lets an agent delegate to the other agents in its task. AgentEnv
sets the routing table with `POST /ext/peer-agents` (`{"peers": [{"name", "url",
"card", "description"}]}`; an empty list clears it, and names must be unique), and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .extensions import (
AGENT_CONFIG_V1,
ATTRIBUTION_PROBE_V1,
CDP_CONFIG_V1,
PROXY_V1,
INSTALL_V1,
MCP_CONFIG_V1,
Expand All @@ -16,6 +17,8 @@
TRIGGERS_V1,
AttributionProbeResponse,
BundleSkillRequest,
CdpAddRequest,
CdpRemoveRequest,
ChangelogIncrement,
ContextEventsTrajectoryRequest,
ContextObjectTrajectoryRequest,
Expand Down Expand Up @@ -137,6 +140,7 @@
__all__ = [
"AGENT_CONFIG_V1",
"ATTRIBUTION_PROBE_V1",
"CDP_CONFIG_V1",
"PROXY_V1",
"INSTALL_V1",
"MCP_CONFIG_V1",
Expand All @@ -157,6 +161,8 @@
"AttributionProbeResponse",
"BuiltinTools",
"BundleSkillRequest",
"CdpAddRequest",
"CdpRemoveRequest",
"ChangelogIncrement",
"ContextEventsTrajectoryRequest",
"ContextObjectTrajectoryRequest",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from enum import Enum
from types import MappingProxyType
from typing import Annotated, Any, Literal
from urllib.parse import urlsplit

from pydantic import (
BaseModel,
Expand All @@ -19,6 +20,7 @@
PositiveInt,
StrictInt,
ValidationError,
field_validator,
model_validator,
)

Expand Down Expand Up @@ -50,7 +52,9 @@ class ExtensionResponse(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)


_SkillName = Annotated[str, Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")]
_NAME_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
_SkillName = Annotated[str, Field(pattern=_NAME_PATTERN)]
_CdpEndpointName = Annotated[str, Field(pattern=_NAME_PATTERN)]


class McpAddRequest(ExtensionRequest):
Expand All @@ -59,6 +63,43 @@ class McpAddRequest(ExtensionRequest):
name: str | None = None


def _absolute_url(url: str, schemes: tuple[str, ...]) -> str:
"""``url``, once it names one of ``schemes``, a host and, if it has one, a valid port."""
parts = urlsplit(url)
try:
valid = parts.scheme in schemes and bool(parts.hostname) and (parts.port is None or parts.port >= 0)
except ValueError:
valid = False
if not valid:
raise ValueError(f"must be an absolute {' or '.join(schemes)} URL with a host and a valid port")
return url
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved


class CdpAddRequest(ExtensionRequest):
"""A CDP endpoint: ``http_url`` for clients that discover through ``/json/version``, ``ws_url`` for clients
that connect to the browser directly."""

name: _CdpEndpointName
http_url: str
ws_url: str
headers: dict[str, str] | None = None
metadata: dict[str, Any] | None = None

@field_validator("http_url")
@classmethod
def _http_url(cls, url: str) -> str:
return _absolute_url(url, ("http", "https"))

@field_validator("ws_url")
@classmethod
def _ws_url(cls, url: str) -> str:
return _absolute_url(url, ("ws", "wss"))


class CdpRemoveRequest(ExtensionRequest):
name: _CdpEndpointName


class InlineSkillRequest(ExtensionRequest):
name: _SkillName
description: str
Expand Down Expand Up @@ -1101,6 +1142,31 @@ def _configure_trajectory(*, live: bool = False) -> ExtensionConfiguration:
},
)

CDP_CONFIG_V1 = ExtensionDefinition(
uri="urn:agentenv:cdp-config/v1",
description="Register CDP endpoints the agent drives itself.",
endpoint="/ext/cdp-config",
core_operations={
"add": _op(
"add",
"POST",
"/ext/cdp-config",
SDK,
request_model=CdpAddRequest,
),
"list": _op(
"list", "GET", "/ext/cdp-config", SDK, response_required=("cdp_endpoints",)
),
"remove": _op(
"remove",
"DELETE",
"/ext/cdp-config",
SDK,
request_model=CdpRemoveRequest,
),
},
)

SKILL_CONFIG_V1 = ExtensionDefinition(
uri="urn:agentenv:skill-config/v1",
description="Register skills made available to the agent.",
Expand Down Expand Up @@ -1319,6 +1385,7 @@ def _configure_trajectory(*, live: bool = False) -> ExtensionConfiguration:
for extension in (
AGENT_CONFIG_V1,
MCP_CONFIG_V1,
CDP_CONFIG_V1,
SKILL_CONFIG_V1,
TRAJECTORY_V1,
SNAPSHOT_V1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from ._triggers import TriggerEngine, TriggerError
from .extensions import (
AGENT_CONFIG_V1,
CDP_CONFIG_V1,
MAX_TRAJECTORY_READ_EVENTS,
MCP_CONFIG_V1,
PEER_AGENTS_V1,
Expand All @@ -41,6 +42,8 @@
ContextEventsTrajectoryRequest,
ContextObjectTrajectoryRequest,
ContextTrajectoryRequest,
CdpAddRequest,
CdpRemoveRequest,
ExtensionActivation,
ExtensionDefinition,
ImplementationOwner,
Expand All @@ -66,6 +69,7 @@
TaskProgress,
TaskRequest,
TaskResult,
_TaskFailed,
)
from .tasks.v1 import (
DataPart as TaskDataPart,
Expand Down Expand Up @@ -541,6 +545,7 @@ def __init__(
self.config_model
)
self.mcp_servers: dict[str, dict[str, Any]] = {}
self.cdp_endpoints: dict[str, dict[str, Any]] = {}
self.task_trajectories = TaskTrajectories()
self._context_sessions = _BoundedContextSessions()
self.skills: list[Mapping[str, Any]] = []
Expand Down Expand Up @@ -616,6 +621,9 @@ def handlers(self) -> dict[tuple[str, str], Callable]:
(AGENT_CONFIG_V1.uri, "get"): self.agent_config_get,
(MCP_CONFIG_V1.uri, "add"): self.mcp_add,
(MCP_CONFIG_V1.uri, "list"): self.mcp_list,
(CDP_CONFIG_V1.uri, "add"): self.cdp_add,
(CDP_CONFIG_V1.uri, "list"): self.cdp_list,
(CDP_CONFIG_V1.uri, "remove"): self.cdp_remove,
(PEER_AGENTS_V1.uri, "set"): self.peer_agents_set,
(PEER_AGENTS_V1.uri, "list"): self.peer_agents_list,
(SKILL_CONFIG_V1.uri, "list"): self.skill_list,
Expand Down Expand Up @@ -721,6 +729,32 @@ async def mcp_list(self) -> dict[str, Any]:
}
}

async def cdp_add(self, request: CdpAddRequest) -> dict[str, Any]:
"""Adding a name again replaces its endpoint."""
self.cdp_endpoints[request.name] = {
"http_url": request.http_url,
"ws_url": request.ws_url,
"headers": dict(request.headers) if request.headers else None,
"metadata": dict(request.metadata or {}),
}
return {"status": "added", "name": request.name}

async def cdp_list(self) -> dict[str, Any]:
"""Names and metadata only: an endpoint's URLs can carry its credential, as its headers can."""
return {
"cdp_endpoints": {
name: {
"has_headers": bool(endpoint["headers"]),
"metadata": endpoint["metadata"],
}
for name, endpoint in self.cdp_endpoints.items()
}
}

async def cdp_remove(self, request: CdpRemoveRequest) -> dict[str, Any]:
removed = self.cdp_endpoints.pop(request.name, None) is not None
return {"status": "removed" if removed else "absent", "name": request.name}

def task_mcp_servers(self) -> dict[str, dict[str, Any]]:
"""The MCP servers a task gets: those registered, and the SDK's peer-agents server when it serves one."""
peer_tools = self.peers.registration() if self.peers is not None else None
Expand Down Expand Up @@ -1238,16 +1272,22 @@ async def execute(self, context: Any, event_queue: Any) -> None:
),
config=task_config,
mcp_servers=self._services.task_mcp_servers(),
cdp_endpoints=self._services.cdp_endpoints,
skills=tuple(self._services.skills),
metadata=metadata,
session_ref=self._services.session_ref_for_context(task.context_id),
workspace=self._workspace,
trajectory=trajectory,
)
if self._streaming:
result = await self._run_streaming(request, updater, event_queue)
else:
result = await self._handler(request)
try:
if self._streaming:
result = await self._run_streaming(request, updater, event_queue)
else:
result = await self._handler(request)
except _TaskFailed as failed:
result = TaskResult.failure(
failed.error.code, failed.error.message, error_type=failed.error.error_type
)
if not isinstance(result, TaskResult):
raise TypeError("agent.run must return TaskResult")
if result.session_ref is not None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ class TaskRequest(Generic[ConfigT]):
parts: tuple[TaskPart, ...]
config: ConfigT
mcp_servers: Mapping[str, Any] = field(default_factory=dict)
cdp_endpoints: Mapping[str, Any] = field(default_factory=dict)
skills: tuple[Mapping[str, Any], ...] = ()
metadata: Mapping[str, Any] = field(default_factory=dict)
session_ref: str | None = None
Expand All @@ -233,11 +234,31 @@ def __post_init__(self) -> None:
# JSON-native types declared by the config model and request contract.
object.__setattr__(self, "config", self.config.model_copy(deep=True))
object.__setattr__(self, "mcp_servers", _copy_json_value(self.mcp_servers))
object.__setattr__(self, "cdp_endpoints", _copy_json_value(self.cdp_endpoints))
object.__setattr__(
self, "skills", tuple(_copy_json_value(skill) for skill in self.skills)
)
object.__setattr__(self, "metadata", _copy_json_value(self.metadata))

def cdp_endpoint(self, name: str | None = None) -> Mapping[str, Any]:
"""The CDP endpoint named ``name``, or the turn's only one. Anything else fails the turn, so an agent
never falls back to a browser of its own."""
if name is not None:
if name in self.cdp_endpoints:
return self.cdp_endpoints[name]
raise _TaskFailed(
"cdp.no_endpoint", f"the task gave no browser named {name!r}", error_type="infra_error"
)
if len(self.cdp_endpoints) == 1:
return next(iter(self.cdp_endpoints.values()))
if not self.cdp_endpoints:
raise _TaskFailed("cdp.no_endpoint", "the task gave no browser", error_type="infra_error")
raise _TaskFailed(
"cdp.ambiguous",
f"the task gave several browsers; name one of {', '.join(sorted(self.cdp_endpoints))}",
error_type="infra_error",
)


class TaskOutcome(str, Enum):
SUCCEEDED = "succeeded"
Expand All @@ -255,6 +276,20 @@ def __post_init__(self) -> None:
raise ValueError("error_type must be 'agent_error' or 'infra_error'")


class _TaskFailed(Exception):
"""Ends the turn as ``TaskResult.failure(code, message, error_type=error_type)``; ``cdp_endpoint`` raises it."""

def __init__(
self,
code: str,
message: str,
*,
error_type: Literal["agent_error", "infra_error"] = "agent_error",
) -> None:
super().__init__(message)
self.error = TaskError(code=code, message=message, error_type=error_type)


@dataclass(frozen=True, slots=True, kw_only=True)
class NativeTrajectory:
format: str
Expand Down
Loading
Loading