Skip to content

Commit 7be845b

Browse files
authored
feat: add AgentsResource (messaging primitive identity surface) (#27)
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)
1 parent 4b37d97 commit 7be845b

4 files changed

Lines changed: 427 additions & 2 deletions

File tree

cueapi/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
RateLimitError,
1212
)
1313
from cueapi.payload import CuePayload
14+
from cueapi.resources.agents import AgentsResource
1415
from cueapi.resources.executions import ExecutionsResource
1516
from cueapi.resources.usage import UsageResource
1617
from cueapi.resources.workers import WorkersResource
@@ -19,6 +20,7 @@
1920
__version__ = "0.1.2"
2021

2122
__all__ = [
23+
"AgentsResource",
2224
"CueAPI",
2325
"CuePayload",
2426
"ExecutionsResource",

cueapi/client.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
InvalidScheduleError,
1616
RateLimitError,
1717
)
18+
from cueapi.resources.agents import AgentsResource
1819
from cueapi.resources.cues import CuesResource
1920
from cueapi.resources.executions import ExecutionsResource
2021
from cueapi.resources.usage import UsageResource
@@ -73,6 +74,7 @@ def __init__(
7374
self.executions = ExecutionsResource(self)
7475
self.workers = WorkersResource(self)
7576
self.usage = UsageResource(self)
77+
self.agents = AgentsResource(self)
7678

7779
def close(self) -> None:
7880
"""Close the underlying HTTP client."""
@@ -93,9 +95,19 @@ def _request(
9395
*,
9496
json: Optional[Dict[str, Any]] = None,
9597
params: Optional[Dict[str, Any]] = None,
98+
headers: Optional[Dict[str, str]] = None,
9699
) -> Any:
97-
"""Make an HTTP request and handle errors."""
98-
response = self._http.request(method, path, json=json, params=params)
100+
"""Make an HTTP request and handle errors.
101+
102+
``headers`` extends (does not replace) the client's default
103+
``Authorization`` + ``Content-Type`` + ``User-Agent`` headers.
104+
Used by per-call header semantics: messaging primitive's
105+
``X-Cueapi-From-Agent`` + ``Idempotency-Key``, and the
106+
destructive-operation guard ``X-Confirm-Destructive``.
107+
"""
108+
response = self._http.request(
109+
method, path, json=json, params=params, headers=headers
110+
)
99111
return self._handle_response(response)
100112

101113
def _handle_response(self, response: httpx.Response) -> Any:

cueapi/resources/agents.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Agents resource — messaging primitive identity surface (Phase 12.1.5)."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING, Any, Dict, Optional
6+
7+
if TYPE_CHECKING:
8+
from cueapi.client import CueAPI
9+
10+
11+
class AgentsResource:
12+
"""Agents API resource.
13+
14+
Wraps the ``/v1/agents`` surface from the messaging primitive
15+
(Phase 12.1.5). Covers identity CRUD, webhook-secret rotation, and
16+
the inbox/sent message lists keyed by agent ref.
17+
18+
The send/get/read/ack message lifecycle lives on a sibling
19+
``client.messages`` resource — this class only handles identity.
20+
"""
21+
22+
def __init__(self, client: "CueAPI") -> None:
23+
self._client = client
24+
25+
def create(
26+
self,
27+
*,
28+
display_name: str,
29+
slug: Optional[str] = None,
30+
webhook_url: Optional[str] = None,
31+
metadata: Optional[Dict[str, Any]] = None,
32+
) -> dict:
33+
"""Create an agent.
34+
35+
The ``webhook_secret`` field is populated in the 201 response
36+
ONLY when ``webhook_url`` is supplied. Subsequent reads omit
37+
the secret. Save it now or use ``webhook_secret_regenerate()``
38+
to mint a fresh one (which revokes the old one).
39+
40+
Args:
41+
display_name: Human-readable name (1-255 chars).
42+
slug: Optional per-user unique slug. If omitted, the server
43+
derives one from ``display_name``.
44+
webhook_url: Push-delivery target. SSRF-validated. Omit for
45+
poll-only.
46+
metadata: Optional JSON metadata blob.
47+
48+
Returns:
49+
Dict matching the server's ``AgentResponse`` shape, including
50+
``webhook_secret`` ONCE on this response if ``webhook_url``
51+
was given.
52+
"""
53+
body: Dict[str, Any] = {"display_name": display_name}
54+
if slug is not None:
55+
body["slug"] = slug
56+
if webhook_url is not None:
57+
body["webhook_url"] = webhook_url
58+
if metadata is not None:
59+
body["metadata"] = metadata
60+
return self._client._post("/v1/agents", json=body)
61+
62+
def list(
63+
self,
64+
*,
65+
status: Optional[str] = None,
66+
include_deleted: bool = False,
67+
limit: int = 50,
68+
offset: int = 0,
69+
) -> dict:
70+
"""List your agents.
71+
72+
Args:
73+
status: Optional filter — ``online`` / ``offline`` / ``away``.
74+
include_deleted: Whether to include soft-deleted agents.
75+
Defaults to False; only sent on the wire when True
76+
(omit-when-default keeps URLs clean and matches the
77+
server's ``include_deleted=false`` default).
78+
limit: Page size (default 50, max 100).
79+
offset: Pagination offset.
80+
"""
81+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
82+
if status is not None:
83+
params["status"] = status
84+
if include_deleted:
85+
params["include_deleted"] = "true"
86+
return self._client._get("/v1/agents", params=params)
87+
88+
def get(
89+
self,
90+
ref: str,
91+
*,
92+
include_deleted: bool = False,
93+
) -> dict:
94+
"""Get an agent by opaque ID or slug-form (``agent@user``)."""
95+
params: Dict[str, Any] = {}
96+
if include_deleted:
97+
params["include_deleted"] = "true"
98+
return self._client._get(f"/v1/agents/{ref}", params=params)
99+
100+
def update(
101+
self,
102+
ref: str,
103+
*,
104+
display_name: Optional[str] = None,
105+
webhook_url: Optional[str] = None,
106+
clear_webhook_url: bool = False,
107+
status: Optional[str] = None,
108+
metadata: Optional[Dict[str, Any]] = None,
109+
) -> dict:
110+
"""Update an agent (PATCH semantics).
111+
112+
``webhook_url`` and ``clear_webhook_url`` are mutually exclusive.
113+
Pass ``clear_webhook_url=True`` to send literal JSON ``null`` and
114+
revert the agent to poll-only — the server uses
115+
``model_fields_set`` to disambiguate "field omitted = no change"
116+
from "field explicitly null = clear", so the SDK MUST send the
117+
key with explicit None rather than omit.
118+
"""
119+
if webhook_url is not None and clear_webhook_url:
120+
raise ValueError(
121+
"webhook_url and clear_webhook_url are mutually exclusive"
122+
)
123+
body: Dict[str, Any] = {}
124+
if display_name is not None:
125+
body["display_name"] = display_name
126+
if webhook_url is not None:
127+
body["webhook_url"] = webhook_url
128+
elif clear_webhook_url:
129+
body["webhook_url"] = None
130+
if status is not None:
131+
body["status"] = status
132+
if metadata is not None:
133+
body["metadata"] = metadata
134+
return self._client._patch(f"/v1/agents/{ref}", json=body)
135+
136+
def delete(self, ref: str) -> None:
137+
"""Soft-delete an agent. Returns ``None`` on success (204)."""
138+
return self._client._delete(f"/v1/agents/{ref}")
139+
140+
def webhook_secret_get(self, ref: str) -> dict:
141+
"""Reveal the agent's current webhook signing secret.
142+
143+
404 path commonly means the agent has no ``webhook_url`` set
144+
(poll-only agents have no webhook secret).
145+
"""
146+
return self._client._get(f"/v1/agents/{ref}/webhook-secret")
147+
148+
def webhook_secret_regenerate(self, ref: str) -> dict:
149+
"""Mint a fresh webhook secret. Old secret revoked immediately.
150+
151+
Sends ``X-Confirm-Destructive: true`` header, which the server
152+
requires for this destructive op. Returns the new secret one-time
153+
in the response — save it now.
154+
"""
155+
return self._client._post(
156+
f"/v1/agents/{ref}/webhook-secret/regenerate",
157+
json={},
158+
headers={"X-Confirm-Destructive": "true"},
159+
)
160+
161+
def inbox(
162+
self,
163+
ref: str,
164+
*,
165+
state: Optional[str] = None,
166+
limit: int = 50,
167+
offset: int = 0,
168+
) -> dict:
169+
"""Poll the agent's inbox (incoming messages).
170+
171+
Args:
172+
ref: Agent opaque ID or slug-form.
173+
state: Optional filter (e.g. ``queued`` / ``delivered`` /
174+
``read`` / ``acked`` / ``failed``).
175+
limit: Page size (default 50).
176+
offset: Pagination offset.
177+
"""
178+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
179+
if state is not None:
180+
params["state"] = state
181+
return self._client._get(f"/v1/agents/{ref}/inbox", params=params)
182+
183+
def sent(
184+
self,
185+
ref: str,
186+
*,
187+
limit: int = 50,
188+
offset: int = 0,
189+
) -> dict:
190+
"""List messages sent by this agent."""
191+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
192+
return self._client._get(f"/v1/agents/{ref}/sent", params=params)

0 commit comments

Comments
 (0)