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,878 @@
|
|
|
1
|
+
"""Intelligence-only multi-route ASGI application."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from starlette.applications import Starlette
|
|
16
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from starlette.responses import JSONResponse, Response
|
|
19
|
+
from starlette.routing import Route
|
|
20
|
+
from starlette.types import Receive, Scope, Send
|
|
21
|
+
|
|
22
|
+
from copilotkit_intelligence import (
|
|
23
|
+
Intelligence,
|
|
24
|
+
RuntimeEntitlementError,
|
|
25
|
+
RuntimeEntitlementResponse,
|
|
26
|
+
)
|
|
27
|
+
from copilotkit_intelligence.inspector import parse_inspector_metadata
|
|
28
|
+
|
|
29
|
+
from .a2ui import A2UIConfig, A2UIMiddleware
|
|
30
|
+
from .agents import Agent
|
|
31
|
+
from .finalizer import EventFinalizer
|
|
32
|
+
from .gateway import Gateway
|
|
33
|
+
from .mcp_apps import MCPAppsConfig, MCPAppsMiddleware, MCPServer
|
|
34
|
+
from .models import Json, PlatformError, RuntimeConfig, RuntimeErrorResponse, User
|
|
35
|
+
from .platform import Platform, segment
|
|
36
|
+
from .telemetry import Telemetry
|
|
37
|
+
|
|
38
|
+
IdentifyUser = Callable[[Request], Awaitable[User | None] | User | None]
|
|
39
|
+
MemoryPolicy = Callable[[User, Request], Awaitable[Json | None] | Json | None]
|
|
40
|
+
LearningSelector = Callable[[User, str, Json], Awaitable[str | None] | str | None]
|
|
41
|
+
ErrorHandler = Callable[[Exception, str], Awaitable[None]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class _Lease:
|
|
46
|
+
"""Transfer lock supervision from startup to execution without resetting its clock."""
|
|
47
|
+
|
|
48
|
+
owner: asyncio.Task[Any] | None
|
|
49
|
+
task: asyncio.Task[None] | None = None
|
|
50
|
+
error: Exception | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def required(body: Json, key: str) -> str:
|
|
54
|
+
"""Require an opaque, nonempty string without whitespace-only identifiers."""
|
|
55
|
+
value = body.get(key)
|
|
56
|
+
if not isinstance(value, str) or not value.strip():
|
|
57
|
+
raise RuntimeErrorResponse(400, f"Valid {key} is required")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IntelligenceRuntime:
|
|
62
|
+
"""Mount on ASGI directly, or mount its app in FastAPI/Starlette.
|
|
63
|
+
|
|
64
|
+
Agents receive independent JSON inputs. Identity and memory policy callbacks
|
|
65
|
+
run on the server for every request. Analytics use a bounded background queue.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
config: RuntimeConfig | None = None,
|
|
71
|
+
*,
|
|
72
|
+
agents: Mapping[str, Agent],
|
|
73
|
+
identify_user: IdentifyUser,
|
|
74
|
+
intelligence: Intelligence | None = None,
|
|
75
|
+
memory_policy: MemoryPolicy | None = None,
|
|
76
|
+
learning_container: LearningSelector | None = None,
|
|
77
|
+
telemetry: Telemetry | None = None,
|
|
78
|
+
http_client: httpx.AsyncClient | None = None,
|
|
79
|
+
a2ui: A2UIConfig | None = None,
|
|
80
|
+
mcp_apps: MCPAppsConfig | None = None,
|
|
81
|
+
on_error: ErrorHandler | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
if config is None:
|
|
84
|
+
if intelligence is None:
|
|
85
|
+
raise ValueError("config or intelligence is required")
|
|
86
|
+
config = RuntimeConfig(
|
|
87
|
+
api_key=intelligence.api_key,
|
|
88
|
+
api_url=intelligence.api_url,
|
|
89
|
+
runner_url=intelligence.runner_url,
|
|
90
|
+
client_url=intelligence.client_url,
|
|
91
|
+
request_timeout=intelligence.request_timeout,
|
|
92
|
+
)
|
|
93
|
+
if intelligence is not None:
|
|
94
|
+
if http_client is not None:
|
|
95
|
+
raise ValueError("Configure the HTTP client on intelligence")
|
|
96
|
+
if (
|
|
97
|
+
config.api_key,
|
|
98
|
+
config.api_url.rstrip("/"),
|
|
99
|
+
config.runner_url,
|
|
100
|
+
config.client_url,
|
|
101
|
+
config.request_timeout,
|
|
102
|
+
) != (
|
|
103
|
+
intelligence.api_key,
|
|
104
|
+
intelligence.api_url,
|
|
105
|
+
intelligence.runner_url,
|
|
106
|
+
intelligence.client_url,
|
|
107
|
+
intelligence.request_timeout,
|
|
108
|
+
):
|
|
109
|
+
raise ValueError("Runtime transport configuration must match intelligence")
|
|
110
|
+
self.config = config
|
|
111
|
+
self.agents = dict(agents)
|
|
112
|
+
self.identify_user = identify_user
|
|
113
|
+
self.memory_policy = memory_policy
|
|
114
|
+
self.learning_container = learning_container
|
|
115
|
+
self.on_error = on_error
|
|
116
|
+
if on_error is not None and not (
|
|
117
|
+
inspect.iscoroutinefunction(on_error)
|
|
118
|
+
or inspect.iscoroutinefunction(getattr(on_error, "__call__", None))
|
|
119
|
+
):
|
|
120
|
+
raise ValueError("Application error handler must be async")
|
|
121
|
+
self.a2ui = a2ui
|
|
122
|
+
self.mcp_apps = MCPAppsMiddleware(mcp_apps) if mcp_apps else None
|
|
123
|
+
self.telemetry = telemetry or Telemetry(config.telemetry_enabled)
|
|
124
|
+
self._owned_client = intelligence is None and http_client is None
|
|
125
|
+
self.client = (
|
|
126
|
+
intelligence.http_client
|
|
127
|
+
if intelligence is not None
|
|
128
|
+
else http_client or httpx.AsyncClient()
|
|
129
|
+
)
|
|
130
|
+
self.platform = Platform(config, self.client, intelligence)
|
|
131
|
+
self.intelligence = self.platform.intelligence
|
|
132
|
+
self._runs: dict[str, tuple[asyncio.Task[None], str, str]] = {}
|
|
133
|
+
self._gateways: dict[str, Gateway] = {}
|
|
134
|
+
self._startups: set[asyncio.Task[Any]] = set()
|
|
135
|
+
self._leases: dict[Gateway, _Lease] = {}
|
|
136
|
+
self._closing = False
|
|
137
|
+
self._instance_reported = False
|
|
138
|
+
base = config.base_path.rstrip("/")
|
|
139
|
+
self.app = Starlette(
|
|
140
|
+
routes=[
|
|
141
|
+
Route(
|
|
142
|
+
base + "/{path:path}",
|
|
143
|
+
self._handle,
|
|
144
|
+
methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
|
145
|
+
)
|
|
146
|
+
],
|
|
147
|
+
lifespan=self._lifespan,
|
|
148
|
+
)
|
|
149
|
+
if config.allowed_origins:
|
|
150
|
+
self.app.add_middleware(
|
|
151
|
+
CORSMiddleware,
|
|
152
|
+
allow_origins=list(config.allowed_origins),
|
|
153
|
+
allow_credentials="*" not in config.allowed_origins,
|
|
154
|
+
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
|
155
|
+
allow_headers=["Content-Type", "Authorization", "traceparent"],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
159
|
+
"""Serve the runtime as an ASGI 3 application."""
|
|
160
|
+
await self.app(scope, receive, send)
|
|
161
|
+
|
|
162
|
+
@asynccontextmanager
|
|
163
|
+
async def _lifespan(self, app: Starlette) -> AsyncIterator[None]:
|
|
164
|
+
"""Drain owned work when the host shuts down the ASGI application."""
|
|
165
|
+
yield
|
|
166
|
+
await self.aclose()
|
|
167
|
+
|
|
168
|
+
async def aclose(self) -> None:
|
|
169
|
+
"""Cancel runs, wait for lock cleanup, and close owned HTTP connections."""
|
|
170
|
+
self._closing = True
|
|
171
|
+
tasks = [item[0] for item in self._runs.values()] + list(self._startups)
|
|
172
|
+
for task in tasks:
|
|
173
|
+
active = next(
|
|
174
|
+
(self._gateways.get(key) for key, item in self._runs.items() if item[0] is task),
|
|
175
|
+
None,
|
|
176
|
+
)
|
|
177
|
+
if active:
|
|
178
|
+
active.stop_requested.set()
|
|
179
|
+
else:
|
|
180
|
+
task.cancel()
|
|
181
|
+
if tasks:
|
|
182
|
+
_, pending = await asyncio.wait(tasks, timeout=self.config.shutdown_timeout)
|
|
183
|
+
if pending:
|
|
184
|
+
for gateway in self._gateways.values():
|
|
185
|
+
gateway.abort()
|
|
186
|
+
for lease in self._leases.values():
|
|
187
|
+
if lease.task:
|
|
188
|
+
lease.task.cancel()
|
|
189
|
+
for task in pending:
|
|
190
|
+
task.cancel()
|
|
191
|
+
# Allow cooperative agents to observe the forced cancellation, without
|
|
192
|
+
# waiting indefinitely for application code that suppresses cancellation.
|
|
193
|
+
await asyncio.sleep(0)
|
|
194
|
+
if self._owned_client:
|
|
195
|
+
await self.client.aclose()
|
|
196
|
+
await self.telemetry.aclose()
|
|
197
|
+
|
|
198
|
+
async def _body(self, request: Request) -> Json:
|
|
199
|
+
"""Read a bounded JSON object without buffering an unbounded request."""
|
|
200
|
+
raw = bytearray()
|
|
201
|
+
async for chunk in request.stream():
|
|
202
|
+
raw.extend(chunk)
|
|
203
|
+
if len(raw) > self.config.max_body_bytes:
|
|
204
|
+
raise RuntimeErrorResponse(413, "Request body too large")
|
|
205
|
+
if not raw:
|
|
206
|
+
return {}
|
|
207
|
+
try:
|
|
208
|
+
body = json.loads(raw)
|
|
209
|
+
except (ValueError, UnicodeError) as error:
|
|
210
|
+
raise RuntimeErrorResponse(400, "Invalid JSON body") from error
|
|
211
|
+
if not isinstance(body, dict):
|
|
212
|
+
raise RuntimeErrorResponse(400, "Request body must be an object")
|
|
213
|
+
return body
|
|
214
|
+
|
|
215
|
+
async def _user(self, request: Request) -> User:
|
|
216
|
+
"""Resolve identity exclusively through the host authentication callback."""
|
|
217
|
+
result = self.identify_user(request)
|
|
218
|
+
user = await result if inspect.isawaitable(result) else result
|
|
219
|
+
if not isinstance(user, User) or not user.id.strip():
|
|
220
|
+
raise RuntimeErrorResponse(401, "Authentication required")
|
|
221
|
+
return user
|
|
222
|
+
|
|
223
|
+
async def _handle(self, request: Request) -> Response:
|
|
224
|
+
"""Apply consistent errors and payload-free telemetry to every mounted route."""
|
|
225
|
+
if not self._instance_reported:
|
|
226
|
+
self._instance_reported = True
|
|
227
|
+
await self.telemetry.emit("oss.runtime.instance_created", agentsAmount=len(self.agents))
|
|
228
|
+
path = request.path_params["path"].strip("/").split("/")
|
|
229
|
+
if (
|
|
230
|
+
len(path) == 3
|
|
231
|
+
and path[0] == "agent"
|
|
232
|
+
and path[2] in ("run", "connect")
|
|
233
|
+
and request.method == "POST"
|
|
234
|
+
):
|
|
235
|
+
await self.telemetry.emit("oss.runtime.copilot_request_created", requestType=path[2])
|
|
236
|
+
try:
|
|
237
|
+
return await self._dispatch(request, path)
|
|
238
|
+
except PlatformError as error:
|
|
239
|
+
await self._report_error(error, "platform")
|
|
240
|
+
status = error.status if 400 <= error.status < 500 else 502
|
|
241
|
+
if len(path) == 3 and path[0] == "agent" and path[2] == "connect":
|
|
242
|
+
status = error.status
|
|
243
|
+
return JSONResponse({"error": str(error)}, status_code=status)
|
|
244
|
+
except RuntimeErrorResponse as error:
|
|
245
|
+
return JSONResponse({"error": str(error)}, status_code=error.status)
|
|
246
|
+
except Exception as error:
|
|
247
|
+
await self._report_error(error, "request")
|
|
248
|
+
logging.getLogger(__name__).error("Runtime request failed")
|
|
249
|
+
return JSONResponse({"error": "Runtime request failed"}, status_code=500)
|
|
250
|
+
|
|
251
|
+
async def _report_error(self, error: Exception, phase: str) -> None:
|
|
252
|
+
"""Call the application's async diagnostic handler, separate from analytics."""
|
|
253
|
+
if self.on_error:
|
|
254
|
+
try:
|
|
255
|
+
async with asyncio.timeout(3):
|
|
256
|
+
await self.on_error(error, phase)
|
|
257
|
+
except Exception:
|
|
258
|
+
logging.getLogger(__name__).warning("Application error handler failed")
|
|
259
|
+
|
|
260
|
+
async def _dispatch(self, request: Request, path: list[str]) -> Response:
|
|
261
|
+
"""Dispatch only known multiroute endpoints; no legacy fallback exists."""
|
|
262
|
+
method = request.method
|
|
263
|
+
if path == ["info"]:
|
|
264
|
+
if method != "GET":
|
|
265
|
+
raise RuntimeErrorResponse(405, "Method not allowed")
|
|
266
|
+
return await self._info()
|
|
267
|
+
if path == ["inspector-metadata"]:
|
|
268
|
+
if method != "GET":
|
|
269
|
+
return JSONResponse(
|
|
270
|
+
{"error": "Method not allowed"}, status_code=405, headers={"Allow": "GET"}
|
|
271
|
+
)
|
|
272
|
+
return await self._inspector_metadata()
|
|
273
|
+
user = await self._user(request)
|
|
274
|
+
body = await self._body(request) if method != "GET" else {}
|
|
275
|
+
if path[0] == "agent" and len(path) >= 3:
|
|
276
|
+
agent_id = path[1]
|
|
277
|
+
if agent_id not in self.agents:
|
|
278
|
+
raise RuntimeErrorResponse(404, "Agent not found")
|
|
279
|
+
if path[2] == "run" and len(path) == 3 and method == "POST":
|
|
280
|
+
return await self._run(agent_id, body, user)
|
|
281
|
+
if path[2] == "connect" and len(path) == 3 and method == "POST":
|
|
282
|
+
thread_id = required(body, "threadId")
|
|
283
|
+
data = await self.platform.request(
|
|
284
|
+
"POST",
|
|
285
|
+
f"/api/threads/{segment(thread_id)}/connect",
|
|
286
|
+
{"userId": user.id, "agentId": agent_id},
|
|
287
|
+
)
|
|
288
|
+
if data is None:
|
|
289
|
+
return Response(status_code=204)
|
|
290
|
+
self._credentials(data)
|
|
291
|
+
return JSONResponse(
|
|
292
|
+
{
|
|
293
|
+
"threadId": data["threadId"],
|
|
294
|
+
"joinToken": data["joinToken"],
|
|
295
|
+
"realtime": self._realtime(data["threadId"]),
|
|
296
|
+
},
|
|
297
|
+
headers={"Cache-Control": "no-cache"},
|
|
298
|
+
)
|
|
299
|
+
if path[2] == "stop" and len(path) == 4 and method == "POST":
|
|
300
|
+
requested_run = required(body, "runId") if "runId" in body else None
|
|
301
|
+
try:
|
|
302
|
+
result = await self.platform.request(
|
|
303
|
+
"GET", "/api/threads/" + segment(path[3]), query={"userId": user.id}
|
|
304
|
+
)
|
|
305
|
+
except PlatformError as error:
|
|
306
|
+
raise RuntimeErrorResponse(
|
|
307
|
+
error.status if 400 <= error.status < 500 else 502,
|
|
308
|
+
"Thread access denied",
|
|
309
|
+
) from error
|
|
310
|
+
thread = result.get("thread") if isinstance(result, dict) else None
|
|
311
|
+
if (
|
|
312
|
+
not isinstance(thread, dict)
|
|
313
|
+
or not isinstance(thread.get("id"), str)
|
|
314
|
+
or not thread["id"].strip()
|
|
315
|
+
):
|
|
316
|
+
raise PlatformError(502, "Invalid thread response")
|
|
317
|
+
if "agentId" in thread and thread["agentId"] != agent_id:
|
|
318
|
+
raise RuntimeErrorResponse(403, "Thread access denied")
|
|
319
|
+
entry = self._runs.get(thread["id"])
|
|
320
|
+
stopped = entry is not None and entry[2] == agent_id
|
|
321
|
+
gateway = self._gateways.get(thread["id"])
|
|
322
|
+
if requested_run is not None and (not gateway or requested_run != gateway.run_id):
|
|
323
|
+
stopped = False
|
|
324
|
+
if stopped and gateway:
|
|
325
|
+
gateway.stop_requested.set()
|
|
326
|
+
data = {"stopped": stopped}
|
|
327
|
+
if stopped:
|
|
328
|
+
data["interrupt"] = {
|
|
329
|
+
"type": "RUN_ERROR",
|
|
330
|
+
"message": "Run stopped by user",
|
|
331
|
+
"code": "STOPPED",
|
|
332
|
+
}
|
|
333
|
+
return JSONResponse(data)
|
|
334
|
+
if path[0] == "threads":
|
|
335
|
+
return await self._threads(request, path, body, user)
|
|
336
|
+
if path[0] == "memories":
|
|
337
|
+
return await self._memories(request, path, body, user)
|
|
338
|
+
if path == ["annotate"] and method == "POST":
|
|
339
|
+
fields = {
|
|
340
|
+
"userId": user.id,
|
|
341
|
+
"threadId": required(body, "threadId"),
|
|
342
|
+
"type": required(body, "type"),
|
|
343
|
+
}
|
|
344
|
+
fields.update({key: body[key] for key in ("payload", "occurredAt") if key in body})
|
|
345
|
+
event_id = body.get("clientEventId") or str(uuid4())
|
|
346
|
+
if not isinstance(event_id, str):
|
|
347
|
+
raise RuntimeErrorResponse(400, "Invalid clientEventId")
|
|
348
|
+
data = await self.platform.request(
|
|
349
|
+
"PUT", "/connector/annotate/" + segment(event_id), fields
|
|
350
|
+
)
|
|
351
|
+
if not isinstance(data, dict):
|
|
352
|
+
raise PlatformError(502, "Invalid annotation response")
|
|
353
|
+
return JSONResponse(data)
|
|
354
|
+
raise RuntimeErrorResponse(404, "Route not found")
|
|
355
|
+
|
|
356
|
+
async def _info(self) -> Response:
|
|
357
|
+
"""Advertise configured capabilities and normalized, fresh SDK entitlements."""
|
|
358
|
+
entitlement: RuntimeEntitlementResponse
|
|
359
|
+
try:
|
|
360
|
+
entitlement = await self.intelligence.get_runtime_entitlements()
|
|
361
|
+
except Exception as error:
|
|
362
|
+
retryable = not isinstance(error, RuntimeEntitlementError) or error.retryable
|
|
363
|
+
entitlement = {
|
|
364
|
+
"status": "unavailable" if retryable else "misconfigured",
|
|
365
|
+
"error": {
|
|
366
|
+
"code": "runtime_entitlements_unavailable"
|
|
367
|
+
if retryable
|
|
368
|
+
else "runtime_entitlements_misconfigured",
|
|
369
|
+
"message": "Runtime entitlement lookup failed"
|
|
370
|
+
if retryable
|
|
371
|
+
else "Runtime entitlement lookup is misconfigured",
|
|
372
|
+
"retryable": retryable,
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
if entitlement["status"] == "ready":
|
|
376
|
+
license_status = "valid" if entitlement["entitlement"]["active"] else "none"
|
|
377
|
+
else:
|
|
378
|
+
license_status = "unknown" if entitlement["error"]["retryable"] else "none"
|
|
379
|
+
return JSONResponse(
|
|
380
|
+
{
|
|
381
|
+
"version": "0.1.0",
|
|
382
|
+
"mode": "intelligence",
|
|
383
|
+
"agents": {
|
|
384
|
+
name: {
|
|
385
|
+
"name": name,
|
|
386
|
+
"description": agent.description,
|
|
387
|
+
"className": type(agent).__name__,
|
|
388
|
+
}
|
|
389
|
+
for name, agent in self.agents.items()
|
|
390
|
+
},
|
|
391
|
+
"intelligence": {"wsUrl": self.config.client_url},
|
|
392
|
+
"threadEndpoints": {
|
|
393
|
+
"list": True,
|
|
394
|
+
"inspect": True,
|
|
395
|
+
"mutations": True,
|
|
396
|
+
"realtimeMetadata": True,
|
|
397
|
+
},
|
|
398
|
+
"runtimeEntitlements": entitlement,
|
|
399
|
+
"licenseStatus": license_status,
|
|
400
|
+
"telemetryDisabled": not self.telemetry.enabled,
|
|
401
|
+
"a2uiEnabled": bool(self.a2ui and self.a2ui.enabled),
|
|
402
|
+
**(
|
|
403
|
+
{
|
|
404
|
+
"a2ui": {
|
|
405
|
+
"enabled": True,
|
|
406
|
+
**(
|
|
407
|
+
{"agents": list(self.a2ui.agents)}
|
|
408
|
+
if self.a2ui.agents is not None
|
|
409
|
+
else {}
|
|
410
|
+
),
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if self.a2ui and self.a2ui.enabled
|
|
414
|
+
else {}
|
|
415
|
+
),
|
|
416
|
+
"openGenerativeUIEnabled": False,
|
|
417
|
+
"audioFileTranscriptionEnabled": False,
|
|
418
|
+
"suggestions": False,
|
|
419
|
+
"inspectorMetadata": True,
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
async def _inspector_metadata(self) -> Response:
|
|
424
|
+
"""Serve project display metadata without browser credentials or shared caching."""
|
|
425
|
+
headers = {"Cache-Control": "no-store, private"}
|
|
426
|
+
try:
|
|
427
|
+
metadata = parse_inspector_metadata(await self.intelligence.get_inspector_metadata())
|
|
428
|
+
except Exception as error:
|
|
429
|
+
logging.getLogger(__name__).warning(
|
|
430
|
+
"Inspector metadata request failed",
|
|
431
|
+
extra={"operation": "inspector.metadata", "error_type": type(error).__name__},
|
|
432
|
+
)
|
|
433
|
+
metadata = None
|
|
434
|
+
if metadata is None:
|
|
435
|
+
return Response(status_code=204, headers=headers)
|
|
436
|
+
return JSONResponse(metadata, headers=headers)
|
|
437
|
+
|
|
438
|
+
def _realtime(self, thread_id: str) -> Json:
|
|
439
|
+
"""Build browser connection metadata using the distinct client endpoint."""
|
|
440
|
+
return {"clientUrl": self.config.client_url, "topic": f"thread:{thread_id}"}
|
|
441
|
+
|
|
442
|
+
def _credentials(self, data: Any, run: bool = False) -> None:
|
|
443
|
+
"""Reject malformed successful platform replies before serving credentials."""
|
|
444
|
+
fields = ("threadId", "joinToken", "runId") if run else ("threadId", "joinToken")
|
|
445
|
+
if not isinstance(data, dict) or any(
|
|
446
|
+
not isinstance(data.get(key), str) or not data[key] for key in fields
|
|
447
|
+
):
|
|
448
|
+
raise PlatformError(502, "Run connection credentials not available")
|
|
449
|
+
|
|
450
|
+
async def _run(self, agent_id: str, body: Json, user: User) -> Response:
|
|
451
|
+
"""Register startup before its first side effect so shutdown can drain it."""
|
|
452
|
+
owner = asyncio.current_task()
|
|
453
|
+
assert owner is not None
|
|
454
|
+
self._startups.add(owner)
|
|
455
|
+
try:
|
|
456
|
+
return await self._start_run(agent_id, body, user)
|
|
457
|
+
finally:
|
|
458
|
+
self._startups.discard(owner)
|
|
459
|
+
|
|
460
|
+
async def _start_run(self, agent_id: str, body: Json, user: User) -> Response:
|
|
461
|
+
"""Acquire canonical ownership and join ingestion before returning success."""
|
|
462
|
+
if self._closing:
|
|
463
|
+
raise RuntimeErrorResponse(503, "Runtime is shutting down")
|
|
464
|
+
thread_id, run_id = required(body, "threadId"), required(body, "runId")
|
|
465
|
+
for key, kind in (("messages", list), ("tools", list), ("context", list), ("state", dict)):
|
|
466
|
+
if key in body and not isinstance(body[key], kind):
|
|
467
|
+
raise RuntimeErrorResponse(400, f"Invalid {key}")
|
|
468
|
+
for message in body.get("messages", []):
|
|
469
|
+
if not isinstance(message, dict) or not isinstance(message.get("id"), str):
|
|
470
|
+
raise RuntimeErrorResponse(400, "Invalid message")
|
|
471
|
+
fields = {"threadId": thread_id, "userId": user.id, "agentId": agent_id}
|
|
472
|
+
if self.learning_container:
|
|
473
|
+
selected = self.learning_container(user, agent_id, body)
|
|
474
|
+
container = await selected if inspect.isawaitable(selected) else selected
|
|
475
|
+
if container is not None:
|
|
476
|
+
if not isinstance(container, str) or not container.strip():
|
|
477
|
+
raise RuntimeErrorResponse(500, "Invalid learning container")
|
|
478
|
+
fields["learningContainerId"] = container
|
|
479
|
+
await self.platform.get_or_create_thread(fields)
|
|
480
|
+
lock = await self.platform.request(
|
|
481
|
+
"POST",
|
|
482
|
+
f"/api/threads/{segment(thread_id)}/lock",
|
|
483
|
+
{**fields, "runId": run_id, "ttlSeconds": self.config.lock_ttl_seconds},
|
|
484
|
+
)
|
|
485
|
+
canonical_thread = lock.get("threadId", thread_id) if isinstance(lock, dict) else thread_id
|
|
486
|
+
canonical_run = lock.get("runId", run_id) if isinstance(lock, dict) else run_id
|
|
487
|
+
gateway = Gateway(
|
|
488
|
+
self.config, canonical_thread or thread_id, canonical_run or run_id, self.telemetry
|
|
489
|
+
)
|
|
490
|
+
lease: _Lease | None = None
|
|
491
|
+
try:
|
|
492
|
+
self._credentials(lock, run=True)
|
|
493
|
+
lease = self._start_lease(gateway)
|
|
494
|
+
self._gateways[canonical_thread] = gateway
|
|
495
|
+
history = await self.platform.request(
|
|
496
|
+
"GET",
|
|
497
|
+
f"/api/threads/{segment(canonical_thread)}/messages",
|
|
498
|
+
query={"userId": user.id},
|
|
499
|
+
)
|
|
500
|
+
if not isinstance(history, dict) or not isinstance(history.get("messages"), list):
|
|
501
|
+
raise PlatformError(502, "Invalid thread history")
|
|
502
|
+
ids = {message["id"] for message in history["messages"]}
|
|
503
|
+
input = {**body, "threadId": canonical_thread, "runId": canonical_run}
|
|
504
|
+
new_messages = [
|
|
505
|
+
message for message in body.get("messages", []) if message["id"] not in ids
|
|
506
|
+
]
|
|
507
|
+
try:
|
|
508
|
+
await gateway.join()
|
|
509
|
+
except Exception as error:
|
|
510
|
+
raise PlatformError(502, "Failed to join Intelligence gateway") from error
|
|
511
|
+
if self._closing:
|
|
512
|
+
raise RuntimeErrorResponse(503, "Runtime is shutting down")
|
|
513
|
+
except BaseException:
|
|
514
|
+
if lease and lease.task:
|
|
515
|
+
lease.task.cancel()
|
|
516
|
+
await asyncio.gather(lease.task, return_exceptions=True)
|
|
517
|
+
self._leases.pop(gateway, None)
|
|
518
|
+
await gateway.aclose()
|
|
519
|
+
await self._cleanup(canonical_thread or thread_id, canonical_run or run_id)
|
|
520
|
+
if self._gateways.get(canonical_thread) is gateway:
|
|
521
|
+
self._gateways.pop(canonical_thread, None)
|
|
522
|
+
if lease and lease.error:
|
|
523
|
+
raise PlatformError(502, "Run lock renewal failed") from lease.error
|
|
524
|
+
raise
|
|
525
|
+
task = asyncio.create_task(
|
|
526
|
+
self._execute(agent_id, input, new_messages, gateway, lease),
|
|
527
|
+
name="copilotkit-agent-run",
|
|
528
|
+
)
|
|
529
|
+
assert lease is not None
|
|
530
|
+
lease.owner = None
|
|
531
|
+
self._runs[canonical_thread] = (task, user.id, agent_id)
|
|
532
|
+
self._gateways[canonical_thread] = gateway
|
|
533
|
+
return JSONResponse(
|
|
534
|
+
{
|
|
535
|
+
"threadId": canonical_thread,
|
|
536
|
+
"runId": canonical_run,
|
|
537
|
+
"joinToken": lock["joinToken"],
|
|
538
|
+
"realtime": self._realtime(canonical_thread),
|
|
539
|
+
},
|
|
540
|
+
headers={"Cache-Control": "no-cache"},
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
async def _cleanup(self, thread_id: str, run_id: str) -> None:
|
|
544
|
+
"""Release only this run's platform lock after completion or failure."""
|
|
545
|
+
try:
|
|
546
|
+
await self.platform.request(
|
|
547
|
+
"DELETE", f"/api/threads/{segment(thread_id)}/lock", {"runId": run_id}
|
|
548
|
+
)
|
|
549
|
+
except Exception as error:
|
|
550
|
+
await self._report_error(error, "lock.cleanup")
|
|
551
|
+
await self.telemetry.emit("runtime.lock.cleanup_failed", outcome="error")
|
|
552
|
+
|
|
553
|
+
async def _renew(self, gateway: Gateway) -> None:
|
|
554
|
+
"""Renew ownership until shutdown; failure cancels the producer task group."""
|
|
555
|
+
while True:
|
|
556
|
+
await asyncio.sleep(self.config.lock_heartbeat_seconds)
|
|
557
|
+
async with asyncio.timeout(
|
|
558
|
+
self.config.lock_ttl_seconds - self.config.lock_heartbeat_seconds
|
|
559
|
+
):
|
|
560
|
+
await self.platform.request(
|
|
561
|
+
"PATCH",
|
|
562
|
+
f"/api/threads/{segment(gateway.thread_id)}/lock",
|
|
563
|
+
{"runId": gateway.run_id, "ttlSeconds": self.config.lock_ttl_seconds},
|
|
564
|
+
)
|
|
565
|
+
await self.telemetry.emit("runtime.lock.renewed")
|
|
566
|
+
|
|
567
|
+
def _start_lease(self, gateway: Gateway) -> _Lease:
|
|
568
|
+
"""Begin lease renewal at acquisition, including history fetch and channel join."""
|
|
569
|
+
owner = asyncio.current_task()
|
|
570
|
+
assert owner is not None
|
|
571
|
+
lease = _Lease(owner)
|
|
572
|
+
|
|
573
|
+
async def supervise() -> None:
|
|
574
|
+
try:
|
|
575
|
+
await self._renew(gateway)
|
|
576
|
+
except Exception as error:
|
|
577
|
+
lease.error = error
|
|
578
|
+
if lease.owner:
|
|
579
|
+
lease.owner.cancel()
|
|
580
|
+
|
|
581
|
+
lease.task = asyncio.create_task(supervise(), name="copilotkit-lock-lease")
|
|
582
|
+
self._leases[gateway] = lease
|
|
583
|
+
return lease
|
|
584
|
+
|
|
585
|
+
async def _execute(
|
|
586
|
+
self,
|
|
587
|
+
agent_id: str,
|
|
588
|
+
input: Json,
|
|
589
|
+
messages: list[Json],
|
|
590
|
+
gateway: Gateway,
|
|
591
|
+
lease: _Lease | None = None,
|
|
592
|
+
) -> None:
|
|
593
|
+
"""Persist ordered agent events while heartbeat failures abort execution."""
|
|
594
|
+
started = time.monotonic()
|
|
595
|
+
await self.telemetry.emit("oss.runtime.agent_execution_stream_started")
|
|
596
|
+
outcome = "complete"
|
|
597
|
+
stream_completed = False
|
|
598
|
+
lease = lease or self._start_lease(gateway)
|
|
599
|
+
lease.owner = None
|
|
600
|
+
sent = EventFinalizer()
|
|
601
|
+
owner = asyncio.current_task()
|
|
602
|
+
|
|
603
|
+
async def stop_monitor() -> None:
|
|
604
|
+
await gateway.stop_requested.wait()
|
|
605
|
+
if owner:
|
|
606
|
+
owner.cancel()
|
|
607
|
+
|
|
608
|
+
async def lease_monitor() -> None:
|
|
609
|
+
if lease.task:
|
|
610
|
+
await asyncio.shield(lease.task)
|
|
611
|
+
if lease.error and owner:
|
|
612
|
+
owner.cancel()
|
|
613
|
+
|
|
614
|
+
queue: asyncio.Queue[Json | None] = asyncio.Queue(maxsize=32)
|
|
615
|
+
|
|
616
|
+
async def produce() -> None:
|
|
617
|
+
if lease.error:
|
|
618
|
+
raise lease.error
|
|
619
|
+
if gateway.stop_requested.is_set():
|
|
620
|
+
raise asyncio.CancelledError
|
|
621
|
+
produced = EventFinalizer()
|
|
622
|
+
terminal = False
|
|
623
|
+
async for event in self._agent_events(agent_id, input, lease):
|
|
624
|
+
if terminal:
|
|
625
|
+
raise ValueError("Agent emitted after terminal event")
|
|
626
|
+
if event.get("type") == "RUN_STARTED":
|
|
627
|
+
continue
|
|
628
|
+
# Detach before yielding control to the producer again.
|
|
629
|
+
await queue.put(json.loads(json.dumps(event, allow_nan=False)))
|
|
630
|
+
produced.observe(event)
|
|
631
|
+
terminal = event.get("type") in ("RUN_FINISHED", "RUN_ERROR")
|
|
632
|
+
if not terminal:
|
|
633
|
+
for final_event in produced.finish():
|
|
634
|
+
await queue.put(final_event)
|
|
635
|
+
await queue.put(None)
|
|
636
|
+
|
|
637
|
+
try:
|
|
638
|
+
initial = {"type": "RUN_STARTED", "input": {**input, "messages": messages}}
|
|
639
|
+
sent.observe(initial)
|
|
640
|
+
await gateway.send(initial)
|
|
641
|
+
# This check follows the ACK await and precedes all producer scheduling.
|
|
642
|
+
if lease.error:
|
|
643
|
+
raise lease.error
|
|
644
|
+
if gateway.stop_requested.is_set():
|
|
645
|
+
raise asyncio.CancelledError
|
|
646
|
+
async with asyncio.TaskGroup() as group:
|
|
647
|
+
keepalive = group.create_task(gateway.keepalive())
|
|
648
|
+
monitor = group.create_task(stop_monitor())
|
|
649
|
+
lease_watcher = group.create_task(lease_monitor())
|
|
650
|
+
group.create_task(produce())
|
|
651
|
+
done = False
|
|
652
|
+
while not done:
|
|
653
|
+
first = await queue.get()
|
|
654
|
+
if first is None:
|
|
655
|
+
break
|
|
656
|
+
batch = [first]
|
|
657
|
+
await asyncio.sleep(0)
|
|
658
|
+
while len(batch) < 32 and not queue.empty():
|
|
659
|
+
item = queue.get_nowait()
|
|
660
|
+
if item is None:
|
|
661
|
+
done = True
|
|
662
|
+
break
|
|
663
|
+
batch.append(item)
|
|
664
|
+
for event in batch:
|
|
665
|
+
sent.observe(event)
|
|
666
|
+
await gateway.send_many(batch)
|
|
667
|
+
if any(event.get("type") == "RUN_ERROR" for event in batch):
|
|
668
|
+
outcome = "error"
|
|
669
|
+
await self._report_error(
|
|
670
|
+
RuntimeError("Agent emitted RUN_ERROR"), "agent.event"
|
|
671
|
+
)
|
|
672
|
+
keepalive.cancel()
|
|
673
|
+
monitor.cancel()
|
|
674
|
+
lease_watcher.cancel()
|
|
675
|
+
stream_completed = True
|
|
676
|
+
except asyncio.CancelledError:
|
|
677
|
+
outcome = (
|
|
678
|
+
"error"
|
|
679
|
+
if lease.error
|
|
680
|
+
else "complete"
|
|
681
|
+
if gateway.stop_requested.is_set()
|
|
682
|
+
else "cancelled"
|
|
683
|
+
)
|
|
684
|
+
if lease.error:
|
|
685
|
+
await self._report_error(lease.error, "lock.renewal")
|
|
686
|
+
try:
|
|
687
|
+
for final_event in sent.finish(
|
|
688
|
+
stop_requested=gateway.stop_requested.is_set() and not lease.error
|
|
689
|
+
):
|
|
690
|
+
await gateway.send(final_event)
|
|
691
|
+
except Exception:
|
|
692
|
+
pass
|
|
693
|
+
except Exception as error:
|
|
694
|
+
await self._report_error(error, "agent.execution")
|
|
695
|
+
outcome = "error"
|
|
696
|
+
try:
|
|
697
|
+
for final_event in sent.finish():
|
|
698
|
+
await gateway.send(final_event)
|
|
699
|
+
except Exception:
|
|
700
|
+
pass
|
|
701
|
+
finally:
|
|
702
|
+
if lease.task:
|
|
703
|
+
lease.task.cancel()
|
|
704
|
+
await asyncio.gather(lease.task, return_exceptions=True)
|
|
705
|
+
self._leases.pop(gateway, None)
|
|
706
|
+
await gateway.aclose()
|
|
707
|
+
await self._cleanup(gateway.thread_id, gateway.run_id)
|
|
708
|
+
entry = self._runs.get(gateway.thread_id)
|
|
709
|
+
if entry and entry[0] is asyncio.current_task():
|
|
710
|
+
self._runs.pop(gateway.thread_id, None)
|
|
711
|
+
self._gateways.pop(gateway.thread_id, None)
|
|
712
|
+
await self.telemetry.emit(
|
|
713
|
+
"oss.runtime.agent_execution_stream_ended"
|
|
714
|
+
if outcome == "complete"
|
|
715
|
+
else "oss.runtime.agent_execution_stream_errored",
|
|
716
|
+
outcome=outcome,
|
|
717
|
+
duration_ms=(time.monotonic() - started) * 1000,
|
|
718
|
+
error="RUN_STOPPED" if outcome == "cancelled" else "AGENT_EXECUTION_FAILED",
|
|
719
|
+
)
|
|
720
|
+
if stream_completed and outcome != "complete":
|
|
721
|
+
await self.telemetry.emit("oss.runtime.agent_execution_stream_ended")
|
|
722
|
+
|
|
723
|
+
async def _agent_events(
|
|
724
|
+
self, agent_id: str, input: Json, lease: _Lease | None = None
|
|
725
|
+
) -> AsyncIterator[Json]:
|
|
726
|
+
"""Transform configured UI features before canonical Intelligence ingestion."""
|
|
727
|
+
proxy = input.get("forwardedProps", {}).get("__proxiedMCPRequest")
|
|
728
|
+
if proxy is not None:
|
|
729
|
+
if not self.mcp_apps or not isinstance(proxy, dict):
|
|
730
|
+
raise ValueError("MCP Apps proxy is not configured")
|
|
731
|
+
async for event in self.mcp_apps.proxy(proxy, agent_id):
|
|
732
|
+
yield event
|
|
733
|
+
return
|
|
734
|
+
a2ui = A2UIMiddleware(self.a2ui) if self.a2ui and self.a2ui.applies(agent_id) else None
|
|
735
|
+
prepared = a2ui.prepare(input) if a2ui else input
|
|
736
|
+
ui_tools: dict[str, tuple[MCPServer, str]] = {}
|
|
737
|
+
if self.mcp_apps:
|
|
738
|
+
prepared, ui_tools = await self.mcp_apps.discover(prepared, agent_id)
|
|
739
|
+
if lease and lease.error:
|
|
740
|
+
raise lease.error
|
|
741
|
+
source = self.agents[agent_id].run(prepared)
|
|
742
|
+
if self.mcp_apps:
|
|
743
|
+
source = self.mcp_apps.transform(source, prepared, ui_tools)
|
|
744
|
+
if a2ui:
|
|
745
|
+
source = a2ui.transform(source, input)
|
|
746
|
+
async for event in source:
|
|
747
|
+
yield event
|
|
748
|
+
|
|
749
|
+
async def _threads(self, request: Request, path: list[str], body: Json, user: User) -> Response:
|
|
750
|
+
"""Forward thread operations with trusted identity and explicit ownership checks."""
|
|
751
|
+
method = request.method
|
|
752
|
+
if len(path) == 1 and method == "GET":
|
|
753
|
+
query = {
|
|
754
|
+
key: value
|
|
755
|
+
for key, value in request.query_params.items()
|
|
756
|
+
if key in ("agentId", "includeArchived", "limit", "cursor")
|
|
757
|
+
}
|
|
758
|
+
required(query, "agentId")
|
|
759
|
+
query["userId"] = user.id
|
|
760
|
+
return JSONResponse(await self.platform.request("GET", "/api/threads", query=query))
|
|
761
|
+
if path == ["threads", "subscribe"] and method == "POST":
|
|
762
|
+
data = await self.platform.request(
|
|
763
|
+
"POST", "/api/threads/subscribe", {"userId": user.id}
|
|
764
|
+
)
|
|
765
|
+
return JSONResponse(data)
|
|
766
|
+
if len(path) < 2:
|
|
767
|
+
raise RuntimeErrorResponse(405, "Method not allowed")
|
|
768
|
+
endpoint = "/api/threads/" + segment(path[1])
|
|
769
|
+
if len(path) == 3 and method == "GET" and path[2] in ("messages", "events", "state"):
|
|
770
|
+
if path[2] == "messages":
|
|
771
|
+
data = await self.platform.request(
|
|
772
|
+
"GET", endpoint + "/messages", query={"userId": user.id}
|
|
773
|
+
)
|
|
774
|
+
else:
|
|
775
|
+
await self.platform.request("GET", endpoint, query={"userId": user.id})
|
|
776
|
+
data = await self.platform.request(
|
|
777
|
+
"GET", "/api/_inspect/threads/" + segment(path[1]) + "/" + path[2]
|
|
778
|
+
)
|
|
779
|
+
if path[2] == "state":
|
|
780
|
+
data = {"state": data.get("state") if data.get("kind") == "snapshot" else None}
|
|
781
|
+
else:
|
|
782
|
+
data = {"events": data["events"]}
|
|
783
|
+
return JSONResponse(data)
|
|
784
|
+
if len(path) == 2 and method == "GET":
|
|
785
|
+
return JSONResponse(
|
|
786
|
+
await self.platform.request("GET", endpoint, query={"userId": user.id})
|
|
787
|
+
)
|
|
788
|
+
archive = len(path) == 3 and path[2] == "archive" and method == "POST"
|
|
789
|
+
if archive or (len(path) == 2 and method in ("PATCH", "DELETE")):
|
|
790
|
+
fields: Json = {"agentId": required(body, "agentId"), "userId": user.id}
|
|
791
|
+
fields.update({key: body[key] for key in ("name", "archived") if key in body})
|
|
792
|
+
if "name" in fields and not isinstance(fields["name"], str):
|
|
793
|
+
raise RuntimeErrorResponse(400, "Invalid name")
|
|
794
|
+
if "archived" in fields and not isinstance(fields["archived"], bool):
|
|
795
|
+
raise RuntimeErrorResponse(400, "Invalid archived")
|
|
796
|
+
if archive:
|
|
797
|
+
fields["archived"] = True
|
|
798
|
+
if method == "DELETE":
|
|
799
|
+
fields["reason"] = "Deleted via CopilotKit runtime"
|
|
800
|
+
data = await self.platform.request("PATCH" if archive else method, endpoint, fields)
|
|
801
|
+
if archive:
|
|
802
|
+
return JSONResponse({"threadId": path[1], "archived": True})
|
|
803
|
+
if method == "DELETE":
|
|
804
|
+
return JSONResponse({"threadId": path[1], "deleted": True})
|
|
805
|
+
if not isinstance(data, dict) or not isinstance(data.get("thread"), dict):
|
|
806
|
+
raise PlatformError(502, "Invalid thread response")
|
|
807
|
+
return JSONResponse(data["thread"])
|
|
808
|
+
raise RuntimeErrorResponse(405, "Method not allowed")
|
|
809
|
+
|
|
810
|
+
async def _memories(
|
|
811
|
+
self, request: Request, path: list[str], body: Json, user: User
|
|
812
|
+
) -> Response:
|
|
813
|
+
"""Apply trusted memory grants and validate writes before forwarding."""
|
|
814
|
+
headers = {"x-cpki-user-id": user.id}
|
|
815
|
+
if self.memory_policy:
|
|
816
|
+
selected = self.memory_policy(user, request)
|
|
817
|
+
grant = await selected if inspect.isawaitable(selected) else selected
|
|
818
|
+
if grant is None:
|
|
819
|
+
raise RuntimeErrorResponse(403, "Memory access denied")
|
|
820
|
+
if not isinstance(grant, dict) or any(
|
|
821
|
+
grant.get(key) not in ("none", "read", "read-write") for key in ("user", "project")
|
|
822
|
+
):
|
|
823
|
+
raise RuntimeErrorResponse(500, "Invalid memory grant")
|
|
824
|
+
if grant["user"] == "none" and grant["project"] == "none":
|
|
825
|
+
raise RuntimeErrorResponse(403, "Memory access denied")
|
|
826
|
+
headers["x-cpki-memory-grant"] = json.dumps(
|
|
827
|
+
{key: grant[key] for key in ("user", "project")}
|
|
828
|
+
)
|
|
829
|
+
method = request.method
|
|
830
|
+
endpoint = "/api/memories" + ("/" + segment(path[1]) if len(path) == 2 else "")
|
|
831
|
+
if len(path) > 2:
|
|
832
|
+
raise RuntimeErrorResponse(404, "Route not found")
|
|
833
|
+
if len(path) == 1 and method == "GET":
|
|
834
|
+
query = (
|
|
835
|
+
{"includeInvalidated": "true"}
|
|
836
|
+
if request.query_params.get("includeInvalidated") == "true"
|
|
837
|
+
else None
|
|
838
|
+
)
|
|
839
|
+
data = await self.platform.request("GET", endpoint, query=query, headers=headers)
|
|
840
|
+
elif path == ["memories", "subscribe"] and method == "POST":
|
|
841
|
+
data = await self.platform.request("POST", endpoint, headers=headers)
|
|
842
|
+
elif path == ["memories", "recall"] and method == "POST":
|
|
843
|
+
fields: Json = {"query": required(body, "query").strip()}
|
|
844
|
+
if "limit" in body:
|
|
845
|
+
if type(body["limit"]) is not int or body["limit"] <= 0:
|
|
846
|
+
raise RuntimeErrorResponse(400, "Invalid limit")
|
|
847
|
+
fields["limit"] = body["limit"]
|
|
848
|
+
self._memory_scope(body, fields)
|
|
849
|
+
data = await self.platform.request("POST", endpoint, fields, headers=headers)
|
|
850
|
+
elif (len(path) == 1 and method == "POST") or (len(path) == 2 and method == "PATCH"):
|
|
851
|
+
if not isinstance(body.get("content"), str) or body.get("kind") not in (
|
|
852
|
+
"topical",
|
|
853
|
+
"episodic",
|
|
854
|
+
"operational",
|
|
855
|
+
):
|
|
856
|
+
raise RuntimeErrorResponse(400, "Invalid memory content or kind")
|
|
857
|
+
sources = body.get("sourceThreadIds", [])
|
|
858
|
+
if not isinstance(sources, list) or any(not isinstance(item, str) for item in sources):
|
|
859
|
+
raise RuntimeErrorResponse(400, "Invalid sourceThreadIds")
|
|
860
|
+
fields = {"content": body["content"], "kind": body["kind"], "sourceThreadIds": sources}
|
|
861
|
+
self._memory_scope(body, fields)
|
|
862
|
+
data = await self.platform.request(method, endpoint, fields, headers=headers)
|
|
863
|
+
elif len(path) == 2 and method == "DELETE":
|
|
864
|
+
await self.platform.request("DELETE", endpoint, headers=headers)
|
|
865
|
+
return Response(status_code=204)
|
|
866
|
+
else:
|
|
867
|
+
raise RuntimeErrorResponse(405, "Method not allowed")
|
|
868
|
+
if (len(path) == 1 and method == "GET") or path == ["memories", "recall"]:
|
|
869
|
+
if not isinstance(data, dict) or not isinstance(data.get("memories"), list):
|
|
870
|
+
raise PlatformError(502, "Invalid memory response")
|
|
871
|
+
return JSONResponse(data, status_code=201 if len(path) == 1 and method == "POST" else 200)
|
|
872
|
+
|
|
873
|
+
def _memory_scope(self, body: Json, fields: Json) -> None:
|
|
874
|
+
"""Accept only platform-defined memory scopes."""
|
|
875
|
+
if "scope" in body:
|
|
876
|
+
if body["scope"] not in ("user", "project"):
|
|
877
|
+
raise RuntimeErrorResponse(400, "Invalid memory scope")
|
|
878
|
+
fields["scope"] = body["scope"]
|