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.
@@ -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
+ )