diff --git a/packages/agentenv-protocol/README.md b/packages/agentenv-protocol/README.md index a6ecae16..8b40aa5c 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 @@ -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__`); 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 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 8ccde546..5f17fed1 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, @@ -137,6 +140,7 @@ __all__ = [ "AGENT_CONFIG_V1", "ATTRIBUTION_PROBE_V1", + "CDP_CONFIG_V1", "PROXY_V1", "INSTALL_V1", "MCP_CONFIG_V1", @@ -157,6 +161,8 @@ "AttributionProbeResponse", "BuiltinTools", "BundleSkillRequest", + "CdpAddRequest", + "CdpRemoveRequest", "ChangelogIncrement", "ContextEventsTrajectoryRequest", "ContextObjectTrajectoryRequest", 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 0ddb4718..d48ff283 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,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 + + +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 +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.", @@ -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, 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 1cccdd2b..ce344d08 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, @@ -66,6 +69,7 @@ TaskProgress, TaskRequest, TaskResult, + _TaskFailed, ) from .tasks.v1 import ( DataPart as TaskDataPart, @@ -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,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 @@ -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: 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 a2eb5d3b..920ec722 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): + """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 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 00000000..2ede4bf1 --- /dev/null +++ b/packages/agentenv-protocol/tests/test_a2a_cdp_config.py @@ -0,0 +1,207 @@ +"""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, + 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_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"}} + client.post("/ext/cdp-config", json=replaced) + listing = client.get("/ext/cdp-config") + + assert listing.json() == { + "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: + 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/"}, + {"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"}, + ], +) +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"] + diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index cc11a119..24c643be 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 f13fca27..0ae4d98a 100644 --- a/src/agent_env/agents/AGENTS.md +++ b/src/agent_env/agents/AGENTS.md @@ -46,8 +46,8 @@ 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 @@ -117,6 +117,17 @@ 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. 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` 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 200fbd26..a0d3e4a7 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 00000000..9cdd51de --- /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