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,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"]