Mercurial
comparison mrjunejune/inference/copilot_sidecar.py @ 260:1f9877b637e9
Add Copilot-powered cyberpunk JRPG chat
Integrate the production JRPG chat with Seobeo streaming, Deita persistence, and a Bazel-managed Copilot SDK and LiteLLM inference stack.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Wed, 05 Aug 2026 09:19:41 -0700 |
| parents | |
| children | 056790c4fb0d |
comparison
equal
deleted
inserted
replaced
| 259:667156fcd3e3 | 260:1f9877b637e9 |
|---|---|
| 1 from __future__ import annotations | |
| 2 | |
| 3 import argparse | |
| 4 import asyncio | |
| 5 import json | |
| 6 import os | |
| 7 import sys | |
| 8 import time | |
| 9 from contextlib import asynccontextmanager | |
| 10 from dataclasses import dataclass, field | |
| 11 from typing import Any, AsyncIterator, Awaitable, Callable, Protocol | |
| 12 | |
| 13 from copilot import CopilotClient, ProviderConfig, RuntimeConnection | |
| 14 from copilot.rpc import PermissionDecisionReject | |
| 15 | |
| 16 | |
| 17 JsonObject = dict[str, Any] | |
| 18 Emit = Callable[[JsonObject], Awaitable[None]] | |
| 19 | |
| 20 | |
| 21 class Session(Protocol): | |
| 22 session_id: str | |
| 23 | |
| 24 def on(self, handler: Callable[[Any], None]) -> Callable[[], None]: ... | |
| 25 | |
| 26 async def send(self, prompt: str) -> str: ... | |
| 27 | |
| 28 async def abort(self) -> None: ... | |
| 29 | |
| 30 async def disconnect(self) -> None: ... | |
| 31 | |
| 32 | |
| 33 class Client(Protocol): | |
| 34 async def start(self) -> None: ... | |
| 35 | |
| 36 async def stop(self) -> None: ... | |
| 37 | |
| 38 async def create_session(self, **kwargs: Any) -> Session: ... | |
| 39 | |
| 40 async def resume_session(self, session_id: str, **kwargs: Any) -> Session: ... | |
| 41 | |
| 42 async def delete_session(self, session_id: str) -> None: ... | |
| 43 | |
| 44 | |
| 45 @dataclass(frozen=True) | |
| 46 class SidecarConfig: | |
| 47 base_url: str | |
| 48 model: str | |
| 49 wire_api: str | |
| 50 base_directory: str | |
| 51 api_key: str | None = None | |
| 52 idle_timeout_seconds: int = 900 | |
| 53 max_sessions: int = 64 | |
| 54 | |
| 55 @classmethod | |
| 56 def from_environment(cls) -> SidecarConfig: | |
| 57 base_url = os.environ.get("LITELLM_BASE_URL", "").strip() | |
| 58 model = os.environ.get("LITELLM_MODEL", "").strip() | |
| 59 wire_api = os.environ.get("LITELLM_WIRE_API", "").strip() | |
| 60 base_directory = os.environ.get("COPILOT_SIDECAR_HOME", "").strip() | |
| 61 missing = [ | |
| 62 name | |
| 63 for name, value in ( | |
| 64 ("LITELLM_BASE_URL", base_url), | |
| 65 ("LITELLM_MODEL", model), | |
| 66 ("LITELLM_WIRE_API", wire_api), | |
| 67 ("COPILOT_SIDECAR_HOME", base_directory), | |
| 68 ) | |
| 69 if not value | |
| 70 ] | |
| 71 if missing: | |
| 72 raise ValueError(f"missing required environment: {', '.join(missing)}") | |
| 73 if wire_api not in ("completions", "responses"): | |
| 74 raise ValueError("LITELLM_WIRE_API must be 'completions' or 'responses'") | |
| 75 return cls( | |
| 76 base_url=base_url, | |
| 77 model=model, | |
| 78 wire_api=wire_api, | |
| 79 base_directory=os.path.abspath(base_directory), | |
| 80 api_key=os.environ.get("LITELLM_API_KEY") or None, | |
| 81 idle_timeout_seconds=max( | |
| 82 1, | |
| 83 int(os.environ.get("COPILOT_SESSION_IDLE_SECONDS", "900")), | |
| 84 ), | |
| 85 max_sessions=max( | |
| 86 1, | |
| 87 int(os.environ.get("COPILOT_MAX_SESSIONS", "64")), | |
| 88 ), | |
| 89 ) | |
| 90 | |
| 91 | |
| 92 @dataclass | |
| 93 class Turn: | |
| 94 request_id: str | |
| 95 done: bool = False | |
| 96 | |
| 97 | |
| 98 @dataclass | |
| 99 class Conversation: | |
| 100 session: Session | |
| 101 unsubscribe: Callable[[], None] | |
| 102 active: Turn | None = None | |
| 103 last_used: float = field(default_factory=time.monotonic) | |
| 104 | |
| 105 | |
| 106 @dataclass | |
| 107 class ConversationGate: | |
| 108 lock: asyncio.Lock | |
| 109 users: int = 0 | |
| 110 | |
| 111 | |
| 112 def deny_permission(*_args: Any, **_kwargs: Any) -> PermissionDecisionReject: | |
| 113 return PermissionDecisionReject(feedback="The inference sidecar denies all permissions.") | |
| 114 | |
| 115 | |
| 116 class Sidecar: | |
| 117 def __init__(self, client: Client, config: SidecarConfig, emit: Emit): | |
| 118 self._client = client | |
| 119 self._config = config | |
| 120 self._emit = emit | |
| 121 self._conversations: dict[str, Conversation] = {} | |
| 122 self._conversations_lock = asyncio.Lock() | |
| 123 self._conversation_gates: dict[str, ConversationGate] = {} | |
| 124 self._conversation_gates_lock = asyncio.Lock() | |
| 125 self._dispatch_condition = asyncio.Condition() | |
| 126 self._active_dispatches = 0 | |
| 127 self._event_tasks: set[asyncio.Task[None]] = set() | |
| 128 self._cleanup_task: asyncio.Task[None] | None = None | |
| 129 self._started = False | |
| 130 self.shutting_down = False | |
| 131 | |
| 132 async def start(self) -> None: | |
| 133 if not self._started: | |
| 134 await self._client.start() | |
| 135 self._started = True | |
| 136 self._cleanup_task = asyncio.create_task(self._cleanup_loop()) | |
| 137 | |
| 138 async def announce_ready(self) -> None: | |
| 139 await self._send("ready", None, None, status="ok") | |
| 140 | |
| 141 async def dispatch(self, command: JsonObject) -> None: | |
| 142 command_name = command.get("command") | |
| 143 request_id = command.get("request_id") | |
| 144 conversation_id = command.get("conversation_id") | |
| 145 if not isinstance(request_id, str) or not request_id: | |
| 146 normalized_request_id = request_id if isinstance(request_id, str) else None | |
| 147 normalized_conversation_id = ( | |
| 148 conversation_id if isinstance(conversation_id, str) else None | |
| 149 ) | |
| 150 await self._send( | |
| 151 "turn.error", | |
| 152 normalized_request_id, | |
| 153 normalized_conversation_id, | |
| 154 error={"code": "invalid_request", "message": "request_id is required"}, | |
| 155 ) | |
| 156 await self._send( | |
| 157 "turn.done", | |
| 158 normalized_request_id, | |
| 159 normalized_conversation_id, | |
| 160 failed=True, | |
| 161 ) | |
| 162 return | |
| 163 | |
| 164 if command_name == "shutdown": | |
| 165 await self._shutdown(request_id, conversation_id) | |
| 166 return | |
| 167 | |
| 168 if not await self._begin_dispatch(): | |
| 169 await self._fail( | |
| 170 request_id, | |
| 171 conversation_id if isinstance(conversation_id, str) else None, | |
| 172 "shutting_down", | |
| 173 "the sidecar is shutting down", | |
| 174 ) | |
| 175 return | |
| 176 try: | |
| 177 if command_name == "health": | |
| 178 await self._send( | |
| 179 "ready", | |
| 180 request_id, | |
| 181 conversation_id if isinstance(conversation_id, str) else None, | |
| 182 status="ok", | |
| 183 ) | |
| 184 return | |
| 185 if not isinstance(conversation_id, str) or not conversation_id: | |
| 186 await self._fail( | |
| 187 request_id, None, "invalid_request", "conversation_id is required" | |
| 188 ) | |
| 189 return | |
| 190 | |
| 191 await self.evict_idle_sessions( | |
| 192 exclude=conversation_id, | |
| 193 reserve=1 if command_name == "turn.start" else 0, | |
| 194 ) | |
| 195 async with self._conversation_command_lock(conversation_id): | |
| 196 if command_name == "turn.start": | |
| 197 prompt = command.get("prompt") | |
| 198 if not isinstance(prompt, str) or not prompt: | |
| 199 await self._fail( | |
| 200 request_id, | |
| 201 conversation_id, | |
| 202 "invalid_request", | |
| 203 "prompt is required", | |
| 204 ) | |
| 205 return | |
| 206 await self._start_turn(request_id, conversation_id, prompt) | |
| 207 elif command_name == "turn.abort": | |
| 208 await self._abort_turn(request_id, conversation_id) | |
| 209 elif command_name == "conversation.delete": | |
| 210 await self._delete_conversation(request_id, conversation_id) | |
| 211 else: | |
| 212 await self._fail( | |
| 213 request_id, | |
| 214 conversation_id, | |
| 215 "unknown_command", | |
| 216 f"unsupported command: {command_name!r}", | |
| 217 ) | |
| 218 finally: | |
| 219 await self._end_dispatch() | |
| 220 | |
| 221 @asynccontextmanager | |
| 222 async def _conversation_command_lock( | |
| 223 self, conversation_id: str | |
| 224 ) -> AsyncIterator[None]: | |
| 225 async with self._conversation_gates_lock: | |
| 226 gate = self._conversation_gates.get(conversation_id) | |
| 227 if gate is None: | |
| 228 gate = ConversationGate(lock=asyncio.Lock()) | |
| 229 self._conversation_gates[conversation_id] = gate | |
| 230 gate.users += 1 | |
| 231 try: | |
| 232 async with gate.lock: | |
| 233 yield | |
| 234 finally: | |
| 235 async with self._conversation_gates_lock: | |
| 236 gate.users -= 1 | |
| 237 if ( | |
| 238 gate.users == 0 | |
| 239 and self._conversation_gates.get(conversation_id) is gate | |
| 240 ): | |
| 241 del self._conversation_gates[conversation_id] | |
| 242 | |
| 243 async def _begin_dispatch(self) -> bool: | |
| 244 async with self._dispatch_condition: | |
| 245 if self.shutting_down: | |
| 246 return False | |
| 247 self._active_dispatches += 1 | |
| 248 return True | |
| 249 | |
| 250 async def _end_dispatch(self) -> None: | |
| 251 async with self._dispatch_condition: | |
| 252 self._active_dispatches -= 1 | |
| 253 if self._active_dispatches == 0: | |
| 254 self._dispatch_condition.notify_all() | |
| 255 | |
| 256 async def _session_options(self) -> JsonObject: | |
| 257 provider: ProviderConfig = { | |
| 258 "type": "openai", | |
| 259 "base_url": self._config.base_url, | |
| 260 "wire_api": self._config.wire_api, | |
| 261 } | |
| 262 if self._config.api_key is not None: | |
| 263 provider["api_key"] = self._config.api_key | |
| 264 return { | |
| 265 "on_permission_request": deny_permission, | |
| 266 "model": self._config.model, | |
| 267 "provider": provider, | |
| 268 "streaming": True, | |
| 269 "tools": [], | |
| 270 "available_tools": [], | |
| 271 "mcp_servers": {}, | |
| 272 "enable_config_discovery": False, | |
| 273 "skip_custom_instructions": True, | |
| 274 "enable_skills": False, | |
| 275 "enable_session_store": True, | |
| 276 } | |
| 277 | |
| 278 async def _get_conversation(self, conversation_id: str) -> Conversation: | |
| 279 async with self._conversations_lock: | |
| 280 existing = self._conversations.get(conversation_id) | |
| 281 if existing is not None: | |
| 282 existing.last_used = time.monotonic() | |
| 283 return existing | |
| 284 if len(self._conversations) >= self._config.max_sessions: | |
| 285 raise RuntimeError("Copilot session capacity exhausted") | |
| 286 | |
| 287 options = await self._session_options() | |
| 288 try: | |
| 289 session = await self._client.resume_session(conversation_id, **options) | |
| 290 except Exception: | |
| 291 session = await self._client.create_session( | |
| 292 session_id=conversation_id, **options | |
| 293 ) | |
| 294 if session is None: | |
| 295 session = await self._client.create_session( | |
| 296 session_id=conversation_id, **options | |
| 297 ) | |
| 298 | |
| 299 def handle_event(event: Any) -> None: | |
| 300 task = asyncio.create_task(self._handle_event(conversation_id, event)) | |
| 301 self._event_tasks.add(task) | |
| 302 task.add_done_callback(self._event_tasks.discard) | |
| 303 | |
| 304 unsubscribe = session.on(handle_event) | |
| 305 conversation = Conversation(session=session, unsubscribe=unsubscribe) | |
| 306 self._conversations[conversation_id] = conversation | |
| 307 return conversation | |
| 308 | |
| 309 async def _cleanup_loop(self) -> None: | |
| 310 interval = min(60, self._config.idle_timeout_seconds) | |
| 311 try: | |
| 312 while not self.shutting_down: | |
| 313 await asyncio.sleep(interval) | |
| 314 try: | |
| 315 await self.evict_idle_sessions() | |
| 316 except Exception as error: | |
| 317 print( | |
| 318 f"copilot-sidecar: session cleanup failed: {error}", | |
| 319 file=sys.stderr, | |
| 320 ) | |
| 321 except asyncio.CancelledError: | |
| 322 return | |
| 323 | |
| 324 async def evict_idle_sessions( | |
| 325 self, | |
| 326 exclude: str | None = None, | |
| 327 reserve: int = 0, | |
| 328 ) -> None: | |
| 329 now = time.monotonic() | |
| 330 async with self._conversations_lock: | |
| 331 inactive = sorted( | |
| 332 ( | |
| 333 (conversation.last_used, conversation_id) | |
| 334 for conversation_id, conversation | |
| 335 in self._conversations.items() | |
| 336 if conversation.active is None | |
| 337 and conversation_id != exclude | |
| 338 ) | |
| 339 ) | |
| 340 overflow = max( | |
| 341 0, | |
| 342 len(self._conversations) + reserve - | |
| 343 self._config.max_sessions, | |
| 344 ) | |
| 345 candidates = { | |
| 346 conversation_id | |
| 347 for index, (last_used, conversation_id) in enumerate(inactive) | |
| 348 if index < overflow | |
| 349 or now - last_used >= self._config.idle_timeout_seconds | |
| 350 } | |
| 351 | |
| 352 for conversation_id in candidates: | |
| 353 async with self._conversation_command_lock(conversation_id): | |
| 354 async with self._conversations_lock: | |
| 355 conversation = self._conversations.get(conversation_id) | |
| 356 if conversation is None or conversation.active is not None: | |
| 357 continue | |
| 358 over_limit = ( | |
| 359 len(self._conversations) + reserve > | |
| 360 self._config.max_sessions | |
| 361 ) | |
| 362 expired = ( | |
| 363 time.monotonic() - conversation.last_used | |
| 364 >= self._config.idle_timeout_seconds | |
| 365 ) | |
| 366 if not over_limit and not expired: | |
| 367 continue | |
| 368 self._conversations.pop(conversation_id, None) | |
| 369 try: | |
| 370 conversation.unsubscribe() | |
| 371 except Exception as error: | |
| 372 print( | |
| 373 f"copilot-sidecar: unsubscribe failed: {error}", | |
| 374 file=sys.stderr, | |
| 375 ) | |
| 376 try: | |
| 377 await conversation.session.disconnect() | |
| 378 except Exception as error: | |
| 379 print( | |
| 380 f"copilot-sidecar: disconnect failed: {error}", | |
| 381 file=sys.stderr, | |
| 382 ) | |
| 383 | |
| 384 async def _start_turn( | |
| 385 self, request_id: str, conversation_id: str, prompt: str | |
| 386 ) -> None: | |
| 387 try: | |
| 388 conversation = await self._get_conversation(conversation_id) | |
| 389 if conversation.active is not None and not conversation.active.done: | |
| 390 await self._fail( | |
| 391 request_id, | |
| 392 conversation_id, | |
| 393 "turn_in_progress", | |
| 394 "the conversation already has an active turn", | |
| 395 ) | |
| 396 return | |
| 397 conversation.active = Turn(request_id=request_id) | |
| 398 await self._send("turn.accepted", request_id, conversation_id) | |
| 399 await conversation.session.send(prompt) | |
| 400 except Exception as error: | |
| 401 await self._finish_with_error(request_id, conversation_id, error) | |
| 402 | |
| 403 async def _abort_turn(self, request_id: str, conversation_id: str) -> None: | |
| 404 conversation = self._conversations.get(conversation_id) | |
| 405 if conversation is None or conversation.active is None: | |
| 406 await self._fail( | |
| 407 request_id, conversation_id, "no_active_turn", "no active turn to abort" | |
| 408 ) | |
| 409 return | |
| 410 try: | |
| 411 target_request_id = conversation.active.request_id | |
| 412 await self._send( | |
| 413 "turn.accepted", | |
| 414 request_id, | |
| 415 conversation_id, | |
| 416 action="abort", | |
| 417 target_request_id=target_request_id, | |
| 418 ) | |
| 419 await conversation.session.abort() | |
| 420 await self._send( | |
| 421 "turn.done", | |
| 422 request_id, | |
| 423 conversation_id, | |
| 424 action="abort", | |
| 425 target_request_id=target_request_id, | |
| 426 ) | |
| 427 except Exception as error: | |
| 428 await self._fail(request_id, conversation_id, "sdk_error", str(error)) | |
| 429 | |
| 430 async def _delete_conversation( | |
| 431 self, request_id: str, conversation_id: str | |
| 432 ) -> None: | |
| 433 try: | |
| 434 async with self._conversations_lock: | |
| 435 conversation = self._conversations.pop(conversation_id, None) | |
| 436 if conversation is not None: | |
| 437 if conversation.active is not None and not conversation.active.done: | |
| 438 await self._finish_turn( | |
| 439 conversation_id, conversation.active, aborted=True, deleted=True | |
| 440 ) | |
| 441 conversation.unsubscribe() | |
| 442 await conversation.session.disconnect() | |
| 443 session_id = conversation.session.session_id | |
| 444 else: | |
| 445 session_id = conversation_id | |
| 446 await self._client.delete_session(session_id) | |
| 447 await self._send( | |
| 448 "turn.done", request_id, conversation_id, action="conversation.delete" | |
| 449 ) | |
| 450 except Exception as error: | |
| 451 await self._finish_with_error(request_id, conversation_id, error) | |
| 452 | |
| 453 async def _handle_event(self, conversation_id: str, event: Any) -> None: | |
| 454 conversation = self._conversations.get(conversation_id) | |
| 455 if conversation is None or conversation.active is None: | |
| 456 return | |
| 457 turn = conversation.active | |
| 458 event_type = getattr(getattr(event, "type", None), "value", None) | |
| 459 data = getattr(event, "data", None) | |
| 460 | |
| 461 if event_type == "assistant.message_delta": | |
| 462 await self._send( | |
| 463 "assistant.delta", | |
| 464 turn.request_id, | |
| 465 conversation_id, | |
| 466 delta=getattr(data, "delta_content", ""), | |
| 467 message_id=getattr(data, "message_id", None), | |
| 468 ) | |
| 469 elif event_type == "assistant.message": | |
| 470 await self._send( | |
| 471 "assistant.completed", | |
| 472 turn.request_id, | |
| 473 conversation_id, | |
| 474 content=getattr(data, "content", ""), | |
| 475 message_id=getattr(data, "message_id", None), | |
| 476 model=getattr(data, "model", None), | |
| 477 ) | |
| 478 elif event_type == "assistant.usage": | |
| 479 usage = { | |
| 480 name: getattr(data, name, None) | |
| 481 for name in ( | |
| 482 "model", | |
| 483 "input_tokens", | |
| 484 "output_tokens", | |
| 485 "reasoning_tokens", | |
| 486 "cache_read_tokens", | |
| 487 "cache_write_tokens", | |
| 488 "finish_reason", | |
| 489 "cost", | |
| 490 ) | |
| 491 if getattr(data, name, None) is not None | |
| 492 } | |
| 493 await self._send( | |
| 494 "assistant.usage", | |
| 495 turn.request_id, | |
| 496 conversation_id, | |
| 497 usage=usage, | |
| 498 ) | |
| 499 elif event_type == "session.error": | |
| 500 error = { | |
| 501 "code": getattr(data, "error_code", None) | |
| 502 or getattr(data, "error_type", "sdk_error"), | |
| 503 "message": getattr(data, "message", "Copilot session error"), | |
| 504 } | |
| 505 if getattr(data, "status_code", None) is not None: | |
| 506 error["status_code"] = data.status_code | |
| 507 await self._send( | |
| 508 "turn.error", | |
| 509 turn.request_id, | |
| 510 conversation_id, | |
| 511 error=error, | |
| 512 ) | |
| 513 await self._finish_turn(conversation_id, turn, failed=True) | |
| 514 elif event_type == "session.idle": | |
| 515 await self._finish_turn( | |
| 516 conversation_id, turn, aborted=bool(getattr(data, "aborted", False)) | |
| 517 ) | |
| 518 | |
| 519 async def _finish_with_error( | |
| 520 self, request_id: str, conversation_id: str | None, error: Exception | |
| 521 ) -> None: | |
| 522 await self._send( | |
| 523 "turn.error", | |
| 524 request_id, | |
| 525 conversation_id, | |
| 526 error={"code": "sdk_error", "message": str(error)}, | |
| 527 ) | |
| 528 conversation = ( | |
| 529 self._conversations.get(conversation_id) | |
| 530 if conversation_id is not None | |
| 531 else None | |
| 532 ) | |
| 533 if conversation is not None and conversation.active is not None: | |
| 534 await self._finish_turn(conversation_id, conversation.active, failed=True) | |
| 535 else: | |
| 536 await self._send("turn.done", request_id, conversation_id, failed=True) | |
| 537 | |
| 538 async def _fail( | |
| 539 self, | |
| 540 request_id: str, | |
| 541 conversation_id: str | None, | |
| 542 code: str, | |
| 543 message: str, | |
| 544 ) -> None: | |
| 545 await self._send( | |
| 546 "turn.error", | |
| 547 request_id, | |
| 548 conversation_id, | |
| 549 error={"code": code, "message": message}, | |
| 550 ) | |
| 551 await self._send("turn.done", request_id, conversation_id, failed=True) | |
| 552 | |
| 553 async def _finish_turn( | |
| 554 self, conversation_id: str, turn: Turn, **fields: Any | |
| 555 ) -> None: | |
| 556 if turn.done: | |
| 557 return | |
| 558 turn.done = True | |
| 559 await self._send("turn.done", turn.request_id, conversation_id, **fields) | |
| 560 conversation = self._conversations.get(conversation_id) | |
| 561 if conversation is not None and conversation.active is turn: | |
| 562 conversation.active = None | |
| 563 conversation.last_used = time.monotonic() | |
| 564 | |
| 565 async def _shutdown( | |
| 566 self, request_id: str, conversation_id: Any | |
| 567 ) -> None: | |
| 568 async with self._dispatch_condition: | |
| 569 if self.shutting_down: | |
| 570 await self._send( | |
| 571 "turn.done", | |
| 572 request_id, | |
| 573 conversation_id if isinstance(conversation_id, str) else None, | |
| 574 action="shutdown", | |
| 575 already_in_progress=True, | |
| 576 ) | |
| 577 return | |
| 578 self.shutting_down = True | |
| 579 if self._cleanup_task is not None: | |
| 580 self._cleanup_task.cancel() | |
| 581 try: | |
| 582 await self._cleanup_task | |
| 583 except asyncio.CancelledError: | |
| 584 pass | |
| 585 self._cleanup_task = None | |
| 586 while self._active_dispatches: | |
| 587 await self._dispatch_condition.wait() | |
| 588 | |
| 589 await self.drain_events() | |
| 590 conversations = list(self._conversations.items()) | |
| 591 self._conversations.clear() | |
| 592 shutdown_errors: list[str] = [] | |
| 593 for item_conversation_id, conversation in conversations: | |
| 594 conversation.unsubscribe() | |
| 595 try: | |
| 596 if conversation.active is not None: | |
| 597 await self._finish_turn( | |
| 598 item_conversation_id, | |
| 599 conversation.active, | |
| 600 aborted=True, | |
| 601 shutdown=True, | |
| 602 ) | |
| 603 await conversation.session.disconnect() | |
| 604 except Exception as error: | |
| 605 shutdown_errors.append(str(error)) | |
| 606 if self._started: | |
| 607 try: | |
| 608 await self._client.stop() | |
| 609 except Exception as error: | |
| 610 shutdown_errors.append(str(error)) | |
| 611 self._started = False | |
| 612 if shutdown_errors: | |
| 613 await self._send( | |
| 614 "turn.error", | |
| 615 request_id, | |
| 616 conversation_id if isinstance(conversation_id, str) else None, | |
| 617 error={ | |
| 618 "code": "shutdown_error", | |
| 619 "message": "; ".join(shutdown_errors), | |
| 620 }, | |
| 621 ) | |
| 622 await self._send( | |
| 623 "turn.done", | |
| 624 request_id, | |
| 625 conversation_id if isinstance(conversation_id, str) else None, | |
| 626 action="shutdown", | |
| 627 failed=bool(shutdown_errors), | |
| 628 ) | |
| 629 | |
| 630 async def drain_events(self) -> None: | |
| 631 while self._event_tasks: | |
| 632 await asyncio.gather(*tuple(self._event_tasks)) | |
| 633 | |
| 634 async def _send( | |
| 635 self, | |
| 636 event_type: str, | |
| 637 request_id: str | None, | |
| 638 conversation_id: str | None, | |
| 639 **fields: Any, | |
| 640 ) -> None: | |
| 641 await self._emit( | |
| 642 { | |
| 643 "type": event_type, | |
| 644 "request_id": request_id, | |
| 645 "conversation_id": conversation_id, | |
| 646 **fields, | |
| 647 } | |
| 648 ) | |
| 649 | |
| 650 | |
| 651 class NdjsonWriter: | |
| 652 def __init__(self) -> None: | |
| 653 self._lock = asyncio.Lock() | |
| 654 | |
| 655 async def __call__(self, payload: JsonObject) -> None: | |
| 656 encoded = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) | |
| 657 async with self._lock: | |
| 658 sys.stdout.write(encoded + "\n") | |
| 659 sys.stdout.flush() | |
| 660 | |
| 661 | |
| 662 def build_client(cli_path: str, base_directory: str) -> CopilotClient: | |
| 663 resolved_path = os.path.abspath(cli_path) | |
| 664 if not os.path.isfile(resolved_path): | |
| 665 raise ValueError(f"Copilot CLI does not exist: {resolved_path}") | |
| 666 if not os.access(resolved_path, os.X_OK): | |
| 667 raise ValueError(f"Copilot CLI is not executable: {resolved_path}") | |
| 668 os.makedirs(base_directory, mode=0o700, exist_ok=True) | |
| 669 return CopilotClient( | |
| 670 connection=RuntimeConnection.for_stdio(path=resolved_path), | |
| 671 base_directory=base_directory, | |
| 672 use_logged_in_user=False, | |
| 673 log_level="error", | |
| 674 mode="empty", | |
| 675 ) | |
| 676 | |
| 677 | |
| 678 async def run(cli_path: str) -> int: | |
| 679 writer = NdjsonWriter() | |
| 680 config = SidecarConfig.from_environment() | |
| 681 sidecar = Sidecar(build_client(cli_path, config.base_directory), config, writer) | |
| 682 await sidecar.start() | |
| 683 await sidecar.announce_ready() | |
| 684 | |
| 685 tasks: set[asyncio.Task[None]] = set() | |
| 686 try: | |
| 687 while not sidecar.shutting_down: | |
| 688 line = await asyncio.to_thread(sys.stdin.readline) | |
| 689 if not line: | |
| 690 break | |
| 691 try: | |
| 692 command = json.loads(line) | |
| 693 if not isinstance(command, dict): | |
| 694 raise ValueError("command must be a JSON object") | |
| 695 except (json.JSONDecodeError, ValueError) as error: | |
| 696 for event_type in ("turn.error", "turn.done"): | |
| 697 payload: JsonObject = { | |
| 698 "type": event_type, | |
| 699 "request_id": None, | |
| 700 "conversation_id": None, | |
| 701 } | |
| 702 if event_type == "turn.error": | |
| 703 payload["error"] = { | |
| 704 "code": "invalid_json", | |
| 705 "message": str(error), | |
| 706 } | |
| 707 else: | |
| 708 payload["failed"] = True | |
| 709 await writer(payload) | |
| 710 continue | |
| 711 task = asyncio.create_task(sidecar.dispatch(command)) | |
| 712 tasks.add(task) | |
| 713 task.add_done_callback(tasks.discard) | |
| 714 if command.get("command") == "shutdown": | |
| 715 await task | |
| 716 break | |
| 717 if tasks: | |
| 718 await asyncio.gather(*tasks) | |
| 719 await sidecar.drain_events() | |
| 720 finally: | |
| 721 if not sidecar.shutting_down: | |
| 722 await sidecar.dispatch( | |
| 723 { | |
| 724 "command": "shutdown", | |
| 725 "request_id": "stdin-eof", | |
| 726 "conversation_id": None, | |
| 727 } | |
| 728 ) | |
| 729 return 0 | |
| 730 | |
| 731 | |
| 732 def main() -> None: | |
| 733 parser = argparse.ArgumentParser(description="Copilot SDK NDJSON sidecar") | |
| 734 parser.add_argument("copilot_cli", help="path to the Bazel-pinned Copilot CLI") | |
| 735 args = parser.parse_args() | |
| 736 try: | |
| 737 raise SystemExit(asyncio.run(run(args.copilot_cli))) | |
| 738 except (OSError, ValueError, RuntimeError) as error: | |
| 739 print(f"copilot-sidecar: {error}", file=sys.stderr) | |
| 740 raise SystemExit(2) from error | |
| 741 | |
| 742 | |
| 743 if __name__ == "__main__": | |
| 744 main() |