onceonly-sdk 2.0.1__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
onceonly/client.py CHANGED
@@ -16,6 +16,7 @@ from ._http import (
16
16
  )
17
17
  from .exceptions import ApiError, UnauthorizedError, OverLimitError, RateLimitError, ValidationError
18
18
  from .ai import AiClient
19
+ from .governance import GovernanceClient
19
20
  from .version import __version__
20
21
  from ._util import to_metadata_dict, MetadataLike
21
22
 
@@ -88,6 +89,14 @@ class OnceOnly:
88
89
  retry_max_backoff=self._retry_max_backoff,
89
90
  )
90
91
 
92
+ self.gov = GovernanceClient(
93
+ self._sync_client,
94
+ self._get_async_client,
95
+ max_retries_429=self._max_retries_429,
96
+ retry_backoff=self._retry_backoff,
97
+ retry_max_backoff=self._retry_max_backoff,
98
+ )
99
+
91
100
  # ---------- Public API ----------
92
101
 
93
102
  def check_lock(
@@ -150,10 +159,10 @@ class OnceOnly:
150
159
  raise
151
160
 
152
161
  # thin wrapper for agent UX
153
- def ai_run_and_wait(self, key: str, **kwargs):
162
+ def ai_run_and_wait(self, key: Optional[str] = None, **kwargs):
154
163
  return self.ai.run_and_wait(key=key, **kwargs)
155
164
 
156
- async def ai_run_and_wait_async(self, key: str, **kwargs):
165
+ async def ai_run_and_wait_async(self, key: Optional[str] = None, **kwargs):
157
166
  return await self.ai.run_and_wait_async(key=key, **kwargs)
158
167
 
159
168
  def me(self) -> Dict[str, Any]:
@@ -194,6 +203,63 @@ class OnceOnly:
194
203
  )
195
204
  return parse_json_or_raise(resp)
196
205
 
206
+ def usage_all(self) -> Dict[str, Any]:
207
+ resp = request_with_retries_sync(
208
+ lambda: self._sync_client.get("/usage/all"),
209
+ max_retries=self._max_retries_429,
210
+ base_backoff=self._retry_backoff,
211
+ max_backoff=self._retry_max_backoff,
212
+ )
213
+ return parse_json_or_raise(resp)
214
+
215
+ async def usage_all_async(self) -> Dict[str, Any]:
216
+ client = await self._get_async_client()
217
+ resp = await request_with_retries_async(
218
+ lambda: client.get("/usage/all"),
219
+ max_retries=self._max_retries_429,
220
+ base_backoff=self._retry_backoff,
221
+ max_backoff=self._retry_max_backoff,
222
+ )
223
+ return parse_json_or_raise(resp)
224
+
225
+ def events(self, limit: int = 50) -> Any:
226
+ resp = request_with_retries_sync(
227
+ lambda: self._sync_client.get("/events", params={"limit": int(limit)}),
228
+ max_retries=self._max_retries_429,
229
+ base_backoff=self._retry_backoff,
230
+ max_backoff=self._retry_max_backoff,
231
+ )
232
+ return parse_json_or_raise(resp)
233
+
234
+ async def events_async(self, limit: int = 50) -> Any:
235
+ client = await self._get_async_client()
236
+ resp = await request_with_retries_async(
237
+ lambda: client.get("/events", params={"limit": int(limit)}),
238
+ max_retries=self._max_retries_429,
239
+ base_backoff=self._retry_backoff,
240
+ max_backoff=self._retry_max_backoff,
241
+ )
242
+ return parse_json_or_raise(resp)
243
+
244
+ def metrics(self, from_day: str, to_day: str) -> Any:
245
+ resp = request_with_retries_sync(
246
+ lambda: self._sync_client.get("/metrics", params={"from_day": from_day, "to_day": to_day}),
247
+ max_retries=self._max_retries_429,
248
+ base_backoff=self._retry_backoff,
249
+ max_backoff=self._retry_max_backoff,
250
+ )
251
+ return parse_json_or_raise(resp)
252
+
253
+ async def metrics_async(self, from_day: str, to_day: str) -> Any:
254
+ client = await self._get_async_client()
255
+ resp = await request_with_retries_async(
256
+ lambda: client.get("/metrics", params={"from_day": from_day, "to_day": to_day}),
257
+ max_retries=self._max_retries_429,
258
+ base_backoff=self._retry_backoff,
259
+ max_backoff=self._retry_max_backoff,
260
+ )
261
+ return parse_json_or_raise(resp)
262
+
197
263
  def close(self) -> None:
198
264
  if self._own_sync:
199
265
  self._sync_client.close()
@@ -321,8 +387,15 @@ class OnceOnly:
321
387
  status = str(data.get("status") or "").strip().lower()
322
388
  success = data.get("success")
323
389
 
324
- locked = (oo_status == "locked") or (status == "locked") or (success is True)
325
- duplicate = (oo_status == "duplicate") or (status == "duplicate") or (success is False)
390
+ if status in ("locked", "duplicate"):
391
+ locked = status == "locked"
392
+ duplicate = status == "duplicate"
393
+ elif oo_status in ("locked", "duplicate"):
394
+ locked = oo_status == "locked"
395
+ duplicate = oo_status == "duplicate"
396
+ else:
397
+ locked = bool(success)
398
+ duplicate = not bool(success)
326
399
 
327
400
  raw = data if isinstance(data, dict) else {}
328
401
  md = to_metadata_dict(fallback_meta)
onceonly/decorators.py CHANGED
@@ -5,13 +5,17 @@ import inspect
5
5
  import json
6
6
  import hashlib
7
7
  import logging
8
- from typing import Any, Callable, Optional, TypeVar
8
+ from typing import Any, Callable, Optional, TypeVar, Awaitable, Union
9
9
 
10
10
  from .client import OnceOnly
11
+ from ._util import MetadataLike
12
+ from .ai_models import AiResult
11
13
 
12
14
  logger = logging.getLogger("onceonly")
13
15
 
14
16
  T = TypeVar("T")
17
+ KeyFn = Callable[..., str]
18
+ MetaFn = Callable[..., Optional[MetadataLike]]
15
19
 
16
20
 
17
21
  def _truncate(s: str, max_len: int = 2048) -> str:
@@ -172,3 +176,85 @@ def idempotent(
172
176
  return sync_wrapper # type: ignore[return-value]
173
177
 
174
178
  return decorator
179
+
180
+ def idempotent_ai(
181
+ client: OnceOnly,
182
+ *,
183
+ key: Optional[str] = None,
184
+ key_fn: Optional[KeyFn] = None,
185
+ ttl: int = 300,
186
+ metadata: Optional[MetadataLike] = None,
187
+ metadata_fn: Optional[MetaFn] = None,
188
+ extend_every: float = 30.0,
189
+ wait_on_conflict: bool = True,
190
+ timeout: float = 60.0,
191
+ poll_min: float = 0.5,
192
+ poll_max: float = 5.0,
193
+ error_code: str = "fn_error",
194
+ ) -> Callable[[Callable[..., Any]], Callable[..., Union[AiResult, Awaitable[AiResult]]]]:
195
+ """
196
+ AI Lease decorator (exactly-once local execution).
197
+
198
+ Provide either:
199
+ - key="fixed:key"
200
+ - key_fn(*args, **kwargs) -> str
201
+ """
202
+
203
+ if key is None and key_fn is None:
204
+ raise ValueError("idempotent_ai requires either key=... or key_fn=...")
205
+
206
+ def _make_key(args: tuple, kwargs: dict) -> str:
207
+ if key is not None:
208
+ return str(key)
209
+ assert key_fn is not None
210
+ return str(key_fn(*args, **kwargs))
211
+
212
+ def _make_meta(args: tuple, kwargs: dict) -> Optional[MetadataLike]:
213
+ if metadata_fn is not None:
214
+ return metadata_fn(*args, **kwargs)
215
+ return metadata
216
+
217
+ def decorator(fn: Callable[..., Any]):
218
+ if inspect.iscoroutinefunction(fn):
219
+
220
+ @functools.wraps(fn)
221
+ async def async_wrapper(*args: Any, **kwargs: Any) -> AiResult:
222
+ k = _make_key(args, kwargs)
223
+ md = _make_meta(args, kwargs)
224
+
225
+ return await client.ai.run_fn_async(
226
+ key=k,
227
+ fn=lambda: fn(*args, **kwargs), # returns awaitable; run_fn_async awaits it
228
+ ttl=int(ttl),
229
+ metadata=md,
230
+ extend_every=float(extend_every),
231
+ wait_on_conflict=bool(wait_on_conflict),
232
+ timeout=float(timeout),
233
+ poll_min=float(poll_min),
234
+ poll_max=float(poll_max),
235
+ error_code=str(error_code),
236
+ )
237
+
238
+ return async_wrapper
239
+
240
+ @functools.wraps(fn)
241
+ def sync_wrapper(*args: Any, **kwargs: Any) -> AiResult:
242
+ k = _make_key(args, kwargs)
243
+ md = _make_meta(args, kwargs)
244
+
245
+ return client.ai.run_fn(
246
+ key=k,
247
+ fn=lambda: fn(*args, **kwargs),
248
+ ttl=int(ttl),
249
+ metadata=md,
250
+ extend_every=float(extend_every),
251
+ wait_on_conflict=bool(wait_on_conflict),
252
+ timeout=float(timeout),
253
+ poll_min=float(poll_min),
254
+ poll_max=float(poll_max),
255
+ error_code=str(error_code),
256
+ )
257
+
258
+ return sync_wrapper
259
+
260
+ return decorator
onceonly/governance.py ADDED
@@ -0,0 +1,471 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Literal
4
+
5
+ import httpx
6
+
7
+ from ._http import parse_json_or_raise, request_with_retries_sync, request_with_retries_async
8
+ from .models import Policy, AgentStatus, AgentLogItem, AgentMetrics
9
+
10
+
11
+ class GovernanceClient:
12
+ """
13
+ Agent Governance API:
14
+ - Policies (limits/permissions)
15
+ - Kill switch (enable/disable)
16
+ - Audit logs + metrics
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ sync_client: httpx.Client,
22
+ async_client_getter: Callable[[], Awaitable[httpx.AsyncClient]],
23
+ *,
24
+ max_retries_429: int = 0,
25
+ retry_backoff: float = 0.5,
26
+ retry_max_backoff: float = 5.0,
27
+ ):
28
+ self._c = sync_client
29
+ self._get_ac = async_client_getter
30
+ self._max_retries_429 = int(max_retries_429)
31
+ self._retry_backoff = float(retry_backoff)
32
+ self._retry_max_backoff = float(retry_max_backoff)
33
+
34
+ # -------- Policies --------
35
+
36
+ def _policy_from_response(
37
+ self,
38
+ j: Any,
39
+ *,
40
+ fallback_agent_id: Optional[str] = None,
41
+ fallback_policy: Optional[Dict[str, Any]] = None,
42
+ ) -> Policy:
43
+ if isinstance(j, dict):
44
+ agent_id = str(j.get("agent_id") or fallback_agent_id or "")
45
+ pol = j.get("policy") if isinstance(j.get("policy"), dict) else (fallback_policy or j)
46
+ else:
47
+ agent_id = str(fallback_agent_id or "")
48
+ pol = fallback_policy or {}
49
+
50
+ if not isinstance(pol, dict):
51
+ pol = {}
52
+
53
+ return Policy(
54
+ agent_id=agent_id,
55
+ policy=pol,
56
+ max_actions_per_hour=pol.get("max_actions_per_hour"),
57
+ max_spend_usd_per_day=pol.get("max_spend_usd_per_day"),
58
+ allowed_tools=pol.get("allowed_tools"),
59
+ blocked_tools=pol.get("blocked_tools"),
60
+ max_calls_per_tool=pol.get("max_calls_per_tool"),
61
+ pricing_rules=pol.get("pricing_rules"),
62
+ raw=j if isinstance(j, dict) else None,
63
+ )
64
+
65
+ @staticmethod
66
+ def _extract_list(value: Any) -> List[Any]:
67
+ if isinstance(value, list):
68
+ return value
69
+ if isinstance(value, dict):
70
+ items = value.get("items")
71
+ if isinstance(items, list):
72
+ return items
73
+ data = value.get("data")
74
+ if isinstance(data, list):
75
+ return data
76
+ return []
77
+
78
+ def upsert_policy(self, policy: Dict[str, Any], *, agent_id: Optional[str] = None) -> Policy:
79
+ agent_id = agent_id or str(policy.get("agent_id") or "")
80
+ if not agent_id:
81
+ raise ValueError("upsert_policy requires agent_id")
82
+
83
+ payload = dict(policy)
84
+ payload["agent_id"] = agent_id
85
+
86
+ data = request_with_retries_sync(
87
+ lambda: self._c.post(f"/policies/{agent_id}", json=payload),
88
+ max_retries=self._max_retries_429,
89
+ base_backoff=self._retry_backoff,
90
+ max_backoff=self._retry_max_backoff,
91
+ )
92
+ j = parse_json_or_raise(data)
93
+ return self._policy_from_response(j, fallback_agent_id=agent_id, fallback_policy=payload)
94
+
95
+ async def upsert_policy_async(self, policy: Dict[str, Any], *, agent_id: Optional[str] = None) -> Policy:
96
+ agent_id = agent_id or str(policy.get("agent_id") or "")
97
+ if not agent_id:
98
+ raise ValueError("upsert_policy_async requires agent_id")
99
+
100
+ payload = dict(policy)
101
+ payload["agent_id"] = agent_id
102
+
103
+ ac = await self._get_ac()
104
+ resp = await request_with_retries_async(
105
+ lambda: ac.post(f"/policies/{agent_id}", json=payload),
106
+ max_retries=self._max_retries_429,
107
+ base_backoff=self._retry_backoff,
108
+ max_backoff=self._retry_max_backoff,
109
+ )
110
+ j = parse_json_or_raise(resp)
111
+ return self._policy_from_response(j, fallback_agent_id=agent_id, fallback_policy=payload)
112
+
113
+ def policy_from_template(self, agent_id: str, template: str, overrides: Optional[Dict[str, Any]] = None) -> Policy:
114
+ payload = {"agent_id": agent_id, "template": template, "overrides": overrides or {}}
115
+ data = request_with_retries_sync(
116
+ lambda: self._c.post(f"/policies/{agent_id}/from-template", json=payload),
117
+ max_retries=self._max_retries_429,
118
+ base_backoff=self._retry_backoff,
119
+ max_backoff=self._retry_max_backoff,
120
+ )
121
+ j = parse_json_or_raise(data)
122
+ return self._policy_from_response(j, fallback_agent_id=agent_id, fallback_policy=payload)
123
+
124
+ async def policy_from_template_async(
125
+ self, agent_id: str, template: str, overrides: Optional[Dict[str, Any]] = None
126
+ ) -> Policy:
127
+ payload = {"agent_id": agent_id, "template": template, "overrides": overrides or {}}
128
+ ac = await self._get_ac()
129
+ resp = await request_with_retries_async(
130
+ lambda: ac.post(f"/policies/{agent_id}/from-template", json=payload),
131
+ max_retries=self._max_retries_429,
132
+ base_backoff=self._retry_backoff,
133
+ max_backoff=self._retry_max_backoff,
134
+ )
135
+ j = parse_json_or_raise(resp)
136
+ return self._policy_from_response(j, fallback_agent_id=agent_id, fallback_policy=payload)
137
+
138
+ def list_policies(self) -> List[Policy]:
139
+ data = request_with_retries_sync(
140
+ lambda: self._c.get("/policies"),
141
+ max_retries=self._max_retries_429,
142
+ base_backoff=self._retry_backoff,
143
+ max_backoff=self._retry_max_backoff,
144
+ )
145
+ j = parse_json_or_raise(data)
146
+ items = self._extract_list(j)
147
+ out: List[Policy] = []
148
+ for it in items or []:
149
+ out.append(self._policy_from_response(it))
150
+ return out
151
+
152
+ async def list_policies_async(self) -> List[Policy]:
153
+ ac = await self._get_ac()
154
+ resp = await request_with_retries_async(
155
+ lambda: ac.get("/policies"),
156
+ max_retries=self._max_retries_429,
157
+ base_backoff=self._retry_backoff,
158
+ max_backoff=self._retry_max_backoff,
159
+ )
160
+ j = parse_json_or_raise(resp)
161
+ items = self._extract_list(j)
162
+ out: List[Policy] = []
163
+ for it in items or []:
164
+ out.append(self._policy_from_response(it))
165
+ return out
166
+
167
+ def get_policy(self, agent_id: str) -> Policy:
168
+ data = request_with_retries_sync(
169
+ lambda: self._c.get(f"/policies/{agent_id}"),
170
+ max_retries=self._max_retries_429,
171
+ base_backoff=self._retry_backoff,
172
+ max_backoff=self._retry_max_backoff,
173
+ )
174
+ j = parse_json_or_raise(data)
175
+ return self._policy_from_response(j, fallback_agent_id=agent_id)
176
+
177
+ async def get_policy_async(self, agent_id: str) -> Policy:
178
+ ac = await self._get_ac()
179
+ resp = await request_with_retries_async(
180
+ lambda: ac.get(f"/policies/{agent_id}"),
181
+ max_retries=self._max_retries_429,
182
+ base_backoff=self._retry_backoff,
183
+ max_backoff=self._retry_max_backoff,
184
+ )
185
+ j = parse_json_or_raise(resp)
186
+ return self._policy_from_response(j, fallback_agent_id=agent_id)
187
+
188
+ # -------- Tools Registry --------
189
+
190
+ def create_tool(self, tool: Dict[str, Any]) -> Dict[str, Any]:
191
+ data = request_with_retries_sync(
192
+ lambda: self._c.post("/tools", json=tool),
193
+ max_retries=self._max_retries_429,
194
+ base_backoff=self._retry_backoff,
195
+ max_backoff=self._retry_max_backoff,
196
+ )
197
+ return parse_json_or_raise(data)
198
+
199
+ async def create_tool_async(self, tool: Dict[str, Any]) -> Dict[str, Any]:
200
+ ac = await self._get_ac()
201
+ resp = await request_with_retries_async(
202
+ lambda: ac.post("/tools", json=tool),
203
+ max_retries=self._max_retries_429,
204
+ base_backoff=self._retry_backoff,
205
+ max_backoff=self._retry_max_backoff,
206
+ )
207
+ return parse_json_or_raise(resp)
208
+
209
+ def list_tools(self, scope_id: str = "global") -> List[Dict[str, Any]]:
210
+ data = request_with_retries_sync(
211
+ lambda: self._c.get("/tools", params={"scope_id": scope_id}),
212
+ max_retries=self._max_retries_429,
213
+ base_backoff=self._retry_backoff,
214
+ max_backoff=self._retry_max_backoff,
215
+ )
216
+ j = parse_json_or_raise(data)
217
+ return self._extract_list(j)
218
+
219
+ async def list_tools_async(self, scope_id: str = "global") -> List[Dict[str, Any]]:
220
+ ac = await self._get_ac()
221
+ resp = await request_with_retries_async(
222
+ lambda: ac.get("/tools", params={"scope_id": scope_id}),
223
+ max_retries=self._max_retries_429,
224
+ base_backoff=self._retry_backoff,
225
+ max_backoff=self._retry_max_backoff,
226
+ )
227
+ j = parse_json_or_raise(resp)
228
+ return self._extract_list(j)
229
+
230
+ def get_tool(self, name: str, scope_id: str = "global") -> Dict[str, Any]:
231
+ data = request_with_retries_sync(
232
+ lambda: self._c.get(f"/tools/{name}", params={"scope_id": scope_id}),
233
+ max_retries=self._max_retries_429,
234
+ base_backoff=self._retry_backoff,
235
+ max_backoff=self._retry_max_backoff,
236
+ )
237
+ return parse_json_or_raise(data)
238
+
239
+ async def get_tool_async(self, name: str, scope_id: str = "global") -> Dict[str, Any]:
240
+ ac = await self._get_ac()
241
+ resp = await request_with_retries_async(
242
+ lambda: ac.get(f"/tools/{name}", params={"scope_id": scope_id}),
243
+ max_retries=self._max_retries_429,
244
+ base_backoff=self._retry_backoff,
245
+ max_backoff=self._retry_max_backoff,
246
+ )
247
+ return parse_json_or_raise(resp)
248
+
249
+ def toggle_tool(self, name: str, *, enabled: bool, scope_id: str = "global") -> Dict[str, Any]:
250
+ data = request_with_retries_sync(
251
+ lambda: self._c.post(
252
+ f"/tools/{name}/toggle",
253
+ params={"scope_id": scope_id},
254
+ json={"enabled": bool(enabled)},
255
+ ),
256
+ max_retries=self._max_retries_429,
257
+ base_backoff=self._retry_backoff,
258
+ max_backoff=self._retry_max_backoff,
259
+ )
260
+ return parse_json_or_raise(data)
261
+
262
+ async def toggle_tool_async(self, name: str, *, enabled: bool, scope_id: str = "global") -> Dict[str, Any]:
263
+ ac = await self._get_ac()
264
+ resp = await request_with_retries_async(
265
+ lambda: ac.post(
266
+ f"/tools/{name}/toggle",
267
+ params={"scope_id": scope_id},
268
+ json={"enabled": bool(enabled)},
269
+ ),
270
+ max_retries=self._max_retries_429,
271
+ base_backoff=self._retry_backoff,
272
+ max_backoff=self._retry_max_backoff,
273
+ )
274
+ return parse_json_or_raise(resp)
275
+
276
+ def delete_tool(self, name: str, scope_id: str = "global") -> Dict[str, Any]:
277
+ data = request_with_retries_sync(
278
+ lambda: self._c.delete(f"/tools/{name}", params={"scope_id": scope_id}),
279
+ max_retries=self._max_retries_429,
280
+ base_backoff=self._retry_backoff,
281
+ max_backoff=self._retry_max_backoff,
282
+ )
283
+ return parse_json_or_raise(data)
284
+
285
+ async def delete_tool_async(self, name: str, scope_id: str = "global") -> Dict[str, Any]:
286
+ ac = await self._get_ac()
287
+ resp = await request_with_retries_async(
288
+ lambda: ac.delete(f"/tools/{name}", params={"scope_id": scope_id}),
289
+ max_retries=self._max_retries_429,
290
+ base_backoff=self._retry_backoff,
291
+ max_backoff=self._retry_max_backoff,
292
+ )
293
+ return parse_json_or_raise(resp)
294
+
295
+ # -------- Kill switch --------
296
+
297
+ def disable_agent(self, agent_id: str, reason: str = "") -> AgentStatus:
298
+ payload = {"reason": reason} if reason else {}
299
+ data = request_with_retries_sync(
300
+ lambda: self._c.post(f"/agents/{agent_id}/disable", json=payload),
301
+ max_retries=self._max_retries_429,
302
+ base_backoff=self._retry_backoff,
303
+ max_backoff=self._retry_max_backoff,
304
+ )
305
+ j = parse_json_or_raise(data)
306
+ return AgentStatus(
307
+ agent_id=str(j.get("agent_id") or agent_id),
308
+ is_enabled=bool(j.get("is_enabled", j.get("enabled"))),
309
+ disabled_reason=j.get("disabled_reason"),
310
+ disabled_at=j.get("disabled_at"),
311
+ raw=j,
312
+ )
313
+
314
+ async def disable_agent_async(self, agent_id: str, reason: str = "") -> AgentStatus:
315
+ payload = {"reason": reason} if reason else {}
316
+ ac = await self._get_ac()
317
+ resp = await request_with_retries_async(
318
+ lambda: ac.post(f"/agents/{agent_id}/disable", json=payload),
319
+ max_retries=self._max_retries_429,
320
+ base_backoff=self._retry_backoff,
321
+ max_backoff=self._retry_max_backoff,
322
+ )
323
+ j = parse_json_or_raise(resp)
324
+ return AgentStatus(
325
+ agent_id=str(j.get("agent_id") or agent_id),
326
+ is_enabled=bool(j.get("is_enabled", j.get("enabled"))),
327
+ disabled_reason=j.get("disabled_reason"),
328
+ disabled_at=j.get("disabled_at"),
329
+ raw=j,
330
+ )
331
+
332
+ def enable_agent(self, agent_id: str, reason: str = "") -> AgentStatus:
333
+ payload = {"reason": reason} if reason else {}
334
+ data = request_with_retries_sync(
335
+ lambda: self._c.post(f"/agents/{agent_id}/enable", json=payload),
336
+ max_retries=self._max_retries_429,
337
+ base_backoff=self._retry_backoff,
338
+ max_backoff=self._retry_max_backoff,
339
+ )
340
+ j = parse_json_or_raise(data)
341
+ return AgentStatus(
342
+ agent_id=str(j.get("agent_id") or agent_id),
343
+ is_enabled=bool(j.get("is_enabled", j.get("enabled"))),
344
+ disabled_reason=j.get("disabled_reason"),
345
+ disabled_at=j.get("disabled_at"),
346
+ raw=j,
347
+ )
348
+
349
+ async def enable_agent_async(self, agent_id: str, reason: str = "") -> AgentStatus:
350
+ payload = {"reason": reason} if reason else {}
351
+ ac = await self._get_ac()
352
+ resp = await request_with_retries_async(
353
+ lambda: ac.post(f"/agents/{agent_id}/enable", json=payload),
354
+ max_retries=self._max_retries_429,
355
+ base_backoff=self._retry_backoff,
356
+ max_backoff=self._retry_max_backoff,
357
+ )
358
+ j = parse_json_or_raise(resp)
359
+ return AgentStatus(
360
+ agent_id=str(j.get("agent_id") or agent_id),
361
+ is_enabled=bool(j.get("is_enabled", j.get("enabled"))),
362
+ disabled_reason=j.get("disabled_reason"),
363
+ disabled_at=j.get("disabled_at"),
364
+ raw=j,
365
+ )
366
+
367
+ # -------- Audit logs + metrics --------
368
+
369
+ def agent_logs(self, agent_id: str, limit: int = 100) -> List[AgentLogItem]:
370
+ data = request_with_retries_sync(
371
+ lambda: self._c.get(f"/agents/{agent_id}/logs", params={"limit": int(limit)}),
372
+ max_retries=self._max_retries_429,
373
+ base_backoff=self._retry_backoff,
374
+ max_backoff=self._retry_max_backoff,
375
+ )
376
+ j = parse_json_or_raise(data)
377
+ items = self._extract_list(j)
378
+ out: List[AgentLogItem] = []
379
+ for it in items or []:
380
+ if not isinstance(it, dict):
381
+ continue
382
+ allowed = bool(it.get("allowed", True))
383
+ decision = str(it.get("decision") or ("blocked" if not allowed else "allowed"))
384
+ policy_reason = it.get("policy_reason") or it.get("reason")
385
+ out.append(
386
+ AgentLogItem(
387
+ ts=it.get("ts"),
388
+ agent_id=str(it.get("agent_id") or agent_id),
389
+ tool=it.get("tool"),
390
+ allowed=allowed,
391
+ decision=decision,
392
+ policy_reason=policy_reason,
393
+ reason=str(it.get("reason") or policy_reason or ""),
394
+ args_hash=it.get("args_hash"),
395
+ risk_level=it.get("risk_level"),
396
+ spend_usd=float(it.get("spend_usd") or 0),
397
+ raw=it,
398
+ )
399
+ )
400
+ return out
401
+
402
+ async def agent_logs_async(self, agent_id: str, limit: int = 100) -> List[AgentLogItem]:
403
+ ac = await self._get_ac()
404
+ resp = await request_with_retries_async(
405
+ lambda: ac.get(f"/agents/{agent_id}/logs", params={"limit": int(limit)}),
406
+ max_retries=self._max_retries_429,
407
+ base_backoff=self._retry_backoff,
408
+ max_backoff=self._retry_max_backoff,
409
+ )
410
+ j = parse_json_or_raise(resp)
411
+ items = self._extract_list(j)
412
+ out: List[AgentLogItem] = []
413
+ for it in items or []:
414
+ if not isinstance(it, dict):
415
+ continue
416
+ allowed = bool(it.get("allowed", True))
417
+ decision = str(it.get("decision") or ("blocked" if not allowed else "allowed"))
418
+ policy_reason = it.get("policy_reason") or it.get("reason")
419
+ out.append(
420
+ AgentLogItem(
421
+ ts=it.get("ts"),
422
+ agent_id=str(it.get("agent_id") or agent_id),
423
+ tool=it.get("tool"),
424
+ allowed=allowed,
425
+ decision=decision,
426
+ policy_reason=policy_reason,
427
+ reason=str(it.get("reason") or policy_reason or ""),
428
+ args_hash=it.get("args_hash"),
429
+ risk_level=it.get("risk_level"),
430
+ spend_usd=float(it.get("spend_usd") or 0),
431
+ raw=it,
432
+ )
433
+ )
434
+ return out
435
+
436
+ def agent_metrics(self, agent_id: str, period: Literal["hour", "day", "week"] = "day") -> AgentMetrics:
437
+ data = request_with_retries_sync(
438
+ lambda: self._c.get(f"/agents/{agent_id}/metrics", params={"period": period}),
439
+ max_retries=self._max_retries_429,
440
+ base_backoff=self._retry_backoff,
441
+ max_backoff=self._retry_max_backoff,
442
+ )
443
+ j = parse_json_or_raise(data)
444
+ return AgentMetrics(
445
+ agent_id=str(j.get("agent_id") or agent_id),
446
+ period=str(j.get("period") or period), # type: ignore[arg-type]
447
+ total_actions=int(j.get("total_actions") or 0),
448
+ blocked_actions=int(j.get("blocked_actions") or 0),
449
+ total_spend_usd=float(j.get("total_spend_usd") or 0),
450
+ top_tools=list(j.get("top_tools") or []),
451
+ raw=j if isinstance(j, dict) else None,
452
+ )
453
+
454
+ async def agent_metrics_async(self, agent_id: str, period: Literal["hour", "day", "week"] = "day") -> AgentMetrics:
455
+ ac = await self._get_ac()
456
+ resp = await request_with_retries_async(
457
+ lambda: ac.get(f"/agents/{agent_id}/metrics", params={"period": period}),
458
+ max_retries=self._max_retries_429,
459
+ base_backoff=self._retry_backoff,
460
+ max_backoff=self._retry_max_backoff,
461
+ )
462
+ j = parse_json_or_raise(resp)
463
+ return AgentMetrics(
464
+ agent_id=str(j.get("agent_id") or agent_id),
465
+ period=str(j.get("period") or period), # type: ignore[arg-type]
466
+ total_actions=int(j.get("total_actions") or 0),
467
+ blocked_actions=int(j.get("blocked_actions") or 0),
468
+ total_spend_usd=float(j.get("total_spend_usd") or 0),
469
+ top_tools=list(j.get("top_tools") or []),
470
+ raw=j if isinstance(j, dict) else None,
471
+ )