copilotkit-intelligence-runtime 0.1.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.
- copilotkit_intelligence/__init__.py +62 -0
- copilotkit_intelligence/client.py +805 -0
- copilotkit_intelligence/entitlements.py +142 -0
- copilotkit_intelligence/inspector.py +182 -0
- copilotkit_intelligence/learned_skills.py +98 -0
- copilotkit_intelligence/py.typed +0 -0
- copilotkit_intelligence/resources.py +134 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/METADATA +403 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/RECORD +22 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/WHEEL +4 -0
- copilotkit_intelligence_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
- copilotkit_runtime/__init__.py +27 -0
- copilotkit_runtime/a2ui.py +559 -0
- copilotkit_runtime/agents.py +64 -0
- copilotkit_runtime/finalizer.py +75 -0
- copilotkit_runtime/gateway.py +287 -0
- copilotkit_runtime/mcp_apps.py +299 -0
- copilotkit_runtime/models.py +77 -0
- copilotkit_runtime/platform.py +67 -0
- copilotkit_runtime/py.typed +0 -0
- copilotkit_runtime/runtime.py +878 -0
- copilotkit_runtime/telemetry.py +263 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
"""Runtime-independent, asynchronous Intelligence API client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import AsyncIterator, Callable
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from time import monotonic as _entitlement_now
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any, Literal, Self, cast
|
|
14
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .entitlements import RuntimeEntitlementResponse, normalize_runtime_entitlements
|
|
20
|
+
from .inspector import InspectorMetadata, parse_inspector_metadata
|
|
21
|
+
from .learned_skills import (
|
|
22
|
+
LearnedSkillsError,
|
|
23
|
+
LearnedSkillsSnapshotResult,
|
|
24
|
+
)
|
|
25
|
+
from .learned_skills import (
|
|
26
|
+
response_error as learned_skills_response_error,
|
|
27
|
+
)
|
|
28
|
+
from .resources import (
|
|
29
|
+
AnnotateResponse,
|
|
30
|
+
ListMemoriesResponse,
|
|
31
|
+
ListThreadsResponse,
|
|
32
|
+
RecallMemoriesResponse,
|
|
33
|
+
SaveMemoryResponse,
|
|
34
|
+
ThreadEventsResponse,
|
|
35
|
+
ThreadMessagesResponse,
|
|
36
|
+
ThreadResolution,
|
|
37
|
+
ThreadStateResponse,
|
|
38
|
+
ThreadSummary,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
Json = dict[str, Any]
|
|
42
|
+
Access = Literal["none", "read", "read-write"]
|
|
43
|
+
ThreadListener = Callable[[Json], None]
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
_INSPECTOR_METADATA_TIMEOUT = 5.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class IntelligenceError(Exception):
|
|
49
|
+
"""A safe platform failure with its HTTP status and no response-body disclosure."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, status: int, message: str) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.status = status
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RuntimeEntitlementError(IntelligenceError):
|
|
57
|
+
"""A safe entitlement request failure with HTTP status and retry guidance."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, status: int, message: str, retryable: bool) -> None:
|
|
60
|
+
super().__init__(status, message)
|
|
61
|
+
self.retryable = retryable
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class MemoryGrant:
|
|
66
|
+
"""Trusted application limits for user and project memories."""
|
|
67
|
+
|
|
68
|
+
user: Access
|
|
69
|
+
project: Access
|
|
70
|
+
|
|
71
|
+
def __post_init__(self) -> None:
|
|
72
|
+
"""Reject unknown permissions instead of delegating an invalid grant."""
|
|
73
|
+
if self.user not in ("none", "read", "read-write") or self.project not in (
|
|
74
|
+
"none",
|
|
75
|
+
"read",
|
|
76
|
+
"read-write",
|
|
77
|
+
):
|
|
78
|
+
raise ValueError("Invalid memory grant")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def segment(value: str) -> str:
|
|
82
|
+
"""Encode a nonempty opaque identifier as one URL path segment."""
|
|
83
|
+
if not isinstance(value, str) or not value.strip():
|
|
84
|
+
raise ValueError("A nonempty identifier is required")
|
|
85
|
+
return quote(value, safe="")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _LearnedSkillsStream(httpx.AsyncByteStream):
|
|
89
|
+
"""Keep response cleanup alive independently of delivery cancellation."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, stream: httpx.AsyncByteStream, pending: set[asyncio.Task[None]]) -> None:
|
|
92
|
+
self._stream = stream
|
|
93
|
+
self._pending = pending
|
|
94
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
95
|
+
|
|
96
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
97
|
+
async for chunk in self._stream:
|
|
98
|
+
yield chunk
|
|
99
|
+
|
|
100
|
+
def close(self) -> asyncio.Task[None]:
|
|
101
|
+
if self._close_task is None:
|
|
102
|
+
self._close_task = asyncio.create_task(self._stream.aclose())
|
|
103
|
+
self._pending.add(self._close_task)
|
|
104
|
+
self._close_task.add_done_callback(self._finished)
|
|
105
|
+
return self._close_task
|
|
106
|
+
|
|
107
|
+
def _finished(self, task: asyncio.Task[None]) -> None:
|
|
108
|
+
self._pending.discard(task)
|
|
109
|
+
if not task.cancelled():
|
|
110
|
+
task.exception() # Observe cleanup failures without disclosing content.
|
|
111
|
+
|
|
112
|
+
async def aclose(self) -> None:
|
|
113
|
+
await asyncio.shield(self.close())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Intelligence:
|
|
117
|
+
"""Call Intelligence from scripts, workers, or a Runtime with one pooled client.
|
|
118
|
+
|
|
119
|
+
The client has no ASGI dependency or agent requirement.
|
|
120
|
+
Its async context manager closes only an HTTP client that it created.
|
|
121
|
+
Writes are never retried automatically. Cancellation propagates to httpx.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
api_key: str,
|
|
128
|
+
api_url: str = "https://api.intelligence.copilotkit.ai",
|
|
129
|
+
runner_url: str = "wss://realtime.intelligence.copilotkit.ai/runner",
|
|
130
|
+
client_url: str = "wss://realtime.intelligence.copilotkit.ai/client",
|
|
131
|
+
request_timeout: float = 30,
|
|
132
|
+
http_client: httpx.AsyncClient | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
if not api_key.strip():
|
|
135
|
+
raise ValueError("api_key is required")
|
|
136
|
+
for endpoint, schemes in (
|
|
137
|
+
(api_url, ("http", "https")),
|
|
138
|
+
(runner_url, ("ws", "wss")),
|
|
139
|
+
(client_url, ("ws", "wss")),
|
|
140
|
+
):
|
|
141
|
+
parsed = urlsplit(endpoint)
|
|
142
|
+
if (
|
|
143
|
+
parsed.scheme not in schemes
|
|
144
|
+
or not parsed.hostname
|
|
145
|
+
or parsed.username
|
|
146
|
+
or parsed.fragment
|
|
147
|
+
or parsed.query
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("Invalid Intelligence endpoint URL")
|
|
150
|
+
if not math.isfinite(request_timeout) or request_timeout <= 0:
|
|
151
|
+
raise ValueError("request_timeout must be positive and finite")
|
|
152
|
+
self.api_key = api_key
|
|
153
|
+
self.api_url = api_url.rstrip("/")
|
|
154
|
+
self.runner_url = runner_url
|
|
155
|
+
self.client_url = client_url
|
|
156
|
+
self.request_timeout = request_timeout
|
|
157
|
+
self.http_client = http_client or httpx.AsyncClient()
|
|
158
|
+
self._owns_http_client = http_client is None
|
|
159
|
+
self._learned_skills_cleanup: set[asyncio.Task[None]] = set()
|
|
160
|
+
self._entitlements_task: asyncio.Task[RuntimeEntitlementResponse] | None = None
|
|
161
|
+
self._entitlements_waiters = 0
|
|
162
|
+
self._entitlements_cache: (
|
|
163
|
+
tuple[float, RuntimeEntitlementResponse | RuntimeEntitlementError] | None
|
|
164
|
+
) = None
|
|
165
|
+
self._listeners: dict[str, list[ThreadListener]] = {
|
|
166
|
+
"created": [],
|
|
167
|
+
"updated": [],
|
|
168
|
+
"deleted": [],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async def __aenter__(self) -> Self:
|
|
172
|
+
"""Use this client within an asynchronous context."""
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def on_thread_created(self, callback: ThreadListener) -> Callable[[], None]:
|
|
176
|
+
"""Register a synchronous creation listener and return its unsubscribe function."""
|
|
177
|
+
return self._subscribe("created", callback)
|
|
178
|
+
|
|
179
|
+
def on_thread_updated(self, callback: ThreadListener) -> Callable[[], None]:
|
|
180
|
+
"""Register a synchronous update or archive listener."""
|
|
181
|
+
return self._subscribe("updated", callback)
|
|
182
|
+
|
|
183
|
+
def on_thread_deleted(self, callback: ThreadListener) -> Callable[[], None]:
|
|
184
|
+
"""Register a synchronous deletion listener with the explicit caller identity."""
|
|
185
|
+
return self._subscribe("deleted", callback)
|
|
186
|
+
|
|
187
|
+
def _subscribe(self, event: str, callback: ThreadListener) -> Callable[[], None]:
|
|
188
|
+
"""Keep registration order and make repeated unsubscribe calls harmless."""
|
|
189
|
+
if not callable(callback):
|
|
190
|
+
raise TypeError("A thread listener must be callable")
|
|
191
|
+
if not any(listener is callback for listener in self._listeners[event]):
|
|
192
|
+
self._listeners[event].append(callback)
|
|
193
|
+
|
|
194
|
+
def unsubscribe() -> None:
|
|
195
|
+
self._listeners[event] = [
|
|
196
|
+
listener for listener in self._listeners[event] if listener is not callback
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
return unsubscribe
|
|
200
|
+
|
|
201
|
+
def _notify_thread_mutation(
|
|
202
|
+
self, method: str, path: str, body: Json | None, result: Any
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Notify SDK and Runtime mutations once, excluding locks and subscriptions."""
|
|
205
|
+
prefix = "/api/threads/"
|
|
206
|
+
thread_path = path.startswith(prefix) and "/" not in path[len(prefix) :]
|
|
207
|
+
event = None
|
|
208
|
+
payload = None
|
|
209
|
+
if (method == "POST" and path == "/api/threads") or (method == "PATCH" and thread_path):
|
|
210
|
+
thread = result.get("thread") if isinstance(result, dict) else None
|
|
211
|
+
if isinstance(thread, dict) and isinstance(thread.get("id"), str):
|
|
212
|
+
event = "created" if method == "POST" else "updated"
|
|
213
|
+
payload = thread
|
|
214
|
+
elif method == "DELETE" and thread_path and body is not None:
|
|
215
|
+
if isinstance(body.get("userId"), str) and isinstance(body.get("agentId"), str):
|
|
216
|
+
event = "deleted"
|
|
217
|
+
payload = {
|
|
218
|
+
"threadId": unquote(path[len(prefix) :]),
|
|
219
|
+
"userId": body["userId"],
|
|
220
|
+
"agentId": body["agentId"],
|
|
221
|
+
}
|
|
222
|
+
if event is None or payload is None:
|
|
223
|
+
return
|
|
224
|
+
for callback in tuple(self._listeners[event]):
|
|
225
|
+
try:
|
|
226
|
+
callback(payload)
|
|
227
|
+
except Exception:
|
|
228
|
+
logger.exception("Intelligence thread %s listener failed", event)
|
|
229
|
+
|
|
230
|
+
async def __aexit__(
|
|
231
|
+
self,
|
|
232
|
+
exc_type: type[BaseException] | None,
|
|
233
|
+
exc: BaseException | None,
|
|
234
|
+
traceback: TracebackType | None,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Release the owned HTTP pool after the context exits."""
|
|
237
|
+
await self.aclose()
|
|
238
|
+
|
|
239
|
+
async def aclose(self) -> None:
|
|
240
|
+
"""Close the owned HTTP client; a supplied client stays usable."""
|
|
241
|
+
task = self._entitlements_task
|
|
242
|
+
if task is not None and not task.done():
|
|
243
|
+
task.cancel()
|
|
244
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
245
|
+
self._entitlements_cache = None
|
|
246
|
+
if self._learned_skills_cleanup:
|
|
247
|
+
await asyncio.shield(
|
|
248
|
+
asyncio.gather(*self._learned_skills_cleanup, return_exceptions=True)
|
|
249
|
+
)
|
|
250
|
+
if self._owns_http_client:
|
|
251
|
+
await self.http_client.aclose()
|
|
252
|
+
|
|
253
|
+
async def _request(
|
|
254
|
+
self,
|
|
255
|
+
method: str,
|
|
256
|
+
path: str,
|
|
257
|
+
body: Json | None = None,
|
|
258
|
+
query: Json | None = None,
|
|
259
|
+
headers: dict[str, str] | None = None,
|
|
260
|
+
) -> Any:
|
|
261
|
+
"""Share authenticated transport with Runtime without exposing browser routes."""
|
|
262
|
+
try:
|
|
263
|
+
response = await self.http_client.request(
|
|
264
|
+
method,
|
|
265
|
+
self.api_url + path,
|
|
266
|
+
json=body,
|
|
267
|
+
params=query,
|
|
268
|
+
headers={
|
|
269
|
+
**(headers or {}),
|
|
270
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
271
|
+
"Content-Type": "application/json",
|
|
272
|
+
},
|
|
273
|
+
timeout=self.request_timeout,
|
|
274
|
+
follow_redirects=False,
|
|
275
|
+
)
|
|
276
|
+
except httpx.HTTPError as error:
|
|
277
|
+
raise IntelligenceError(502, "Intelligence connection failed") from error
|
|
278
|
+
if not 200 <= response.status_code < 300:
|
|
279
|
+
raise IntelligenceError(response.status_code, "Intelligence request rejected")
|
|
280
|
+
result = None
|
|
281
|
+
if response.content:
|
|
282
|
+
try:
|
|
283
|
+
result = response.json()
|
|
284
|
+
except ValueError as error:
|
|
285
|
+
raise IntelligenceError(502, "Invalid Intelligence response") from error
|
|
286
|
+
self._notify_thread_mutation(method, path, body, result)
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
async def _object(
|
|
290
|
+
self,
|
|
291
|
+
method: str,
|
|
292
|
+
path: str,
|
|
293
|
+
body: Json | None = None,
|
|
294
|
+
query: Json | None = None,
|
|
295
|
+
headers: dict[str, str] | None = None,
|
|
296
|
+
) -> Json:
|
|
297
|
+
"""Reject empty or non-object responses before returning a resource."""
|
|
298
|
+
result = await self._request(method, path, body, query, headers)
|
|
299
|
+
if not isinstance(result, dict):
|
|
300
|
+
raise IntelligenceError(502, "Invalid Intelligence response")
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _memory_headers(user_id: str, grant: MemoryGrant | None) -> dict[str, str]:
|
|
305
|
+
"""Attribute memory operations to a bare customer user, not an API-key creator."""
|
|
306
|
+
segment(user_id)
|
|
307
|
+
headers = {"x-cpki-user-id": user_id}
|
|
308
|
+
if grant is not None:
|
|
309
|
+
if not isinstance(grant, MemoryGrant):
|
|
310
|
+
raise ValueError("memory_grant must be a MemoryGrant")
|
|
311
|
+
headers["x-cpki-memory-grant"] = json.dumps(
|
|
312
|
+
{"user": grant.user, "project": grant.project}, separators=(",", ":")
|
|
313
|
+
)
|
|
314
|
+
return headers
|
|
315
|
+
|
|
316
|
+
async def get_learned_skills_snapshot(
|
|
317
|
+
self,
|
|
318
|
+
*,
|
|
319
|
+
container_id: str,
|
|
320
|
+
revision: str | None = None,
|
|
321
|
+
if_none_match: str | None = None,
|
|
322
|
+
request_timeout: float | None = None,
|
|
323
|
+
) -> LearnedSkillsSnapshotResult:
|
|
324
|
+
"""Read raw ZIP bytes with this client's credentials and HTTP pool.
|
|
325
|
+
|
|
326
|
+
No retries, parsing, or cache. The client deadline includes the body
|
|
327
|
+
read. Native asyncio cancellation propagates and request timeouts use
|
|
328
|
+
TIMEOUT, except that a confirmed HTTP denial remains a denial.
|
|
329
|
+
request_timeout overrides the client default for this operation only.
|
|
330
|
+
"""
|
|
331
|
+
deadline = self.request_timeout if request_timeout is None else request_timeout
|
|
332
|
+
if (
|
|
333
|
+
type(deadline) not in (int, float)
|
|
334
|
+
or not math.isfinite(deadline)
|
|
335
|
+
or deadline <= 0
|
|
336
|
+
or not isinstance(container_id, str)
|
|
337
|
+
or not container_id.strip()
|
|
338
|
+
or (revision is not None and (not isinstance(revision, str) or not revision))
|
|
339
|
+
or (
|
|
340
|
+
if_none_match is not None
|
|
341
|
+
and (
|
|
342
|
+
not isinstance(if_none_match, str)
|
|
343
|
+
or not if_none_match
|
|
344
|
+
or "\r" in if_none_match
|
|
345
|
+
or "\n" in if_none_match
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
):
|
|
349
|
+
raise LearnedSkillsError("INVALID_CONFIG", False)
|
|
350
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/zip"}
|
|
351
|
+
if if_none_match is not None:
|
|
352
|
+
headers["If-None-Match"] = if_none_match
|
|
353
|
+
status: int | None = None
|
|
354
|
+
cleanup: _LearnedSkillsStream | None = None
|
|
355
|
+
try:
|
|
356
|
+
async with asyncio.timeout(deadline):
|
|
357
|
+
request = self.http_client.build_request(
|
|
358
|
+
"GET",
|
|
359
|
+
self.api_url
|
|
360
|
+
+ "/api/v1/learning/containers/"
|
|
361
|
+
+ segment(container_id)
|
|
362
|
+
+ "/skills",
|
|
363
|
+
params={"revision": revision} if revision is not None else None,
|
|
364
|
+
headers=headers,
|
|
365
|
+
timeout=deadline,
|
|
366
|
+
)
|
|
367
|
+
response = await self.http_client.send(request, stream=True, follow_redirects=False)
|
|
368
|
+
assert isinstance(response.stream, httpx.AsyncByteStream)
|
|
369
|
+
cleanup = _LearnedSkillsStream(response.stream, self._learned_skills_cleanup)
|
|
370
|
+
response.stream = cleanup
|
|
371
|
+
status = response.status_code
|
|
372
|
+
if status == 401:
|
|
373
|
+
raise LearnedSkillsError("AUTHENTICATION_FAILED", False)
|
|
374
|
+
if status not in (200, 304):
|
|
375
|
+
await response.aread()
|
|
376
|
+
try:
|
|
377
|
+
body = response.json()
|
|
378
|
+
except (ValueError, UnicodeError):
|
|
379
|
+
body = None
|
|
380
|
+
raise learned_skills_response_error(status, body) from None
|
|
381
|
+
returned_revision = response.headers.get("X-CopilotKit-Skills-Revision")
|
|
382
|
+
etag = response.headers.get("ETag")
|
|
383
|
+
if (
|
|
384
|
+
not returned_revision
|
|
385
|
+
or not etag
|
|
386
|
+
or re.fullmatch(r'"[a-f0-9]{64}"', etag) is None
|
|
387
|
+
or (revision is not None and revision != returned_revision)
|
|
388
|
+
):
|
|
389
|
+
raise LearnedSkillsError("INVALID_SNAPSHOT", False)
|
|
390
|
+
if status == 304:
|
|
391
|
+
if if_none_match is None:
|
|
392
|
+
raise LearnedSkillsError("INVALID_SNAPSHOT", False)
|
|
393
|
+
return {"status": "unchanged", "revision": returned_revision, "etag": etag}
|
|
394
|
+
content_type = response.headers.get("Content-Type")
|
|
395
|
+
if (
|
|
396
|
+
not content_type
|
|
397
|
+
or content_type.split(";", 1)[0].strip().lower() != "application/zip"
|
|
398
|
+
):
|
|
399
|
+
raise LearnedSkillsError("INVALID_SNAPSHOT", False)
|
|
400
|
+
data = await response.aread()
|
|
401
|
+
return {
|
|
402
|
+
"status": "snapshot",
|
|
403
|
+
"bytes": data,
|
|
404
|
+
"revision": returned_revision,
|
|
405
|
+
"etag": etag,
|
|
406
|
+
"contentType": content_type,
|
|
407
|
+
}
|
|
408
|
+
except (asyncio.CancelledError, TimeoutError, httpx.HTTPError) as error:
|
|
409
|
+
# Once access is denied, a failed or cancelled body read cannot
|
|
410
|
+
# turn that denial into a transient failure that permits stale data.
|
|
411
|
+
if status in (401, 403):
|
|
412
|
+
raise LearnedSkillsError(
|
|
413
|
+
"AUTHENTICATION_FAILED" if status == 401 else "AUTHORIZATION_FAILED",
|
|
414
|
+
False,
|
|
415
|
+
error,
|
|
416
|
+
) from None
|
|
417
|
+
if isinstance(error, asyncio.CancelledError):
|
|
418
|
+
raise
|
|
419
|
+
if isinstance(error, (TimeoutError, httpx.TimeoutException)):
|
|
420
|
+
raise LearnedSkillsError("TIMEOUT", True, error) from None
|
|
421
|
+
raise LearnedSkillsError("NETWORK_ERROR", True, error) from None
|
|
422
|
+
finally:
|
|
423
|
+
if cleanup is not None:
|
|
424
|
+
# Do not delay a known denial behind asynchronous pool cleanup.
|
|
425
|
+
# The client retains this task and aclose awaits its completion.
|
|
426
|
+
cleanup.close()
|
|
427
|
+
|
|
428
|
+
async def get_inspector_metadata(self) -> InspectorMetadata | None:
|
|
429
|
+
"""Read sanitized project metadata within five seconds, or a shorter client deadline.
|
|
430
|
+
|
|
431
|
+
A 204, 404, or unsupported schema returns None. Other provider failures
|
|
432
|
+
raise IntelligenceError. Cancellation propagates and a deadline raises TimeoutError.
|
|
433
|
+
"""
|
|
434
|
+
deadline = min(self.request_timeout, _INSPECTOR_METADATA_TIMEOUT)
|
|
435
|
+
try:
|
|
436
|
+
async with asyncio.timeout(deadline):
|
|
437
|
+
async with self.http_client.stream(
|
|
438
|
+
"GET",
|
|
439
|
+
self.api_url + "/api/inspector/metadata",
|
|
440
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
441
|
+
timeout=deadline,
|
|
442
|
+
follow_redirects=False,
|
|
443
|
+
) as response:
|
|
444
|
+
if response.status_code in (204, 404):
|
|
445
|
+
return None
|
|
446
|
+
if not 200 <= response.status_code < 300:
|
|
447
|
+
raise IntelligenceError(
|
|
448
|
+
response.status_code, "Intelligence request rejected"
|
|
449
|
+
)
|
|
450
|
+
await response.aread()
|
|
451
|
+
try:
|
|
452
|
+
decoded = response.json()
|
|
453
|
+
except ValueError:
|
|
454
|
+
raise IntelligenceError(
|
|
455
|
+
502, "Invalid Inspector metadata response"
|
|
456
|
+
) from None
|
|
457
|
+
return parse_inspector_metadata(decoded)
|
|
458
|
+
except (TimeoutError, httpx.TimeoutException):
|
|
459
|
+
raise TimeoutError("Inspector metadata request timed out") from None
|
|
460
|
+
except httpx.HTTPError:
|
|
461
|
+
raise IntelligenceError(502, "Intelligence connection failed") from None
|
|
462
|
+
|
|
463
|
+
async def get_runtime_entitlements(self) -> RuntimeEntitlementResponse:
|
|
464
|
+
"""Share concurrent lookups and return copies of fresh cached results."""
|
|
465
|
+
cached = self._entitlements_cache
|
|
466
|
+
if cached is not None and _entitlement_now() < cached[0]:
|
|
467
|
+
value = cached[1]
|
|
468
|
+
if isinstance(value, RuntimeEntitlementError):
|
|
469
|
+
raise RuntimeEntitlementError(value.status, str(value), value.retryable) from None
|
|
470
|
+
return deepcopy(value)
|
|
471
|
+
task = self._entitlements_task
|
|
472
|
+
if task is None:
|
|
473
|
+
task = asyncio.create_task(self._load_runtime_entitlements())
|
|
474
|
+
self._entitlements_task = task
|
|
475
|
+
self._entitlements_waiters += 1
|
|
476
|
+
try:
|
|
477
|
+
return deepcopy(await asyncio.shield(task))
|
|
478
|
+
except RuntimeEntitlementError as error:
|
|
479
|
+
raise RuntimeEntitlementError(error.status, str(error), error.retryable) from None
|
|
480
|
+
finally:
|
|
481
|
+
self._entitlements_waiters -= 1
|
|
482
|
+
if self._entitlements_task is task and (task.done() or self._entitlements_waiters == 0):
|
|
483
|
+
self._entitlements_task = None
|
|
484
|
+
if not task.done():
|
|
485
|
+
task.cancel()
|
|
486
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
487
|
+
|
|
488
|
+
async def _load_runtime_entitlements(self) -> RuntimeEntitlementResponse:
|
|
489
|
+
"""Cache completed lookups without extending expired Runtime authority."""
|
|
490
|
+
try:
|
|
491
|
+
response = await self._fetch_runtime_entitlements()
|
|
492
|
+
except RuntimeEntitlementError as error:
|
|
493
|
+
self._entitlements_cache = (_entitlement_now() + 5, error)
|
|
494
|
+
raise
|
|
495
|
+
active = response["status"] == "ready" and response["entitlement"]["active"]
|
|
496
|
+
self._entitlements_cache = (_entitlement_now() + (30 if active else 5), response)
|
|
497
|
+
return response
|
|
498
|
+
|
|
499
|
+
async def _fetch_runtime_entitlements(self) -> RuntimeEntitlementResponse:
|
|
500
|
+
"""Make one bounded entitlement request without redirects or retries."""
|
|
501
|
+
deadline = min(self.request_timeout, 1.5)
|
|
502
|
+
try:
|
|
503
|
+
async with asyncio.timeout(deadline):
|
|
504
|
+
async with self.http_client.stream(
|
|
505
|
+
"GET",
|
|
506
|
+
self.api_url + "/api/entitlements/runtime",
|
|
507
|
+
headers={
|
|
508
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
509
|
+
"Content-Type": "application/json",
|
|
510
|
+
},
|
|
511
|
+
timeout=deadline,
|
|
512
|
+
follow_redirects=False,
|
|
513
|
+
) as response:
|
|
514
|
+
status = response.status_code
|
|
515
|
+
if not 200 <= status < 300:
|
|
516
|
+
raise RuntimeEntitlementError(
|
|
517
|
+
status,
|
|
518
|
+
"Runtime entitlement request rejected",
|
|
519
|
+
status in (408, 425, 429) or status >= 500,
|
|
520
|
+
)
|
|
521
|
+
await response.aread()
|
|
522
|
+
try:
|
|
523
|
+
normalized = normalize_runtime_entitlements(response.json())
|
|
524
|
+
except ValueError:
|
|
525
|
+
normalized = None
|
|
526
|
+
if normalized is None:
|
|
527
|
+
raise RuntimeEntitlementError(
|
|
528
|
+
502, "Invalid Runtime entitlement response", False
|
|
529
|
+
)
|
|
530
|
+
return normalized
|
|
531
|
+
except (TimeoutError, httpx.TimeoutException):
|
|
532
|
+
raise RuntimeEntitlementError(
|
|
533
|
+
504, "Runtime entitlement request timed out", True
|
|
534
|
+
) from None
|
|
535
|
+
except RuntimeEntitlementError as error:
|
|
536
|
+
raise RuntimeEntitlementError(
|
|
537
|
+
error.status, "Runtime entitlement request failed", error.retryable
|
|
538
|
+
) from None
|
|
539
|
+
except Exception:
|
|
540
|
+
raise RuntimeEntitlementError(
|
|
541
|
+
502, "Runtime entitlement connection failed", True
|
|
542
|
+
) from None
|
|
543
|
+
|
|
544
|
+
async def list_memories(
|
|
545
|
+
self,
|
|
546
|
+
*,
|
|
547
|
+
user_id: str,
|
|
548
|
+
memory_grant: MemoryGrant | None = None,
|
|
549
|
+
include_invalidated: bool = False,
|
|
550
|
+
) -> ListMemoriesResponse:
|
|
551
|
+
"""List memories; include retired entries only when requested."""
|
|
552
|
+
return cast(
|
|
553
|
+
ListMemoriesResponse,
|
|
554
|
+
await self._object(
|
|
555
|
+
"GET",
|
|
556
|
+
"/api/memories",
|
|
557
|
+
query={"includeInvalidated": "true"} if include_invalidated else None,
|
|
558
|
+
headers=self._memory_headers(user_id, memory_grant),
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
async def create_memory(
|
|
563
|
+
self,
|
|
564
|
+
*,
|
|
565
|
+
user_id: str,
|
|
566
|
+
content: str,
|
|
567
|
+
kind: str,
|
|
568
|
+
scope: str | None = None,
|
|
569
|
+
source_thread_ids: list[str] | None = None,
|
|
570
|
+
memory_grant: MemoryGrant | None = None,
|
|
571
|
+
) -> SaveMemoryResponse:
|
|
572
|
+
"""Save a memory, retaining the platform's absorbed marker."""
|
|
573
|
+
body: Json = {"content": content, "kind": kind, "sourceThreadIds": source_thread_ids or []}
|
|
574
|
+
if scope is not None:
|
|
575
|
+
body["scope"] = scope
|
|
576
|
+
return cast(
|
|
577
|
+
SaveMemoryResponse,
|
|
578
|
+
await self._object(
|
|
579
|
+
"POST", "/api/memories", body, headers=self._memory_headers(user_id, memory_grant)
|
|
580
|
+
),
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
async def update_memory(
|
|
584
|
+
self,
|
|
585
|
+
*,
|
|
586
|
+
user_id: str,
|
|
587
|
+
memory_id: str,
|
|
588
|
+
content: str,
|
|
589
|
+
kind: str,
|
|
590
|
+
scope: str | None = None,
|
|
591
|
+
source_thread_ids: list[str] | None = None,
|
|
592
|
+
memory_grant: MemoryGrant | None = None,
|
|
593
|
+
) -> SaveMemoryResponse:
|
|
594
|
+
"""Supersede a memory and return its replacement and retired ID."""
|
|
595
|
+
body: Json = {"content": content, "kind": kind, "sourceThreadIds": source_thread_ids or []}
|
|
596
|
+
if scope is not None:
|
|
597
|
+
body["scope"] = scope
|
|
598
|
+
return cast(
|
|
599
|
+
SaveMemoryResponse,
|
|
600
|
+
await self._object(
|
|
601
|
+
"PATCH",
|
|
602
|
+
"/api/memories/" + segment(memory_id),
|
|
603
|
+
body,
|
|
604
|
+
headers=self._memory_headers(user_id, memory_grant),
|
|
605
|
+
),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
async def remove_memory(
|
|
609
|
+
self, *, user_id: str, memory_id: str, memory_grant: MemoryGrant | None = None
|
|
610
|
+
) -> None:
|
|
611
|
+
"""Retire a memory without deleting its history."""
|
|
612
|
+
await self._request(
|
|
613
|
+
"DELETE",
|
|
614
|
+
"/api/memories/" + segment(memory_id),
|
|
615
|
+
headers=self._memory_headers(user_id, memory_grant),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
async def recall_memories(
|
|
619
|
+
self,
|
|
620
|
+
*,
|
|
621
|
+
user_id: str,
|
|
622
|
+
query: str,
|
|
623
|
+
limit: int | None = None,
|
|
624
|
+
scope: str | None = None,
|
|
625
|
+
memory_grant: MemoryGrant | None = None,
|
|
626
|
+
) -> RecallMemoriesResponse:
|
|
627
|
+
"""Recall relevant memories with the platform's relevance scores."""
|
|
628
|
+
body: Json = {"query": query}
|
|
629
|
+
if limit is not None:
|
|
630
|
+
body["limit"] = limit
|
|
631
|
+
if scope is not None:
|
|
632
|
+
body["scope"] = scope
|
|
633
|
+
return cast(
|
|
634
|
+
RecallMemoriesResponse,
|
|
635
|
+
await self._object(
|
|
636
|
+
"POST",
|
|
637
|
+
"/api/memories/recall",
|
|
638
|
+
body,
|
|
639
|
+
headers=self._memory_headers(user_id, memory_grant),
|
|
640
|
+
),
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
async def list_threads(
|
|
644
|
+
self,
|
|
645
|
+
*,
|
|
646
|
+
user_id: str,
|
|
647
|
+
agent_id: str,
|
|
648
|
+
include_archived: bool = False,
|
|
649
|
+
limit: int | None = None,
|
|
650
|
+
cursor: str | None = None,
|
|
651
|
+
) -> ListThreadsResponse:
|
|
652
|
+
"""List a user's threads for one agent and retain the pagination cursor."""
|
|
653
|
+
query: Json = {"userId": user_id, "agentId": agent_id}
|
|
654
|
+
if include_archived:
|
|
655
|
+
query["includeArchived"] = "true"
|
|
656
|
+
if limit is not None:
|
|
657
|
+
query["limit"] = limit
|
|
658
|
+
if cursor is not None:
|
|
659
|
+
query["cursor"] = cursor
|
|
660
|
+
return cast(ListThreadsResponse, await self._object("GET", "/api/threads", query=query))
|
|
661
|
+
|
|
662
|
+
async def _thread(
|
|
663
|
+
self, method: str, path: str, body: Json | None = None, query: Json | None = None
|
|
664
|
+
) -> ThreadSummary:
|
|
665
|
+
"""Unwrap the platform's thread envelope."""
|
|
666
|
+
result = await self._object(method, path, body, query)
|
|
667
|
+
thread = result.get("thread")
|
|
668
|
+
if not isinstance(thread, dict) or not isinstance(thread.get("id"), str):
|
|
669
|
+
raise IntelligenceError(502, "Invalid thread response")
|
|
670
|
+
return cast(ThreadSummary, thread)
|
|
671
|
+
|
|
672
|
+
async def get_thread(self, *, thread_id: str, user_id: str) -> ThreadSummary:
|
|
673
|
+
"""Read a thread with the caller's explicit user scope."""
|
|
674
|
+
return await self._thread(
|
|
675
|
+
"GET", "/api/threads/" + segment(thread_id), query={"userId": user_id}
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
async def create_thread(
|
|
679
|
+
self,
|
|
680
|
+
*,
|
|
681
|
+
thread_id: str,
|
|
682
|
+
user_id: str,
|
|
683
|
+
agent_id: str,
|
|
684
|
+
name: str | None = None,
|
|
685
|
+
learning_container_id: str | None = None,
|
|
686
|
+
) -> ThreadSummary:
|
|
687
|
+
"""Create a thread and optionally assign its stable Learning Container ID."""
|
|
688
|
+
body: Json = {"threadId": thread_id, "userId": user_id, "agentId": agent_id}
|
|
689
|
+
if name is not None:
|
|
690
|
+
body["name"] = name
|
|
691
|
+
if learning_container_id is not None:
|
|
692
|
+
body["learningContainerId"] = learning_container_id
|
|
693
|
+
return await self._thread("POST", "/api/threads", body)
|
|
694
|
+
|
|
695
|
+
async def get_or_create_thread(
|
|
696
|
+
self,
|
|
697
|
+
*,
|
|
698
|
+
thread_id: str,
|
|
699
|
+
user_id: str,
|
|
700
|
+
agent_id: str,
|
|
701
|
+
name: str | None = None,
|
|
702
|
+
learning_container_id: str | None = None,
|
|
703
|
+
) -> ThreadResolution:
|
|
704
|
+
"""Resolve concurrent creation with a scoped read after a 409 conflict."""
|
|
705
|
+
try:
|
|
706
|
+
return {
|
|
707
|
+
"thread": await self.get_thread(thread_id=thread_id, user_id=user_id),
|
|
708
|
+
"created": False,
|
|
709
|
+
}
|
|
710
|
+
except IntelligenceError as error:
|
|
711
|
+
if error.status != 404:
|
|
712
|
+
raise
|
|
713
|
+
try:
|
|
714
|
+
thread = await self.create_thread(
|
|
715
|
+
thread_id=thread_id,
|
|
716
|
+
user_id=user_id,
|
|
717
|
+
agent_id=agent_id,
|
|
718
|
+
name=name,
|
|
719
|
+
learning_container_id=learning_container_id,
|
|
720
|
+
)
|
|
721
|
+
return {"thread": thread, "created": True}
|
|
722
|
+
except IntelligenceError as error:
|
|
723
|
+
if error.status != 409:
|
|
724
|
+
raise
|
|
725
|
+
return {
|
|
726
|
+
"thread": await self.get_thread(thread_id=thread_id, user_id=user_id),
|
|
727
|
+
"created": False,
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async def update_thread(
|
|
731
|
+
self, *, thread_id: str, user_id: str, agent_id: str, updates: Json
|
|
732
|
+
) -> ThreadSummary:
|
|
733
|
+
"""Update thread metadata without letting updates replace caller identity."""
|
|
734
|
+
return await self._thread(
|
|
735
|
+
"PATCH",
|
|
736
|
+
"/api/threads/" + segment(thread_id),
|
|
737
|
+
{**updates, "userId": user_id, "agentId": agent_id},
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
async def archive_thread(self, *, thread_id: str, user_id: str, agent_id: str) -> None:
|
|
741
|
+
"""Archive a thread while retaining its messages."""
|
|
742
|
+
await self.update_thread(
|
|
743
|
+
thread_id=thread_id, user_id=user_id, agent_id=agent_id, updates={"archived": True}
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
async def delete_thread(self, *, thread_id: str, user_id: str, agent_id: str) -> None:
|
|
747
|
+
"""Permanently delete a thread and its history."""
|
|
748
|
+
await self._request(
|
|
749
|
+
"DELETE",
|
|
750
|
+
"/api/threads/" + segment(thread_id),
|
|
751
|
+
{
|
|
752
|
+
"userId": user_id,
|
|
753
|
+
"agentId": agent_id,
|
|
754
|
+
"reason": f"Deleted via CopilotKit SDK (userId={user_id}, agentId={agent_id})",
|
|
755
|
+
},
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
async def get_thread_messages(self, *, thread_id: str, user_id: str) -> ThreadMessagesResponse:
|
|
759
|
+
"""Read persisted messages in chronological order."""
|
|
760
|
+
return cast(
|
|
761
|
+
ThreadMessagesResponse,
|
|
762
|
+
await self._object(
|
|
763
|
+
"GET", "/api/threads/" + segment(thread_id) + "/messages", query={"userId": user_id}
|
|
764
|
+
),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
async def get_thread_events(self, *, thread_id: str) -> ThreadEventsResponse:
|
|
768
|
+
"""Read project-authorized persisted events through the inspection API."""
|
|
769
|
+
return cast(
|
|
770
|
+
ThreadEventsResponse,
|
|
771
|
+
await self._object("GET", "/api/_inspect/threads/" + segment(thread_id) + "/events"),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
async def get_thread_state(self, *, thread_id: str) -> ThreadStateResponse:
|
|
775
|
+
"""Read the platform's folded state and snapshot-presence marker."""
|
|
776
|
+
return cast(
|
|
777
|
+
ThreadStateResponse,
|
|
778
|
+
await self._object("GET", "/api/_inspect/threads/" + segment(thread_id) + "/state"),
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
async def annotate(
|
|
782
|
+
self,
|
|
783
|
+
*,
|
|
784
|
+
user_id: str,
|
|
785
|
+
thread_id: str,
|
|
786
|
+
annotation_type: str,
|
|
787
|
+
client_event_id: str | None = None,
|
|
788
|
+
payload: Json | None = None,
|
|
789
|
+
occurred_at: str | None = None,
|
|
790
|
+
) -> AnnotateResponse:
|
|
791
|
+
"""Write an annotation; reuse client_event_id for an idempotent retry."""
|
|
792
|
+
body: Json = {"type": annotation_type, "userId": user_id, "threadId": thread_id}
|
|
793
|
+
if payload is not None:
|
|
794
|
+
body["payload"] = payload
|
|
795
|
+
if occurred_at is not None:
|
|
796
|
+
body["occurredAt"] = occurred_at
|
|
797
|
+
return cast(
|
|
798
|
+
AnnotateResponse,
|
|
799
|
+
await self._object(
|
|
800
|
+
"PUT",
|
|
801
|
+
"/connector/annotate/"
|
|
802
|
+
+ segment(client_event_id if client_event_id is not None else str(uuid4())),
|
|
803
|
+
body,
|
|
804
|
+
),
|
|
805
|
+
)
|