From fb4088c9ebea458c87a1538b83f9f3dd00b5ce89 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 4 May 2026 10:40:16 -0700 Subject: [PATCH] feat: add AgentsResource (messaging primitive identity surface) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the entire `/v1/agents` surface from the messaging primitive (Phase 12.1.5). Closes the agents portion of the `Messaging primitive` endpoints_missing entry in cueapi-python #24's parity manifest. The companion `MessagesResource` (send/get/read/ack lifecycle) ships in a follow-up PR. New resource: - `cueapi/resources/agents.py`: AgentsResource - .create(display_name, slug=None, webhook_url=None, metadata=None) - .list(status=None, include_deleted=False, limit=50, offset=0) - .get(ref, include_deleted=False) - .update(ref, display_name=None, webhook_url=None, clear_webhook_url=False, status=None, metadata=None) - .delete(ref) - .webhook_secret_get(ref) - .webhook_secret_regenerate(ref) # sends X-Confirm-Destructive: true - .inbox(ref, state=None, limit=50, offset=0) - .sent(ref, limit=50, offset=0) Client extension: - `client._request` now accepts an optional `headers` kwarg, which extends (does not replace) the client's default Authorization + Content-Type + User-Agent headers. Used here for the destructive X-Confirm-Destructive guard; will also be used by the upcoming MessagesResource for X-Cueapi-From-Agent + Idempotency-Key. Design notes pinned by tests: - `--include-deleted` mirror: `include_deleted=True` sends `"true"`, `False` (default) omits. Same omit-when-default pattern as PR #26's `executions list --has-evidence`. - `clear_webhook_url=True` sends literal JSON `null` (key present, value None), NOT field omission. Server uses `model_fields_set` to disambiguate "omitted = no change" from "explicit null = clear", so the SDK MUST send the key with explicit None. Pinned by test_clear_webhook_url_sends_explicit_null. - `webhook_url` and `clear_webhook_url` mutex enforced with a clear ValueError before any HTTP call. - `webhook_secret_regenerate` sends X-Confirm-Destructive: true in the header. The server requires it; the SDK adds it automatically so callers don't have to know about the header. Pinned by test_regenerate_sends_destructive_header. Tests: 18 new across 9 test classes (12 → ~30 unit tests; total 46 passing across all unit-test files). No hosted-PR dependency. All 9 endpoints already shipped on prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- cueapi/__init__.py | 2 + cueapi/client.py | 16 ++- cueapi/resources/agents.py | 192 +++++++++++++++++++++++++++++ tests/test_agents_resource.py | 219 ++++++++++++++++++++++++++++++++++ 4 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 cueapi/resources/agents.py create mode 100644 tests/test_agents_resource.py diff --git a/cueapi/__init__.py b/cueapi/__init__.py index 86ca880..3be480c 100644 --- a/cueapi/__init__.py +++ b/cueapi/__init__.py @@ -11,6 +11,7 @@ RateLimitError, ) from cueapi.payload import CuePayload +from cueapi.resources.agents import AgentsResource from cueapi.resources.executions import ExecutionsResource from cueapi.resources.usage import UsageResource from cueapi.resources.workers import WorkersResource @@ -19,6 +20,7 @@ __version__ = "0.1.2" __all__ = [ + "AgentsResource", "CueAPI", "CuePayload", "ExecutionsResource", diff --git a/cueapi/client.py b/cueapi/client.py index 0fe27b4..ce925a9 100644 --- a/cueapi/client.py +++ b/cueapi/client.py @@ -15,6 +15,7 @@ InvalidScheduleError, RateLimitError, ) +from cueapi.resources.agents import AgentsResource from cueapi.resources.cues import CuesResource from cueapi.resources.executions import ExecutionsResource from cueapi.resources.usage import UsageResource @@ -73,6 +74,7 @@ def __init__( self.executions = ExecutionsResource(self) self.workers = WorkersResource(self) self.usage = UsageResource(self) + self.agents = AgentsResource(self) def close(self) -> None: """Close the underlying HTTP client.""" @@ -93,9 +95,19 @@ def _request( *, json: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, ) -> Any: - """Make an HTTP request and handle errors.""" - response = self._http.request(method, path, json=json, params=params) + """Make an HTTP request and handle errors. + + ``headers`` extends (does not replace) the client's default + ``Authorization`` + ``Content-Type`` + ``User-Agent`` headers. + Used by per-call header semantics: messaging primitive's + ``X-Cueapi-From-Agent`` + ``Idempotency-Key``, and the + destructive-operation guard ``X-Confirm-Destructive``. + """ + response = self._http.request( + method, path, json=json, params=params, headers=headers + ) return self._handle_response(response) def _handle_response(self, response: httpx.Response) -> Any: diff --git a/cueapi/resources/agents.py b/cueapi/resources/agents.py new file mode 100644 index 0000000..d7c8933 --- /dev/null +++ b/cueapi/resources/agents.py @@ -0,0 +1,192 @@ +"""Agents resource — messaging primitive identity surface (Phase 12.1.5).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +if TYPE_CHECKING: + from cueapi.client import CueAPI + + +class AgentsResource: + """Agents API resource. + + Wraps the ``/v1/agents`` surface from the messaging primitive + (Phase 12.1.5). Covers identity CRUD, webhook-secret rotation, and + the inbox/sent message lists keyed by agent ref. + + The send/get/read/ack message lifecycle lives on a sibling + ``client.messages`` resource — this class only handles identity. + """ + + def __init__(self, client: "CueAPI") -> None: + self._client = client + + def create( + self, + *, + display_name: str, + slug: Optional[str] = None, + webhook_url: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: + """Create an agent. + + The ``webhook_secret`` field is populated in the 201 response + ONLY when ``webhook_url`` is supplied. Subsequent reads omit + the secret. Save it now or use ``webhook_secret_regenerate()`` + to mint a fresh one (which revokes the old one). + + Args: + display_name: Human-readable name (1-255 chars). + slug: Optional per-user unique slug. If omitted, the server + derives one from ``display_name``. + webhook_url: Push-delivery target. SSRF-validated. Omit for + poll-only. + metadata: Optional JSON metadata blob. + + Returns: + Dict matching the server's ``AgentResponse`` shape, including + ``webhook_secret`` ONCE on this response if ``webhook_url`` + was given. + """ + body: Dict[str, Any] = {"display_name": display_name} + if slug is not None: + body["slug"] = slug + if webhook_url is not None: + body["webhook_url"] = webhook_url + if metadata is not None: + body["metadata"] = metadata + return self._client._post("/v1/agents", json=body) + + def list( + self, + *, + status: Optional[str] = None, + include_deleted: bool = False, + limit: int = 50, + offset: int = 0, + ) -> dict: + """List your agents. + + Args: + status: Optional filter — ``online`` / ``offline`` / ``away``. + include_deleted: Whether to include soft-deleted agents. + Defaults to False; only sent on the wire when True + (omit-when-default keeps URLs clean and matches the + server's ``include_deleted=false`` default). + limit: Page size (default 50, max 100). + offset: Pagination offset. + """ + params: Dict[str, Any] = {"limit": limit, "offset": offset} + if status is not None: + params["status"] = status + if include_deleted: + params["include_deleted"] = "true" + return self._client._get("/v1/agents", params=params) + + def get( + self, + ref: str, + *, + include_deleted: bool = False, + ) -> dict: + """Get an agent by opaque ID or slug-form (``agent@user``).""" + params: Dict[str, Any] = {} + if include_deleted: + params["include_deleted"] = "true" + return self._client._get(f"/v1/agents/{ref}", params=params) + + def update( + self, + ref: str, + *, + display_name: Optional[str] = None, + webhook_url: Optional[str] = None, + clear_webhook_url: bool = False, + status: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> dict: + """Update an agent (PATCH semantics). + + ``webhook_url`` and ``clear_webhook_url`` are mutually exclusive. + Pass ``clear_webhook_url=True`` to send literal JSON ``null`` and + revert the agent to poll-only — the server uses + ``model_fields_set`` to disambiguate "field omitted = no change" + from "field explicitly null = clear", so the SDK MUST send the + key with explicit None rather than omit. + """ + if webhook_url is not None and clear_webhook_url: + raise ValueError( + "webhook_url and clear_webhook_url are mutually exclusive" + ) + body: Dict[str, Any] = {} + if display_name is not None: + body["display_name"] = display_name + if webhook_url is not None: + body["webhook_url"] = webhook_url + elif clear_webhook_url: + body["webhook_url"] = None + if status is not None: + body["status"] = status + if metadata is not None: + body["metadata"] = metadata + return self._client._patch(f"/v1/agents/{ref}", json=body) + + def delete(self, ref: str) -> None: + """Soft-delete an agent. Returns ``None`` on success (204).""" + return self._client._delete(f"/v1/agents/{ref}") + + def webhook_secret_get(self, ref: str) -> dict: + """Reveal the agent's current webhook signing secret. + + 404 path commonly means the agent has no ``webhook_url`` set + (poll-only agents have no webhook secret). + """ + return self._client._get(f"/v1/agents/{ref}/webhook-secret") + + def webhook_secret_regenerate(self, ref: str) -> dict: + """Mint a fresh webhook secret. Old secret revoked immediately. + + Sends ``X-Confirm-Destructive: true`` header, which the server + requires for this destructive op. Returns the new secret one-time + in the response — save it now. + """ + return self._client._post( + f"/v1/agents/{ref}/webhook-secret/regenerate", + json={}, + headers={"X-Confirm-Destructive": "true"}, + ) + + def inbox( + self, + ref: str, + *, + state: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> dict: + """Poll the agent's inbox (incoming messages). + + Args: + ref: Agent opaque ID or slug-form. + state: Optional filter (e.g. ``queued`` / ``delivered`` / + ``read`` / ``acked`` / ``failed``). + limit: Page size (default 50). + offset: Pagination offset. + """ + params: Dict[str, Any] = {"limit": limit, "offset": offset} + if state is not None: + params["state"] = state + return self._client._get(f"/v1/agents/{ref}/inbox", params=params) + + def sent( + self, + ref: str, + *, + limit: int = 50, + offset: int = 0, + ) -> dict: + """List messages sent by this agent.""" + params: Dict[str, Any] = {"limit": limit, "offset": offset} + return self._client._get(f"/v1/agents/{ref}/sent", params=params) diff --git a/tests/test_agents_resource.py b/tests/test_agents_resource.py new file mode 100644 index 0000000..58c6de1 --- /dev/null +++ b/tests/test_agents_resource.py @@ -0,0 +1,219 @@ +"""Tests for AgentsResource.""" + +import pytest +from unittest.mock import MagicMock + +from cueapi.resources.agents import AgentsResource + + +class TestCreate: + def test_minimal_only_display_name(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_x", "slug": "team-comm", "display_name": "Team Comm", + "status": "online", + } + r = AgentsResource(mock_client) + + r.create(display_name="Team Comm") + + mock_client._post.assert_called_once_with( + "/v1/agents", + json={"display_name": "Team Comm"}, + ) + + def test_with_all_optionals(self): + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "agt_x", "slug": "team-comm", "display_name": "Team Comm", + "status": "online", "webhook_url": "https://x.example", + "webhook_secret": "wsec_secretvalue", + } + r = AgentsResource(mock_client) + + r.create( + display_name="Team Comm", + slug="team-comm", + webhook_url="https://x.example/webhook", + metadata={"team": "platform"}, + ) + + mock_client._post.assert_called_once_with( + "/v1/agents", + json={ + "display_name": "Team Comm", + "slug": "team-comm", + "webhook_url": "https://x.example/webhook", + "metadata": {"team": "platform"}, + }, + ) + + +class TestList: + def test_defaults_omit_filters(self): + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list() + + params = mock_client._get.call_args.kwargs["params"] + assert params["limit"] == 50 + assert params["offset"] == 0 + assert "status" not in params + assert "include_deleted" not in params + + def test_include_deleted_only_sent_when_true(self): + # Same omit-when-default pattern as `executions list --has-evidence` + # in the CLI. Pinned so a refactor can't silently start sending + # `include_deleted=false` (which is no-op server-side but adds noise). + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list(include_deleted=True) + assert mock_client._get.call_args.kwargs["params"]["include_deleted"] == "true" + + # Reset, run with default — must omit. + mock_client.reset_mock() + r.list() + assert "include_deleted" not in mock_client._get.call_args.kwargs["params"] + + def test_status_filter_passed(self): + mock_client = MagicMock() + mock_client._get.return_value = {"agents": [], "total": 0} + r = AgentsResource(mock_client) + + r.list(status="online") + + assert mock_client._get.call_args.kwargs["params"]["status"] == "online" + + +class TestGet: + def test_get_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.get("agt_x") + + mock_client._get.assert_called_once_with("/v1/agents/agt_x", params={}) + + def test_get_with_include_deleted(self): + mock_client = MagicMock() + mock_client._get.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.get("agt_x", include_deleted=True) + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x", params={"include_deleted": "true"} + ) + + +class TestUpdate: + def test_partial_body(self): + mock_client = MagicMock() + mock_client._patch.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.update("agt_x", status="away") + + mock_client._patch.assert_called_once_with( + "/v1/agents/agt_x", json={"status": "away"} + ) + + def test_clear_webhook_url_sends_explicit_null(self): + # Mirror of cueapi-cli #28's --clear-webhook-url pin. Server uses + # model_fields_set to disambiguate "field omitted = no change" + # vs "field explicitly null = clear", so the SDK MUST send None + # (literal JSON null) rather than omit the key. + mock_client = MagicMock() + mock_client._patch.return_value = {"id": "agt_x"} + r = AgentsResource(mock_client) + + r.update("agt_x", clear_webhook_url=True) + + sent_body = mock_client._patch.call_args.kwargs["json"] + assert "webhook_url" in sent_body + assert sent_body["webhook_url"] is None + + def test_webhook_url_and_clear_mutually_exclusive(self): + mock_client = MagicMock() + r = AgentsResource(mock_client) + + with pytest.raises(ValueError, match="mutually exclusive"): + r.update("agt_x", webhook_url="https://x.example", clear_webhook_url=True) + + +class TestDelete: + def test_delete(self): + mock_client = MagicMock() + mock_client._delete.return_value = None + r = AgentsResource(mock_client) + + result = r.delete("agt_x") + + mock_client._delete.assert_called_once_with("/v1/agents/agt_x") + assert result is None + + +class TestWebhookSecret: + def test_get(self): + mock_client = MagicMock() + mock_client._get.return_value = {"webhook_secret": "wsec_revealed"} + r = AgentsResource(mock_client) + + r.webhook_secret_get("agt_x") + + mock_client._get.assert_called_once_with("/v1/agents/agt_x/webhook-secret") + + def test_regenerate_sends_destructive_header(self): + # Server requires X-Confirm-Destructive: true for this op. Pin + # the header so a refactor can't drop it (which would 400). + mock_client = MagicMock() + mock_client._post.return_value = {"webhook_secret": "wsec_new"} + r = AgentsResource(mock_client) + + r.webhook_secret_regenerate("agt_x") + + mock_client._post.assert_called_once_with( + "/v1/agents/agt_x/webhook-secret/regenerate", + json={}, + headers={"X-Confirm-Destructive": "true"}, + ) + + +class TestInbox: + def test_inbox_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.inbox("agt_x") + + params = mock_client._get.call_args.kwargs["params"] + assert params == {"limit": 50, "offset": 0} + + def test_inbox_with_state_filter(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.inbox("agt_x", state="queued") + + assert mock_client._get.call_args.kwargs["params"]["state"] == "queued" + + +class TestSent: + def test_sent_basic(self): + mock_client = MagicMock() + mock_client._get.return_value = {"messages": [], "total": 0} + r = AgentsResource(mock_client) + + r.sent("agt_x") + + mock_client._get.assert_called_once_with( + "/v1/agents/agt_x/sent", + params={"limit": 50, "offset": 0}, + )