From cb6be938bfcf589a12796eba56ba80725e46c84f Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 2 Nov 2026 08:33:19 -0800 Subject: [PATCH 1/3] feat(agents): cdp-config/v1 hands an agent the CDP endpoints it drives An agent that drives a browser itself lists CDP_CONFIG_V1. The SDK serves add (adding a name again replaces it), list (header values hidden) and remove at /ext/cdp-config, and passes the endpoints to every turn as TaskRequest.cdp_endpoints. request.cdp_endpoint(name=None) returns the named or only endpoint and otherwise fails the turn as an infra_error (cdp.no_endpoint, cdp.ambiguous), so an agent never falls back to a browser of its own. TaskFailed is the exception behind it: raised in run(), it ends the turn as TaskResult.failure would. agent-env knows the extension as A2AAgent.EXT_CDP_CONFIG. Nothing adds endpoints yet; browser_connect_cdp does that next. FD-4948 Co-Authored-By: Claude Opus 5.5 --- packages/agentenv-protocol/README.md | 21 +- .../agentenv_protocol/a2a_agent/__init__.py | 8 + .../agentenv_protocol/a2a_agent/extensions.py | 64 +++++- .../agentenv_protocol/a2a_agent/framework.py | 49 +++- .../agentenv_protocol/a2a_agent/tasks/v1.py | 35 +++ .../tests/test_a2a_cdp_config.py | 217 ++++++++++++++++++ src/agent_env/a2a_agent/a2a_agent.py | 1 + src/agent_env/agents/AGENTS.md | 18 +- .../verify_a2a_agent_card.py | 2 +- tst/unit/a2a_agent/cdp_config_test.py | 40 ++++ 10 files changed, 442 insertions(+), 13 deletions(-) create mode 100644 packages/agentenv-protocol/tests/test_a2a_cdp_config.py create mode 100644 tst/unit/a2a_agent/cdp_config_test.py diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index a6ecae16b..6fb7f991b 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -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 @@ -866,8 +866,10 @@ and become failed A2A tasks with `error_type`, `error_code`, and `error_message` in the terminal message. `error_type` is the platform classification (`agent_error` by default, or explicitly `infra_error` for a retryable infrastructure failure); `error_code` preserves the author's machine-readable -code. An exception escaping `run()`, an invalid return value, or a -result-mapping failure is logged with a correlation ID and reported as an +code. `run()` may instead raise `TaskFailed(code, message, error_type=...)`, +which fails the task the same way. Any other exception escaping `run()`, an +invalid return value, or a result-mapping failure is logged with a +correlation ID and reported as an `infra_error` with code `framework.unhandled_exception`; raw exception text is never sent to callers. Invalid input that prevents task creation returns JSON-RPC `InvalidParams`. @@ -1172,6 +1174,19 @@ tools with (agent-env relays the env card's name: the MultiEnv's declared name, `env` + 4 random digits, giving e.g. `mcp__env4821__`); 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: {"http_url", +"ws_url", "has_headers", "metadata"}}}`, header values hidden, and +`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, +headers included. `TaskRequest.cdp_endpoint(name=None)` returns the named +endpoint, or the only one, and otherwise raises `TaskFailed`, failing the task +as an `infra_error` with `cdp.no_endpoint` (none, or none by that name) or +`cdp.ambiguous` (several and no name). + `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 diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py index 8ccde546d..775bade71 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py @@ -5,6 +5,7 @@ from .extensions import ( AGENT_CONFIG_V1, ATTRIBUTION_PROBE_V1, + CDP_CONFIG_V1, PROXY_V1, INSTALL_V1, MCP_CONFIG_V1, @@ -16,6 +17,8 @@ TRIGGERS_V1, AttributionProbeResponse, BundleSkillRequest, + CdpAddRequest, + CdpRemoveRequest, ChangelogIncrement, ContextEventsTrajectoryRequest, ContextObjectTrajectoryRequest, @@ -108,6 +111,7 @@ FilePart, NativeTrajectory, TaskError, + TaskFailed, TaskOutcome, TaskPart, TaskProgress, @@ -137,6 +141,7 @@ __all__ = [ "AGENT_CONFIG_V1", "ATTRIBUTION_PROBE_V1", + "CDP_CONFIG_V1", "PROXY_V1", "INSTALL_V1", "MCP_CONFIG_V1", @@ -157,6 +162,8 @@ "AttributionProbeResponse", "BuiltinTools", "BundleSkillRequest", + "CdpAddRequest", + "CdpRemoveRequest", "ChangelogIncrement", "ContextEventsTrajectoryRequest", "ContextObjectTrajectoryRequest", @@ -224,6 +231,7 @@ "StagingStore", "TaskError", "TaskEventsTrajectoryRequest", + "TaskFailed", "TaskObjectTrajectoryRequest", "TaskOutcome", "TaskPart", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py index 0ddb47183..7571596c6 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py @@ -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, @@ -19,6 +20,7 @@ PositiveInt, StrictInt, ValidationError, + field_validator, model_validator, ) @@ -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): @@ -59,6 +63,38 @@ class McpAddRequest(ExtensionRequest): name: str | None = None +def _absolute_url(url: str, schemes: tuple[str, ...]) -> str: + parts = urlsplit(url) + if parts.scheme not in schemes or not parts.netloc: + raise ValueError(f"must be an absolute {' or '.join(schemes)} URL") + return url + + +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 @@ -1101,6 +1137,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.", @@ -1319,6 +1380,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, diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py index 1cccdd2bd..a3ce38ae2 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py @@ -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, @@ -41,6 +42,8 @@ ContextEventsTrajectoryRequest, ContextObjectTrajectoryRequest, ContextTrajectoryRequest, + CdpAddRequest, + CdpRemoveRequest, ExtensionActivation, ExtensionDefinition, ImplementationOwner, @@ -62,6 +65,7 @@ from .tasks.v1 import ( AgentConfig, AgentRunResult, + TaskFailed, TaskOutcome, TaskProgress, TaskRequest, @@ -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]] = [] @@ -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, @@ -721,6 +729,33 @@ 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]: + return { + "cdp_endpoints": { + name: { + "http_url": endpoint["http_url"], + "ws_url": endpoint["ws_url"], + "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 @@ -1238,16 +1273,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: diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/tasks/v1.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/tasks/v1.py index a2eb5d3b4..514bfb7ba 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/tasks/v1.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/tasks/v1.py @@ -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 @@ -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" @@ -255,6 +276,20 @@ def __post_init__(self) -> None: raise ValueError("error_type must be 'agent_error' or 'infra_error'") +class TaskFailed(Exception): + """Raised in ``run()`` to end the turn as ``TaskResult.failure(code, message, error_type=error_type)``.""" + + 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 diff --git a/packages/agentenv-protocol/tests/test_a2a_cdp_config.py b/packages/agentenv-protocol/tests/test_a2a_cdp_config.py new file mode 100644 index 000000000..c97131363 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_a2a_cdp_config.py @@ -0,0 +1,217 @@ +"""cdp-config/v1 as the SDK serves it: the card entry, add, list and remove, the endpoints every turn gets, and +``request.cdp_endpoint()`` failing the turn closed.""" + +from __future__ import annotations + +import uuid +from typing import Any + +import pytest +from starlette.testclient import TestClient + +from agentenv_protocol.a2a_agent import ( + CDP_CONFIG_V1, + AgentEnvAgent, + AgentIdentity, + TaskFailed, + TaskProgress, + TaskRequest, + TaskResult, + TextPart, + a2a_agent, +) +from agentenv_protocol.a2a_agent.tasks.v1 import AgentConfig + +CHROMIUM = { + "name": "chromium", + "http_url": "http://browser.test:8000/cdp/token-1/", + "ws_url": "ws://browser.test:8000/cdp/token-1/", + "metadata": {"browser": "chromium", "context": "default"}, +} + + +@a2a_agent( + identity=AgentIdentity(name="cdp-agent", description="test", version="1"), + extensions=(CDP_CONFIG_V1,), +) +class CdpAgent(AgentEnvAgent): + """Answers with the endpoint its prompt names, or the only one when the prompt is ``only``.""" + + async def run(self, request: TaskRequest) -> TaskResult: + prompt = request.parts[0].text + endpoint = request.cdp_endpoint(None if prompt == "only" else prompt) + return TaskResult.text(f"{endpoint['http_url']} {endpoint['ws_url']} {sorted(endpoint['headers'] or {})}") + + +def _turn(client: TestClient, prompt: str) -> dict[str, Any]: + message = { + "kind": "message", + "messageId": uuid.uuid4().hex, + "role": "user", + "contextId": "context-1", + "parts": [{"kind": "text", "text": prompt}], + } + request = { + "jsonrpc": "2.0", + "id": uuid.uuid4().hex, + "method": "message/send", + "params": {"message": message, "configuration": {"blocking": True}}, + } + return client.post("/a2a", json=request).json()["result"] + + +def _reply(task: dict[str, Any]) -> str: + assert task["status"]["state"] == "completed", task["status"] + return task["status"]["message"]["parts"][0]["text"] + + +def _error(task: dict[str, Any]) -> dict[str, Any]: + assert task["status"]["state"] == "failed" + return task["status"]["message"]["parts"][-1]["data"] + + +def test_the_card_lists_add_list_and_remove() -> None: + with TestClient(CdpAgent().create_app()) as client: + card = client.get("/.well-known/agent-card.json").json() + + [extension] = [item for item in card["capabilities"]["extensions"] if item["uri"] == "urn:agentenv:cdp-config/v1"] + assert extension["params"]["endpoint"] == "/ext/cdp-config" + methods = extension["params"]["methods"] + assert {name: method["method"] for name, method in methods.items()} == { + "add": "POST", + "list": "GET", + "remove": "DELETE", + } + assert all("endpoint" not in method for method in methods.values()) + + +def test_adding_a_name_again_replaces_it_and_list_hides_header_values() -> None: + with TestClient(CdpAgent().create_app()) as client: + assert client.post("/ext/cdp-config", json=CHROMIUM).json() == {"status": "added", "name": "chromium"} + replaced = {**CHROMIUM, "http_url": "http://browser.test:8000/cdp/token-2/", "headers": {"X-Secret": "s3"}} + client.post("/ext/cdp-config", json=replaced) + listing = client.get("/ext/cdp-config") + + assert listing.json() == { + "cdp_endpoints": { + "chromium": { + "http_url": "http://browser.test:8000/cdp/token-2/", + "ws_url": CHROMIUM["ws_url"], + "has_headers": True, + "metadata": CHROMIUM["metadata"], + } + } + } + assert "s3" not in listing.text + + +def test_remove_takes_a_name_and_a_name_that_is_not_there_is_not_an_error() -> None: + with TestClient(CdpAgent().create_app()) as client: + client.post("/ext/cdp-config", json=CHROMIUM) + removed = client.request("DELETE", "/ext/cdp-config", json={"name": "chromium"}) + again = client.request("DELETE", "/ext/cdp-config", json={"name": "chromium"}) + listing = client.get("/ext/cdp-config").json() + + assert removed.json() == {"status": "removed", "name": "chromium"} + assert again.status_code == 200 + assert again.json() == {"status": "absent", "name": "chromium"} + assert listing == {"cdp_endpoints": {}} + + +@pytest.mark.parametrize( + "change", + [ + {"name": ""}, + {"name": "-chromium"}, + {"name": "chromium buyer"}, + {"http_url": "ws://browser.test/cdp/"}, + {"http_url": "/cdp/token-1/"}, + {"ws_url": "http://browser.test/cdp/"}, + {"ws_url": "ws:///cdp/"}, + {"extra": "field"}, + ], +) +def test_add_refuses_a_bad_name_url_or_field(change: dict[str, str]) -> None: + with TestClient(CdpAgent().create_app()) as client: + response = client.post("/ext/cdp-config", json={**CHROMIUM, **change}) + listing = client.get("/ext/cdp-config").json() + + assert response.status_code == 400 + assert listing == {"cdp_endpoints": {}} + + +def test_each_turn_gets_the_endpoints_added_before_it_with_their_headers() -> None: + with TestClient(CdpAgent().create_app()) as client: + before = _turn(client, "only") + client.post("/ext/cdp-config", json={**CHROMIUM, "headers": {"Authorization": "Bearer t"}}) + with_one = _turn(client, "only") + client.request("DELETE", "/ext/cdp-config", json={"name": "chromium"}) + after = _turn(client, "only") + + assert _error(before) == { + "error_type": "infra_error", + "error_code": "cdp.no_endpoint", + "error_message": "the task gave no browser", + } + assert _reply(with_one) == f"{CHROMIUM['http_url']} {CHROMIUM['ws_url']} ['Authorization']" + assert _error(after)["error_code"] == "cdp.no_endpoint" + + +def test_several_endpoints_need_a_name() -> None: + buyer = {**CHROMIUM, "name": "chromium-buyer", "http_url": "http://browser.test:8000/cdp/token-b/"} + with TestClient(CdpAgent().create_app()) as client: + client.post("/ext/cdp-config", json=CHROMIUM) + client.post("/ext/cdp-config", json=buyer) + unnamed = _turn(client, "only") + named = _turn(client, "chromium-buyer") + unknown = _turn(client, "chromium-seller") + + assert _error(unnamed) == { + "error_type": "infra_error", + "error_code": "cdp.ambiguous", + "error_message": "the task gave several browsers; name one of chromium, chromium-buyer", + } + assert _reply(named).startswith("http://browser.test:8000/cdp/token-b/ ") + assert _error(unknown) == { + "error_type": "infra_error", + "error_code": "cdp.no_endpoint", + "error_message": "the task gave no browser named 'chromium-seller'", + } + + +def test_a_streaming_run_fails_closed_the_same_way() -> None: + @a2a_agent( + identity=AgentIdentity(name="cdp-streaming", description="test", version="1"), + extensions=(CDP_CONFIG_V1,), + ) + class StreamingAgent(AgentEnvAgent): + async def run(self, request: TaskRequest): + yield TaskProgress.text("connecting") + yield TaskResult.text(request.cdp_endpoint()["ws_url"]) + + with TestClient(StreamingAgent().create_app()) as client: + task = _turn(client, "only") + + assert _error(task)["error_code"] == "cdp.no_endpoint" + + +def test_a_turn_holds_a_copy_of_the_endpoints() -> None: + endpoints = {"chromium": {**CHROMIUM, "headers": None}} + request = TaskRequest( + task_id="task-1", + context_id="context-1", + parts=(TextPart(text="only"),), + config=AgentConfig(), + cdp_endpoints=endpoints, + ) + endpoints["chromium"]["ws_url"] = "ws://elsewhere.test/" + + assert request.cdp_endpoint()["ws_url"] == CHROMIUM["ws_url"] + assert request.cdp_endpoint("chromium") == request.cdp_endpoints["chromium"] + + +def test_task_failed_takes_only_the_known_error_types() -> None: + assert TaskFailed("cdp.no_endpoint", "none", error_type="infra_error").error.error_type == "infra_error" + assert TaskFailed("tool.failed", "failed").error.error_type == "agent_error" + with pytest.raises(ValueError, match="error_type"): + TaskFailed("cdp.no_endpoint", "none", error_type="custom") # type: ignore[arg-type] diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index cc11a1198..24c643be7 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -135,6 +135,7 @@ class A2AAgent: } EXT_MCP_CONFIG: ClassVar[str] = "urn:agentenv:mcp-config/v1" + EXT_CDP_CONFIG: ClassVar[str] = "urn:agentenv:cdp-config/v1" EXT_SKILL_CONFIG: ClassVar[str] = "urn:agentenv:skill-config/v1" EXT_TRAJECTORY: ClassVar[str] = "urn:agentenv:trajectory/v1" EXT_AGENT_CONFIG: ClassVar[str] = "urn:agentenv:agent-config/v1" diff --git a/src/agent_env/agents/AGENTS.md b/src/agent_env/agents/AGENTS.md index f13fca273..37c453604 100644 --- a/src/agent_env/agents/AGENTS.md +++ b/src/agent_env/agents/AGENTS.md @@ -46,15 +46,16 @@ agent-env speaks A2A JSON-RPC to `/a2a`: `message/send` for each prompt, then `t terminal state, `completed`, `failed` or `canceled`. Your `run()` is one turn. - **In:** `TaskRequest` carries `task_id`, `context_id`, `parts` (`TextPart`, `FilePart` by `bytes` or `uri`, - `DataPart`), `config` (your typed config, as it stood when the task was created), `mcp_servers`, `skills`, - `metadata`, `session_ref`, `workspace` and `trajectory`. + `DataPart`), `config` (your typed config, as it stood when the task was created), `mcp_servers`, + `cdp_endpoints`, `skills`, `metadata`, `session_ref`, `workspace` and `trajectory`. - **Out:** a `TaskResult` from `TaskResult.text(...)`, `TaskResult.failure(code, message)` or `TaskResult.builder()`. Its parts become the task's status message, with `usage` as a data part; a failure adds `{error_type, error_code, error_message}`. A success needs at least one part, and `builder().failed(...)` adds no text part, so add one. - **Errors:** `agent_error`, the default, means the agent tried and failed; `infra_error` means the run couldn't - happen, and agent-env may retry it. An exception that escapes `run()` becomes an opaque `infra_error`, its text - never sent. + happen, and agent-env may retry it. Raising `TaskFailed(code, message, error_type=...)` in `run()` fails the turn + the same way as returning `TaskResult.failure`. Any other exception that escapes `run()` becomes an opaque + `infra_error`, its text never sent. - **Context and sessions:** turns of one `context_id` run one at a time; different contexts run concurrently, and the validator relies on it, so don't serialize them. Return your runtime's conversation id as `session_ref` and the next turn in that context gets it back; bind it before the runtime starts, so a live trajectory read or a mid-turn @@ -117,6 +118,15 @@ agent-env registers each env's MCP server as `{url, name, headers}`. The SDK imp Reference: `enable(MCP_CONFIG_V1, ...)`, and `claude_bridge.py` where it builds `--mcp-config`. +### `cdp-config/v1`, `/ext/cdp-config` + +For an agent that drives a browser itself. agent-env adds each browser it gives the agent as `{name, http_url, +ws_url, headers?, metadata?}`, and adding a name again replaces it. The SDK implements `add`, `list` and `remove` and +passes the endpoints in `request.cdp_endpoints` every turn. Take yours with `request.cdp_endpoint()`, or +`request.cdp_endpoint(name)` when the task gives several, and connect afresh each turn, since an address can change +between turns. With no endpoint, or several and no name, it fails the turn as an `infra_error` (`cdp.no_endpoint`, +`cdp.ambiguous`), so the agent never falls back to a browser of its own. + ### `trajectory/v1`, `/ext/trajectory` agent-env reads what the agent did, and verifiers grade it. Call `request.trajectory.set_format(...)`, then append diff --git a/src/agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py b/src/agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py index 200fbd26c..a0d3e4a74 100644 --- a/src/agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +++ b/src/agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py @@ -91,7 +91,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: logger.info(f"Agent card missing optional fields: {missing_optional}") # Log which extensions the card advertises - for ext_uri in [A2AAgent.EXT_MCP_CONFIG, A2AAgent.EXT_SKILL_CONFIG, A2AAgent.EXT_TRAJECTORY, A2AAgent.EXT_AGENT_CONFIG, A2AAgent.EXT_TOOLS]: + for ext_uri in [A2AAgent.EXT_MCP_CONFIG, A2AAgent.EXT_CDP_CONFIG, A2AAgent.EXT_SKILL_CONFIG, A2AAgent.EXT_TRAJECTORY, A2AAgent.EXT_AGENT_CONFIG, A2AAgent.EXT_TOOLS]: ext = A2AAgent.find_extension(card, ext_uri) logger.info(f" {ext_uri}: {'advertised' if ext else 'not advertised'}") diff --git a/tst/unit/a2a_agent/cdp_config_test.py b/tst/unit/a2a_agent/cdp_config_test.py new file mode 100644 index 000000000..9cdd51de7 --- /dev/null +++ b/tst/unit/a2a_agent/cdp_config_test.py @@ -0,0 +1,40 @@ +"""agent-env finds cdp-config/v1 on a card the protocol SDK builds, and where to call each of its operations.""" + +from starlette.testclient import TestClient + +from agent_env.a2a_agent import A2AAgent +from agentenv_protocol.a2a_agent import ( + CDP_CONFIG_V1, + AgentEnvAgent, + AgentIdentity, + TaskRequest, + TaskResult, + a2a_agent, +) + + +@a2a_agent(identity=AgentIdentity(name="cdp-agent", description="test", version="1"), extensions=(CDP_CONFIG_V1,)) +class CdpAgent(AgentEnvAgent): + async def run(self, request: TaskRequest) -> TaskResult: + return TaskResult.text("ok") + + +def test_each_operation_resolves_to_its_method_and_path(): + with TestClient(CdpAgent().create_app()) as client: + card = client.get("/.well-known/agent-card.json").json() + extension = A2AAgent.find_extension(card, A2AAgent.EXT_CDP_CONFIG) + + assert A2AAgent.EXT_CDP_CONFIG == CDP_CONFIG_V1.uri + resolved = {name: A2AAgent.operation(extension, name) for name in ("add", "list", "remove")} + assert {name: (method["method"], path) for name, (method, path) in resolved.items()} == { + "add": ("POST", "/ext/cdp-config"), + "list": ("GET", "/ext/cdp-config"), + "remove": ("DELETE", "/ext/cdp-config"), + } + + +def test_a_card_without_it_has_no_cdp_operations(): + card = {"capabilities": {"extensions": [{"uri": A2AAgent.EXT_MCP_CONFIG, "params": {}}]}} + + assert A2AAgent.find_extension(card, A2AAgent.EXT_CDP_CONFIG) is None + assert A2AAgent.extension_method(card, A2AAgent.EXT_CDP_CONFIG, "add") is None From b257609518e366bba46f377f110e093f15f8344a Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 2 Nov 2026 08:38:57 -0800 Subject: [PATCH 2/3] fix(agents): cdp-config/v1 refuses an endpoint URL without a host or with a bad port urlsplit accepts http://:8080/ and ws://browser.test:abc/ with a netloc, so add stored addresses no client could connect to. FD-4948 Co-Authored-By: Claude Opus 5.5 --- .../src/agentenv_protocol/a2a_agent/extensions.py | 9 +++++++-- packages/agentenv-protocol/tests/test_a2a_cdp_config.py | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py index 7571596c6..d48ff283b 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/extensions.py @@ -64,9 +64,14 @@ class McpAddRequest(ExtensionRequest): 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) - if parts.scheme not in schemes or not parts.netloc: - raise ValueError(f"must be an absolute {' or '.join(schemes)} 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 diff --git a/packages/agentenv-protocol/tests/test_a2a_cdp_config.py b/packages/agentenv-protocol/tests/test_a2a_cdp_config.py index c97131363..a497de4d0 100644 --- a/packages/agentenv-protocol/tests/test_a2a_cdp_config.py +++ b/packages/agentenv-protocol/tests/test_a2a_cdp_config.py @@ -126,8 +126,11 @@ def test_remove_takes_a_name_and_a_name_that_is_not_there_is_not_an_error() -> N {"name": "chromium buyer"}, {"http_url": "ws://browser.test/cdp/"}, {"http_url": "/cdp/token-1/"}, + {"http_url": "http://:8080/"}, {"ws_url": "http://browser.test/cdp/"}, {"ws_url": "ws:///cdp/"}, + {"ws_url": "ws://browser.test:abc/"}, + {"ws_url": "ws://browser.test:70000/"}, {"extra": "field"}, ], ) From 7b0749b8dc35437410db0709f825c186f35b66f0 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 2 Nov 2026 09:08:33 -0800 Subject: [PATCH 3/3] refactor(agents): cdp-config/v1 list shows no URLs, and its turn failure stays private - list returns each endpoint's has_headers and metadata only. An agentenv address carries its credential in the URL, so hiding headers alone hid nothing that matters. - TaskFailed becomes the private _TaskFailed: only cdp_endpoint raises it, so the protocol gains no public way to fail a turn yet. The docs say to call cdp_endpoint before an exception handler that catches everything. - AGENTS.md no longer says agent-env sends endpoints: nothing does until the browser_connect_cdp task step. FD-4948 Co-Authored-By: Claude Opus 5.5 --- packages/agentenv-protocol/README.md | 23 +++++++++---------- .../agentenv_protocol/a2a_agent/__init__.py | 2 -- .../agentenv_protocol/a2a_agent/framework.py | 7 +++--- .../agentenv_protocol/a2a_agent/tasks/v1.py | 10 ++++---- .../tests/test_a2a_cdp_config.py | 19 +++------------ src/agent_env/agents/AGENTS.md | 19 +++++++-------- 6 files changed, 32 insertions(+), 48 deletions(-) diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index 6fb7f991b..8b40aa5cf 100644 --- a/packages/agentenv-protocol/README.md +++ b/packages/agentenv-protocol/README.md @@ -866,10 +866,8 @@ and become failed A2A tasks with `error_type`, `error_code`, and `error_message` in the terminal message. `error_type` is the platform classification (`agent_error` by default, or explicitly `infra_error` for a retryable infrastructure failure); `error_code` preserves the author's machine-readable -code. `run()` may instead raise `TaskFailed(code, message, error_type=...)`, -which fails the task the same way. Any other exception escaping `run()`, an -invalid return value, or a result-mapping failure is logged with a -correlation ID and reported as an +code. An exception escaping `run()`, an invalid return value, or a +result-mapping failure is logged with a correlation ID and reported as an `infra_error` with code `framework.unhandled_exception`; raw exception text is never sent to callers. Invalid input that prevents task creation returns JSON-RPC `InvalidParams`. @@ -1178,14 +1176,15 @@ tools with (agent-env relays the env card's name: the MultiEnv's declared name, `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: {"http_url", -"ws_url", "has_headers", "metadata"}}}`, header values hidden, and -`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, -headers included. `TaskRequest.cdp_endpoint(name=None)` returns the named -endpoint, or the only one, and otherwise raises `TaskFailed`, failing the task -as an `infra_error` with `cdp.no_endpoint` (none, or none by that name) or -`cdp.ambiguous` (several and no name). +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", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py index 775bade71..5f17fed1e 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/__init__.py @@ -111,7 +111,6 @@ FilePart, NativeTrajectory, TaskError, - TaskFailed, TaskOutcome, TaskPart, TaskProgress, @@ -231,7 +230,6 @@ "StagingStore", "TaskError", "TaskEventsTrajectoryRequest", - "TaskFailed", "TaskObjectTrajectoryRequest", "TaskOutcome", "TaskPart", diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py index a3ce38ae2..ce344d08a 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/framework.py @@ -65,11 +65,11 @@ from .tasks.v1 import ( AgentConfig, AgentRunResult, - TaskFailed, TaskOutcome, TaskProgress, TaskRequest, TaskResult, + _TaskFailed, ) from .tasks.v1 import ( DataPart as TaskDataPart, @@ -740,11 +740,10 @@ async def cdp_add(self, request: CdpAddRequest) -> dict[str, Any]: 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: { - "http_url": endpoint["http_url"], - "ws_url": endpoint["ws_url"], "has_headers": bool(endpoint["headers"]), "metadata": endpoint["metadata"], } @@ -1285,7 +1284,7 @@ async def execute(self, context: Any, event_queue: Any) -> None: result = await self._run_streaming(request, updater, event_queue) else: result = await self._handler(request) - except TaskFailed as failed: + except _TaskFailed as failed: result = TaskResult.failure( failed.error.code, failed.error.message, error_type=failed.error.error_type ) diff --git a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/tasks/v1.py b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/tasks/v1.py index 514bfb7ba..920ec7226 100644 --- a/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/tasks/v1.py +++ b/packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/tasks/v1.py @@ -246,14 +246,14 @@ def cdp_endpoint(self, name: str | None = None) -> Mapping[str, Any]: if name is not None: if name in self.cdp_endpoints: return self.cdp_endpoints[name] - raise TaskFailed( + 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( + 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", @@ -276,8 +276,8 @@ def __post_init__(self) -> None: raise ValueError("error_type must be 'agent_error' or 'infra_error'") -class TaskFailed(Exception): - """Raised in ``run()`` to end the turn as ``TaskResult.failure(code, message, error_type=error_type)``.""" +class _TaskFailed(Exception): + """Ends the turn as ``TaskResult.failure(code, message, error_type=error_type)``; ``cdp_endpoint`` raises it.""" def __init__( self, diff --git a/packages/agentenv-protocol/tests/test_a2a_cdp_config.py b/packages/agentenv-protocol/tests/test_a2a_cdp_config.py index a497de4d0..2ede4bf1c 100644 --- a/packages/agentenv-protocol/tests/test_a2a_cdp_config.py +++ b/packages/agentenv-protocol/tests/test_a2a_cdp_config.py @@ -13,7 +13,6 @@ CDP_CONFIG_V1, AgentEnvAgent, AgentIdentity, - TaskFailed, TaskProgress, TaskRequest, TaskResult, @@ -85,7 +84,7 @@ def test_the_card_lists_add_list_and_remove() -> None: assert all("endpoint" not in method for method in methods.values()) -def test_adding_a_name_again_replaces_it_and_list_hides_header_values() -> None: +def test_adding_a_name_again_replaces_it_and_list_hides_urls_and_header_values() -> None: with TestClient(CdpAgent().create_app()) as client: assert client.post("/ext/cdp-config", json=CHROMIUM).json() == {"status": "added", "name": "chromium"} replaced = {**CHROMIUM, "http_url": "http://browser.test:8000/cdp/token-2/", "headers": {"X-Secret": "s3"}} @@ -93,16 +92,10 @@ def test_adding_a_name_again_replaces_it_and_list_hides_header_values() -> None: listing = client.get("/ext/cdp-config") assert listing.json() == { - "cdp_endpoints": { - "chromium": { - "http_url": "http://browser.test:8000/cdp/token-2/", - "ws_url": CHROMIUM["ws_url"], - "has_headers": True, - "metadata": CHROMIUM["metadata"], - } - } + "cdp_endpoints": {"chromium": {"has_headers": True, "metadata": CHROMIUM["metadata"]}} } assert "s3" not in listing.text + assert "token-" not in listing.text def test_remove_takes_a_name_and_a_name_that_is_not_there_is_not_an_error() -> None: @@ -212,9 +205,3 @@ def test_a_turn_holds_a_copy_of_the_endpoints() -> None: assert request.cdp_endpoint()["ws_url"] == CHROMIUM["ws_url"] assert request.cdp_endpoint("chromium") == request.cdp_endpoints["chromium"] - -def test_task_failed_takes_only_the_known_error_types() -> None: - assert TaskFailed("cdp.no_endpoint", "none", error_type="infra_error").error.error_type == "infra_error" - assert TaskFailed("tool.failed", "failed").error.error_type == "agent_error" - with pytest.raises(ValueError, match="error_type"): - TaskFailed("cdp.no_endpoint", "none", error_type="custom") # type: ignore[arg-type] diff --git a/src/agent_env/agents/AGENTS.md b/src/agent_env/agents/AGENTS.md index 37c453604..0ae4d98a9 100644 --- a/src/agent_env/agents/AGENTS.md +++ b/src/agent_env/agents/AGENTS.md @@ -53,9 +53,8 @@ terminal state, `completed`, `failed` or `canceled`. Your `run()` is one turn. `{error_type, error_code, error_message}`. A success needs at least one part, and `builder().failed(...)` adds no text part, so add one. - **Errors:** `agent_error`, the default, means the agent tried and failed; `infra_error` means the run couldn't - happen, and agent-env may retry it. Raising `TaskFailed(code, message, error_type=...)` in `run()` fails the turn - the same way as returning `TaskResult.failure`. Any other exception that escapes `run()` becomes an opaque - `infra_error`, its text never sent. + happen, and agent-env may retry it. An exception that escapes `run()` becomes an opaque `infra_error`, its text + never sent. - **Context and sessions:** turns of one `context_id` run one at a time; different contexts run concurrently, and the validator relies on it, so don't serialize them. Return your runtime's conversation id as `session_ref` and the next turn in that context gets it back; bind it before the runtime starts, so a live trajectory read or a mid-turn @@ -120,12 +119,14 @@ Reference: `enable(MCP_CONFIG_V1, ...)`, and `claude_bridge.py` where it builds ### `cdp-config/v1`, `/ext/cdp-config` -For an agent that drives a browser itself. agent-env adds each browser it gives the agent as `{name, http_url, -ws_url, headers?, metadata?}`, and adding a name again replaces it. The SDK implements `add`, `list` and `remove` and -passes the endpoints in `request.cdp_endpoints` every turn. Take yours with `request.cdp_endpoint()`, or -`request.cdp_endpoint(name)` when the task gives several, and connect afresh each turn, since an address can change -between turns. With no endpoint, or several and no name, it fails the turn as an `infra_error` (`cdp.no_endpoint`, -`cdp.ambiguous`), so the agent never falls back to a browser of its own. +For an agent that drives a browser itself. A browser reaches the agent as an endpoint `{name, http_url, ws_url, +headers?, metadata?}` sent to `add`, and adding a name again replaces it; nothing in agent-env sends one yet (the +`browser_connect_cdp` task step will). The SDK implements `add`, `list` and `remove`, and passes the endpoints in +`request.cdp_endpoints` every turn. Take yours with `request.cdp_endpoint()`, or `request.cdp_endpoint(name)` when +the task gives several, and connect afresh each turn, since an address can change between turns. With no endpoint, or +several and no name, it fails the turn as an `infra_error` (`cdp.no_endpoint`, `cdp.ambiguous`), so the agent never +falls back to a browser of its own. It fails the turn by raising, so call it before any handler of yours that catches +every exception. ### `trajectory/v1`, `/ext/trajectory`