borgee-plugin-sdk 0.1.2__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,1680 @@
1
+ """Bounded BPP WebSocket connection engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import hashlib
8
+ import importlib.metadata
9
+ import inspect
10
+ import ipaddress
11
+ import json
12
+ import logging
13
+ import random
14
+ import time
15
+ import uuid
16
+ from collections import OrderedDict, deque
17
+ from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
18
+ from dataclasses import dataclass
19
+ from typing import Any, Literal, TypeGuard
20
+ from urllib.parse import urlsplit, urlunsplit
21
+
22
+ from websockets.asyncio.client import ClientConnection
23
+ from websockets.asyncio.client import connect as WebSocketConnect
24
+ from websockets.exceptions import ConnectionClosed, SecurityError
25
+
26
+ from .cursor import CursorStore
27
+ from .errors import (
28
+ BorgeeError,
29
+ NotOnlineError,
30
+ PermissionDeniedError,
31
+ PreResumeError,
32
+ ProtocolCapacityError,
33
+ ProtocolError,
34
+ ResumeAckError,
35
+ StaleDeliveryError,
36
+ )
37
+ from .models import (
38
+ ConfigApplyContext,
39
+ ConfigApplyResult,
40
+ ConfigApplyStatus,
41
+ ConfigUpdate,
42
+ ConnectionState,
43
+ ConnectionStatus,
44
+ DeliveryEvent,
45
+ FaultReason,
46
+ InboundKind,
47
+ InboundMessageEvent,
48
+ InboundReaction,
49
+ Message,
50
+ ReplayMode,
51
+ ReplaySummaryEvent,
52
+ user_from_wire,
53
+ )
54
+
55
+ MAX_INCOMING_BYTES = 5 << 20
56
+ MAX_INCOMING_FRAMES = 512
57
+ MAX_OUTBOUND_BYTES = 32 << 10
58
+ REGULAR_MAX_FRAMES = 64
59
+ REGULAR_MAX_BYTES = 2 << 20
60
+ RESERVED_MAX_FRAMES = 32
61
+ RESERVED_MAX_BYTES = 1 << 20
62
+ CONFIG_MAX_FRAMES = 32
63
+ CONFIG_MAX_BYTES = 1 << 20
64
+ REQUEST_MAX_FRAMES = 32
65
+ REQUEST_MAX_BYTES = 1 << 20
66
+ REQUEST_SINGLE_MAX_BYTES = 32 << 10
67
+ ACTION_PENDING_MAX = 256
68
+ PRE_RESUME_TIMEOUT = 8.0
69
+ RESUME_ACK_TIMEOUT = 5.0
70
+ REPLAY_ARRIVAL_TIMEOUT = 5.0
71
+ SERVER_REQUEST_DEADLINE = 8.0
72
+ SERVER_REQUEST_QUEUE_WAIT = 2.0
73
+ SERVER_REQUEST_HANDLER_TIMEOUT = 5.0
74
+ HANDLER_TASK_SLOT_LIMIT = 64
75
+
76
+ _DETACHED_TASK_GUARDIAN: set[asyncio.Task[Any]] = set()
77
+
78
+
79
+ ConfigHandler = Callable[[ConfigUpdate, ConfigApplyContext], Awaitable[ConfigApplyResult] | ConfigApplyResult]
80
+ ConfigObserver = Callable[[ConfigUpdate], Awaitable[None] | None]
81
+ ServerRequestHandler = Callable[[str, Any], Awaitable[Any] | Any]
82
+
83
+
84
+ @dataclass(slots=True)
85
+ class ConnectionOptions:
86
+ base_url: str
87
+ api_key: str
88
+ agent_id: str
89
+ plugin_id: str
90
+ cursor_store: CursorStore
91
+ allow_insecure_loopback: bool
92
+ replay_mode: ReplayMode
93
+ replay_latest_n: int
94
+ config_update_handler: ConfigHandler | None
95
+ config_update_observer: ConfigObserver | None
96
+ server_request_handler: ServerRequestHandler | None
97
+ logger: logging.Logger
98
+ action_timeout: float
99
+ config_apply_timeout: float
100
+ auto_reconnect: bool
101
+ reconnect_base: float
102
+ reconnect_max: float
103
+ heartbeat_interval: float
104
+ random_source: random.Random
105
+
106
+
107
+ @dataclass(slots=True)
108
+ class _QueuedFrame:
109
+ data: str
110
+ size: int
111
+ completion: asyncio.Future[None]
112
+ deadline: float | None
113
+ send_timeout: float
114
+
115
+
116
+ class _NoRedirectConnect(WebSocketConnect):
117
+ def process_redirect(self, exc: Exception) -> Exception | str:
118
+ raise SecurityError("WebSocket redirects are disabled")
119
+
120
+
121
+ def validate_websockets_runtime() -> None:
122
+ try:
123
+ version = importlib.metadata.version("websockets")
124
+ except importlib.metadata.PackageNotFoundError as exc:
125
+ raise PreResumeError("websockets 15.0.1 is required", "bpp.websocket_dependency_missing") from exc
126
+ if version != "15.0.1":
127
+ raise PreResumeError(
128
+ f"websockets 15.0.1 is required; found {version}",
129
+ "bpp.websocket_dependency_mismatch",
130
+ )
131
+ if not inspect.isclass(WebSocketConnect):
132
+ raise PreResumeError(
133
+ "websockets connect implementation is incompatible", "bpp.websocket_api_mismatch"
134
+ )
135
+ parameters = tuple(inspect.signature(WebSocketConnect.process_redirect).parameters)
136
+ if parameters != ("self", "exc"):
137
+ raise PreResumeError("websockets redirect hook is incompatible", "bpp.websocket_api_mismatch")
138
+
139
+
140
+ def plugin_websocket_url(base_url: str, *, allow_insecure_loopback: bool = False) -> tuple[str, str]:
141
+ try:
142
+ parsed = urlsplit(base_url)
143
+ port = parsed.port
144
+ except ValueError as exc:
145
+ raise ValueError("base_url is not a valid HTTP origin") from exc
146
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
147
+ raise ValueError("base_url must be an http or https origin")
148
+ if parsed.username is not None or parsed.password is not None:
149
+ raise ValueError("base_url must not contain user information")
150
+ if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
151
+ raise ValueError("base_url must be an origin without a path, query, or fragment")
152
+ if parsed.scheme == "http":
153
+ if not allow_insecure_loopback or not _is_loopback(parsed.hostname):
154
+ raise ValueError(
155
+ "insecure WebSocket transport is restricted to explicitly allowed loopback origins"
156
+ )
157
+ hostname = parsed.hostname.lower()
158
+ host = f"[{hostname}]" if ":" in hostname else hostname
159
+ netloc = host if port is None else f"{host}:{port}"
160
+ origin = urlunsplit((parsed.scheme, netloc, "", "", ""))
161
+ ws_scheme = "wss" if parsed.scheme == "https" else "ws"
162
+ return urlunsplit((ws_scheme, netloc, "/ws/plugin", "", "")), origin
163
+
164
+
165
+ def _is_loopback(hostname: str) -> bool:
166
+ if hostname.lower() == "localhost":
167
+ return True
168
+ try:
169
+ addr = ipaddress.ip_address(hostname)
170
+ return addr.is_loopback or addr.is_private
171
+ except ValueError:
172
+ return False
173
+
174
+
175
+ class _OutboundWriter:
176
+ def __init__(self, websocket: ClientConnection) -> None:
177
+ self._websocket = websocket
178
+ self._regular: deque[_QueuedFrame] = deque()
179
+ self._reserved: deque[_QueuedFrame] = deque()
180
+ self._regular_bytes = 0
181
+ self._reserved_bytes = 0
182
+ self._reserved_streak = 0
183
+ self._wakeup = asyncio.Event()
184
+ self._space = asyncio.Event()
185
+ self._space.set()
186
+ self._accepting = True
187
+ self._task = asyncio.create_task(self._run(), name="borgee-plugin-writer")
188
+
189
+ async def send(
190
+ self,
191
+ frame: dict[str, Any],
192
+ *,
193
+ lane: Literal["regular", "reserved"] = "regular",
194
+ deadline: float | None = None,
195
+ send_timeout: float = 0.1,
196
+ ) -> None:
197
+ item = self._encode(frame, deadline, send_timeout)
198
+ admission_deadline = min(time.monotonic() + 0.1, deadline or float("inf"))
199
+ while True:
200
+ if not self._accepting:
201
+ raise BorgeeError("connection writer is closed", "bpp.writer_closed")
202
+ if self._admit(item, lane):
203
+ break
204
+ remaining = admission_deadline - time.monotonic()
205
+ if remaining <= 0:
206
+ raise ProtocolCapacityError("outbound queue admission timed out")
207
+ self._space.clear()
208
+ try:
209
+ await asyncio.wait_for(self._space.wait(), remaining)
210
+ except TimeoutError as exc:
211
+ raise ProtocolCapacityError("outbound queue admission timed out") from exc
212
+ await item.completion
213
+
214
+ def send_nowait(
215
+ self,
216
+ frame: dict[str, Any],
217
+ *,
218
+ lane: Literal["regular", "reserved"] = "reserved",
219
+ deadline: float | None = None,
220
+ send_timeout: float = 0.1,
221
+ ) -> asyncio.Future[None]:
222
+ item = self._encode(frame, deadline, send_timeout)
223
+ if not self._accepting or not self._admit(item, lane):
224
+ raise ProtocolCapacityError("outbound queue capacity exceeded")
225
+ return item.completion
226
+
227
+ async def close(self, error: BaseException | None = None) -> None:
228
+ self._accepting = False
229
+ self._space.set()
230
+ failure = error or BorgeeError("connection writer closed", "bpp.writer_closed")
231
+ for queue in (self._regular, self._reserved):
232
+ while queue:
233
+ item = queue.popleft()
234
+ if not item.completion.done():
235
+ item.completion.set_exception(failure)
236
+ self._regular_bytes = 0
237
+ self._reserved_bytes = 0
238
+ self._task.cancel()
239
+ with contextlib.suppress(asyncio.CancelledError):
240
+ await self._task
241
+
242
+ def _encode(self, frame: dict[str, Any], deadline: float | None, send_timeout: float) -> _QueuedFrame:
243
+ data = json.dumps(frame, separators=(",", ":"), ensure_ascii=False)
244
+ size = len(data.encode("utf-8"))
245
+ if size > MAX_OUTBOUND_BYTES:
246
+ raise ProtocolCapacityError(f"outbound frame exceeds {MAX_OUTBOUND_BYTES} bytes")
247
+ return _QueuedFrame(data, size, asyncio.get_running_loop().create_future(), deadline, send_timeout)
248
+
249
+ def _admit(self, item: _QueuedFrame, lane: Literal["regular", "reserved"]) -> bool:
250
+ if lane == "regular":
251
+ if (
252
+ len(self._regular) >= REGULAR_MAX_FRAMES
253
+ or self._regular_bytes + item.size > REGULAR_MAX_BYTES
254
+ ):
255
+ return False
256
+ self._regular.append(item)
257
+ self._regular_bytes += item.size
258
+ else:
259
+ if (
260
+ len(self._reserved) >= RESERVED_MAX_FRAMES
261
+ or self._reserved_bytes + item.size > RESERVED_MAX_BYTES
262
+ ):
263
+ return False
264
+ self._reserved.append(item)
265
+ self._reserved_bytes += item.size
266
+ self._wakeup.set()
267
+ return True
268
+
269
+ def _take(self) -> _QueuedFrame | None:
270
+ if self._reserved and (not self._regular or self._reserved_streak < 8):
271
+ self._reserved_streak += 1
272
+ item = self._reserved.popleft()
273
+ self._reserved_bytes -= item.size
274
+ elif self._regular:
275
+ self._reserved_streak = 0
276
+ item = self._regular.popleft()
277
+ self._regular_bytes -= item.size
278
+ elif self._reserved:
279
+ self._reserved_streak += 1
280
+ item = self._reserved.popleft()
281
+ self._reserved_bytes -= item.size
282
+ else:
283
+ return None
284
+ self._space.set()
285
+ return item
286
+
287
+ async def _run(self) -> None:
288
+ while True:
289
+ item = self._take()
290
+ if item is None:
291
+ self._wakeup.clear()
292
+ await self._wakeup.wait()
293
+ continue
294
+ if item.deadline is not None and time.monotonic() >= item.deadline:
295
+ if not item.completion.done():
296
+ item.completion.set_exception(TimeoutError("outbound frame deadline expired"))
297
+ continue
298
+ timeout = item.send_timeout
299
+ if item.deadline is not None:
300
+ timeout = min(timeout, max(0.0, item.deadline - time.monotonic()))
301
+ try:
302
+ async with asyncio.timeout(timeout):
303
+ await self._websocket.send(item.data)
304
+ except BaseException as exc:
305
+ if not item.completion.done():
306
+ item.completion.set_exception(exc)
307
+ if isinstance(exc, asyncio.CancelledError):
308
+ raise
309
+ else:
310
+ if not item.completion.done():
311
+ item.completion.set_result(None)
312
+
313
+
314
+ @dataclass(slots=True)
315
+ class _PendingAction:
316
+ operation: str
317
+ future: asyncio.Future[Any]
318
+
319
+
320
+ @dataclass(slots=True)
321
+ class _BufferedDelivery:
322
+ event: DeliveryEvent
323
+ cursor: int
324
+ size: int
325
+ generation: int
326
+ replay: bool
327
+
328
+
329
+ class Delivery:
330
+ """One generation-bound inbound event awaiting an explicit disposition."""
331
+
332
+ def __init__(self, connection: BppConnection, buffered: _BufferedDelivery) -> None:
333
+ self.event = buffered.event
334
+ self.cursor = buffered.cursor
335
+ self._connection = connection
336
+ self._generation = buffered.generation
337
+ self._replay = buffered.replay
338
+ self._resolved: Literal["checkpoint", "fail"] | None = None
339
+
340
+ async def checkpoint(self) -> None:
341
+ if self._resolved == "checkpoint":
342
+ return
343
+ if self._resolved is not None:
344
+ raise BorgeeError("delivery was already failed", "bpp.delivery_resolved")
345
+ await self._connection._settle_delivery(self, checkpoint=True, error=None)
346
+ self._resolved = "checkpoint"
347
+
348
+ async def fail(self, error: BaseException | None = None) -> None:
349
+ if self._resolved == "fail":
350
+ return
351
+ if self._resolved is not None:
352
+ raise BorgeeError("delivery was already checkpointed", "bpp.delivery_resolved")
353
+ await self._connection._settle_delivery(self, checkpoint=False, error=error)
354
+ self._resolved = "fail"
355
+
356
+
357
+ class _RecoverableDisconnect(Exception):
358
+ pass
359
+
360
+
361
+ class _TerminalStop(Exception):
362
+ pass
363
+
364
+
365
+ class BppConnection:
366
+ def __init__(self, options: ConnectionOptions) -> None:
367
+ if not options.api_key:
368
+ raise ValueError("api_key must not be empty")
369
+ if options.replay_latest_n < 1 or options.replay_latest_n > 500:
370
+ raise ValueError("replay_latest_n must be between 1 and 500")
371
+ self._options = options
372
+ self._websocket_url, self._origin = plugin_websocket_url(
373
+ options.base_url,
374
+ allow_insecure_loopback=options.allow_insecure_loopback,
375
+ )
376
+ self._agent_id = options.agent_id
377
+ self._state = ConnectionState(ConnectionStatus.CONNECTING)
378
+ self._state_queue: asyncio.Queue[ConnectionState] = asyncio.Queue(maxsize=1)
379
+ self._online_ready = asyncio.Event()
380
+ # A "stop" signal fired for EITHER a terminal fault OR an explicit client close, so an
381
+ # action waiting for ONLINE fails fast with the real reason instead of the full timeout.
382
+ self._terminal_event = asyncio.Event()
383
+ self._supervisor_task: asyncio.Task[None] | None = None
384
+ self._observer_task: asyncio.Task[None] | None = None
385
+ self._initial_ready: asyncio.Future[None] | None = None
386
+ self._connect_future: asyncio.Future[None] | None = None
387
+ self._close_task: asyncio.Task[None] | None = None
388
+ self._lifecycle_lock = asyncio.Lock()
389
+ self._generation_cleanup_lock = asyncio.Lock()
390
+ self._ever_ready = False
391
+ self._ever_online = False
392
+ self._closing = False
393
+ self._closed_permanently = False
394
+ self._terminal_set = False
395
+ self._terminal_error: BaseException | None = None
396
+
397
+ self._generation_counter = 0
398
+ self._active_generation: int | None = None
399
+ self._websocket: ClientConnection | None = None
400
+ self._writer: _OutboundWriter | None = None
401
+ self._generation_tasks: set[asyncio.Task[Any]] = set()
402
+ self._detached_tasks: set[asyncio.Task[Any]] = set()
403
+ self._handler_slot_tasks: set[asyncio.Task[Any]] = set()
404
+ self._handler_slots_available = HANDLER_TASK_SLOT_LIMIT
405
+ self._background_failure: BaseException | None = None
406
+ self._resume_ack: asyncio.Future[None] | None = None
407
+ self._resume_request: tuple[ReplayMode, int] | None = None
408
+ self._resume_count = 0
409
+ self._replay_received = 0
410
+ self._replay_checkpointed = 0
411
+ self._resume_high_water = 0
412
+ self._resume_ack_seen = False
413
+ self._replay_arrived = asyncio.Event()
414
+
415
+ self._pending_actions: dict[str, _PendingAction] = {}
416
+ self._deliveries: deque[_BufferedDelivery] = deque()
417
+ self._deferred_live: deque[_BufferedDelivery] = deque()
418
+ self._seen_delivery_cursors: set[int] = set()
419
+ self._delivery_bytes = 0
420
+ self._delivery_wakeup = asyncio.Event()
421
+ self._delivery_consumer = False
422
+ self._active_delivery: Delivery | None = None
423
+ self._last_checkpoint = 0
424
+ self._last_received = 0
425
+ self._cursor_key = ""
426
+ self._cursor_write_lock = asyncio.Lock()
427
+
428
+ self._config_queue: asyncio.Queue[tuple[dict[str, Any], int, int]] | None = None
429
+ self._config_queue_bytes = 0
430
+ self._config_acks: OrderedDict[str, dict[str, Any]] = OrderedDict()
431
+ self._request_queue: asyncio.Queue[tuple[dict[str, Any], int, float, int]] | None = None
432
+ self._request_queue_bytes = 0
433
+ self._observer_queue: deque[tuple[ConfigUpdate, int]] = deque()
434
+ self._observer_bytes = 0
435
+ self._observer_wakeup = asyncio.Event()
436
+
437
+ @property
438
+ def agent_id(self) -> str:
439
+ return self._agent_id
440
+
441
+ @property
442
+ def state(self) -> ConnectionState:
443
+ return self._state
444
+
445
+ def _is_stopping(self) -> bool:
446
+ # Keep lifecycle checks behind a method call. Their values may change at
447
+ # every await boundary even though a static analyzer sees one task.
448
+ return self._terminal_set or self._closing
449
+
450
+ async def connect(self) -> None:
451
+ async with self._lifecycle_lock:
452
+ if self._closed_permanently:
453
+ raise BorgeeError("client is permanently closed", "bpp.client_closed")
454
+ if self._terminal_error is not None:
455
+ raise self._terminal_error
456
+ future = self._connect_future
457
+ if future is None:
458
+ validate_websockets_runtime()
459
+ self._closing = False
460
+ future = asyncio.get_running_loop().create_future()
461
+ future.add_done_callback(self._consume_future_exception)
462
+ self._connect_future = future
463
+ self._initial_ready = future
464
+ self._observer_task = asyncio.create_task(
465
+ self._run_config_observer(), name="borgee-config-observer"
466
+ )
467
+ self._supervisor_task = asyncio.create_task(
468
+ self._supervise(), name="borgee-plugin-supervisor"
469
+ )
470
+ await asyncio.shield(future)
471
+
472
+ async def close(self) -> None:
473
+ async with self._lifecycle_lock:
474
+ task = self._close_task
475
+ if task is None:
476
+ self._closed_permanently = True
477
+ task = asyncio.create_task(self._close_impl(), name="borgee-plugin-close")
478
+ self._close_task = task
479
+ await asyncio.shield(task)
480
+
481
+ async def _close_impl(self) -> None:
482
+ self._closing = True
483
+ self._terminal_event.set()
484
+ error = BorgeeError("client closed", "bpp.client_closed")
485
+ future = self._connect_future
486
+ if future is not None and not future.done():
487
+ future.set_exception(error)
488
+ task = self._supervisor_task
489
+ if task is not None and task is not asyncio.current_task():
490
+ task.cancel()
491
+ await self._invalidate_generation(error)
492
+ if task is not None and task is not asyncio.current_task():
493
+ with contextlib.suppress(asyncio.CancelledError):
494
+ await task
495
+ await self._cleanup_generation(error)
496
+ if self._observer_task is not None:
497
+ self._detach_task(self._observer_task)
498
+ self._set_state(ConnectionState(ConnectionStatus.CLOSED))
499
+ self._delivery_wakeup.set()
500
+
501
+ async def __aenter__(self) -> BppConnection:
502
+ await self.connect()
503
+ return self
504
+
505
+ async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
506
+ await self.close()
507
+
508
+ async def deliveries(self) -> AsyncIterator[Delivery]:
509
+ if self._delivery_consumer:
510
+ raise RuntimeError("deliveries() supports one consumer")
511
+ self._delivery_consumer = True
512
+ try:
513
+ while True:
514
+ delivery = await self._next_delivery()
515
+ if delivery is None:
516
+ return
517
+ yield delivery
518
+ finally:
519
+ self._delivery_consumer = False
520
+
521
+ async def state_changes(self) -> AsyncIterator[ConnectionState]:
522
+ while True:
523
+ state = await self._state_queue.get()
524
+ yield state
525
+ if state.status in {ConnectionStatus.ERROR, ConnectionStatus.CLOSED}:
526
+ return
527
+
528
+ async def perform(self, operation: str, payload: dict[str, Any]) -> Any:
529
+ return await self._perform(operation, payload, allow_pre_resume=False)
530
+
531
+ async def report_task_started(self, task_id: str, channel_id: str, subject: str) -> None:
532
+ self._require_online()
533
+ await self._send_regular(
534
+ {
535
+ "type": "task_started",
536
+ "task_id": task_id,
537
+ "agent_id": self._agent_id,
538
+ "channel_id": channel_id,
539
+ "subject": subject,
540
+ "started_at": int(time.time() * 1000),
541
+ }
542
+ )
543
+
544
+ async def report_task_finished(
545
+ self,
546
+ task_id: str,
547
+ channel_id: str,
548
+ outcome: str,
549
+ reason: str,
550
+ ) -> None:
551
+ self._require_online()
552
+ await self._send_regular(
553
+ {
554
+ "type": "task_finished",
555
+ "task_id": task_id,
556
+ "agent_id": self._agent_id,
557
+ "channel_id": channel_id,
558
+ "outcome": outcome,
559
+ "reason": reason,
560
+ "finished_at": int(time.time() * 1000),
561
+ }
562
+ )
563
+
564
+ def send_typing_nowait(self, channel_id: str) -> None:
565
+ """Fire-and-forget typing indicator frame."""
566
+ writer = self._writer
567
+ if writer is None or self._state.status is not ConnectionStatus.ONLINE:
568
+ return
569
+ try:
570
+ writer.send_nowait(
571
+ {"type": "typing", "channel_id": channel_id},
572
+ lane="regular",
573
+ )
574
+ except (ProtocolCapacityError, BorgeeError):
575
+ pass # best-effort, never fail the caller
576
+
577
+ async def _supervise(self) -> None:
578
+ reconnect_attempt = 0
579
+ initial = True
580
+ try:
581
+ while not self._is_stopping():
582
+ try:
583
+ if self._is_stopping():
584
+ return
585
+ await self._run_generation(initial=initial)
586
+ raise _RecoverableDisconnect("connection ended")
587
+ except asyncio.CancelledError:
588
+ raise
589
+ except _TerminalStop:
590
+ return
591
+ except _RecoverableDisconnect as exc:
592
+ if self._is_stopping():
593
+ return
594
+ if not self._ever_ready or not self._options.auto_reconnect:
595
+ raise PreResumeError(str(exc), "bpp.connection_lost") from exc
596
+ initial = False
597
+ reconnect_attempt += 1
598
+ self._set_state(ConnectionState(ConnectionStatus.RECONNECTING, attempt=reconnect_attempt))
599
+ cap = min(
600
+ self._options.reconnect_max,
601
+ self._options.reconnect_base * (2 ** (reconnect_attempt - 1)),
602
+ )
603
+ delay = cap * (0.5 + self._options.random_source.random() * 0.5)
604
+ await asyncio.sleep(delay)
605
+ if self._is_stopping():
606
+ return
607
+ initial = False
608
+ except asyncio.CancelledError:
609
+ raise
610
+ except BaseException as exc:
611
+ await self._terminal(exc)
612
+
613
+ async def _run_generation(self, *, initial: bool) -> None:
614
+ if self._is_stopping():
615
+ raise _TerminalStop
616
+ self._generation_counter += 1
617
+ generation = self._generation_counter
618
+ self._active_generation = generation
619
+ self._resume_ack_seen = False
620
+ self._background_failure = None
621
+ self._resume_count = 0
622
+ self._replay_received = 0
623
+ self._replay_checkpointed = 0
624
+ self._resume_high_water = 0
625
+ self._last_received = 0
626
+ self._replay_arrived.clear()
627
+ if initial:
628
+ self._set_state(ConnectionState(ConnectionStatus.CONNECTING))
629
+
630
+ receive_task: asyncio.Task[None] | None = None
631
+ opened = False
632
+ try:
633
+ async with asyncio.timeout(PRE_RESUME_TIMEOUT):
634
+ websocket = await _NoRedirectConnect(
635
+ self._websocket_url,
636
+ additional_headers={"Authorization": f"Bearer {self._options.api_key}"},
637
+ open_timeout=PRE_RESUME_TIMEOUT,
638
+ close_timeout=1,
639
+ ping_interval=None,
640
+ max_size=MAX_INCOMING_BYTES,
641
+ max_queue=4,
642
+ write_limit=MAX_OUTBOUND_BYTES,
643
+ )
644
+ if self._is_stopping():
645
+ websocket.transport.abort()
646
+ raise _TerminalStop
647
+ opened = True
648
+ self._websocket = websocket
649
+ self._writer = _OutboundWriter(websocket)
650
+ self._config_queue = asyncio.Queue(maxsize=CONFIG_MAX_FRAMES)
651
+ self._request_queue = asyncio.Queue(maxsize=REQUEST_MAX_FRAMES)
652
+ self._config_queue_bytes = 0
653
+ self._request_queue_bytes = 0
654
+ receive_task = self._spawn(
655
+ self._receive_loop(websocket, generation), "borgee-plugin-receiver"
656
+ )
657
+ self._spawn(self._config_worker(generation), "borgee-config-worker")
658
+ for index in range(8):
659
+ self._spawn(self._request_worker(generation), f"borgee-request-worker-{index}")
660
+
661
+ await self._send_regular(
662
+ {
663
+ "type": "connect",
664
+ "plugin_id": self._options.plugin_id,
665
+ "token": "",
666
+ "version": "bpp-1",
667
+ "capabilities": json.dumps(
668
+ ["semantic_action", "inbound_message", "read_file"], separators=(",", ":")
669
+ ),
670
+ }
671
+ )
672
+ if not self._agent_id:
673
+ identity = await self._perform("get_me", {}, allow_pre_resume=True)
674
+ try:
675
+ self._agent_id = user_from_wire(identity).id
676
+ except (TypeError, ValueError) as exc:
677
+ raise PreResumeError(
678
+ "authenticated agent identity is invalid", "bpp.identity_invalid"
679
+ ) from exc
680
+
681
+ digest = hashlib.sha256(self._origin.encode()).hexdigest()
682
+ self._cursor_key = f"{digest}:{self._agent_id}"
683
+ cursor = await self._read_cursor_detached_on_cancel()
684
+ if not isinstance(cursor, int) or isinstance(cursor, bool) or cursor < 0:
685
+ raise PreResumeError(
686
+ "cursor store returned an invalid cursor", "bpp.cursor_store_read_failed"
687
+ )
688
+ self._last_checkpoint = cursor
689
+ self._last_received = cursor
690
+ self._resume_request = (self._options.replay_mode, cursor)
691
+ self._resume_ack = asyncio.get_running_loop().create_future()
692
+ self._resume_ack.add_done_callback(self._consume_future_exception)
693
+ self._set_state(ConnectionState(ConnectionStatus.RESUMING))
694
+ resume: dict[str, Any] = {
695
+ "type": "session.resume",
696
+ "replay_mode": self._options.replay_mode.value,
697
+ "since_cursor": cursor,
698
+ }
699
+ if self._options.replay_mode is ReplayMode.LATEST_N:
700
+ resume["latest_n"] = self._options.replay_latest_n
701
+ await self._send_regular(resume)
702
+ try:
703
+ await asyncio.wait_for(asyncio.shield(self._resume_ack), RESUME_ACK_TIMEOUT)
704
+ except TimeoutError as exc:
705
+ raise ResumeAckError("session.resume_ack timed out", "bpp.resume_ack_timeout") from exc
706
+
707
+ if self._is_stopping():
708
+ raise _TerminalStop
709
+ if self._initial_ready is not None and not self._initial_ready.done():
710
+ self._initial_ready.set_result(None)
711
+ self._ever_ready = True
712
+ self._spawn(self._heartbeat_loop(generation), "borgee-heartbeat")
713
+ assert receive_task is not None
714
+ await receive_task
715
+ raise _RecoverableDisconnect("WebSocket closed")
716
+ except ConnectionClosed as exc:
717
+ close_code = _connection_close_code(exc)
718
+ if self._background_failure is not None:
719
+ raise self._background_failure from exc
720
+ if close_code == 4004:
721
+ raise PreResumeError("authentication failed", "bpp.auth_failed") from exc
722
+ if close_code == 1009:
723
+ raise ProtocolCapacityError("WebSocket peer rejected a frame for capacity") from exc
724
+ if close_code in {1002, 1003, 1007, 1008}:
725
+ raise ProtocolError(
726
+ f"WebSocket peer closed for a protocol violation ({close_code})",
727
+ "bpp.peer_protocol_close",
728
+ ) from exc
729
+ if not self._resume_ack_seen:
730
+ raise PreResumeError(
731
+ "WebSocket closed before resume acknowledgement", "bpp.pre_resume_closed"
732
+ ) from exc
733
+ raise _RecoverableDisconnect(f"WebSocket closed with code {close_code}") from exc
734
+ except TimeoutError as exc:
735
+ if not opened and self._ever_ready:
736
+ raise _RecoverableDisconnect("WebSocket dial timed out") from exc
737
+ if not self._resume_ack_seen:
738
+ raise PreResumeError("pre-resume deadline expired", "bpp.pre_resume_timeout") from exc
739
+ raise
740
+ except OSError as exc:
741
+ if not opened and self._ever_ready:
742
+ raise _RecoverableDisconnect("WebSocket dial failed") from exc
743
+ raise PreResumeError("WebSocket dial failed", "bpp.connect_failed") from exc
744
+ finally:
745
+ await self._invalidate_generation(
746
+ BorgeeError("connection generation ended", "bpp.connection_lost")
747
+ )
748
+ await self._cleanup_generation(BorgeeError("connection generation ended", "bpp.connection_lost"))
749
+
750
+ async def _receive_loop(self, websocket: ClientConnection, generation: int) -> None:
751
+ async for raw in websocket:
752
+ received_at = time.monotonic()
753
+ if generation != self._active_generation:
754
+ return
755
+ if isinstance(raw, bytes):
756
+ size = len(raw)
757
+ try:
758
+ text = raw.decode("utf-8")
759
+ except UnicodeDecodeError as exc:
760
+ raise ProtocolError("incoming frame is not UTF-8", "bpp.frame_encoding") from exc
761
+ else:
762
+ text = raw
763
+ size = len(raw.encode("utf-8"))
764
+ if size > MAX_INCOMING_BYTES:
765
+ raise ProtocolCapacityError("incoming frame exceeds five MiB")
766
+ try:
767
+ frame = json.loads(text)
768
+ except (TypeError, json.JSONDecodeError) as exc:
769
+ raise ProtocolError("incoming frame is not valid JSON", "bpp.frame_json") from exc
770
+ if not isinstance(frame, dict) or not isinstance(frame.get("type"), str):
771
+ raise ProtocolError("incoming frame has no string type", "bpp.frame_shape")
772
+ frame_type = frame["type"]
773
+ if frame_type == "ping":
774
+ self._send_reserved_nowait({"type": "pong"}, send_timeout=1.0)
775
+ elif frame_type in {"pong", "agent_toggle"}:
776
+ continue
777
+ elif frame_type == "session.resume_ack":
778
+ self._handle_resume_ack(frame, generation)
779
+ elif frame_type == "semantic_action_result":
780
+ self._handle_action_result(frame)
781
+ elif frame_type == "permission_denied":
782
+ self._handle_permission_denied(frame)
783
+ elif frame_type == "inbound_message":
784
+ self._handle_inbound(frame, size, generation)
785
+ elif frame_type == "session.summary":
786
+ self._handle_summary(frame, size, generation)
787
+ elif frame_type == "agent_config_update":
788
+ self._admit_config(frame, size, generation)
789
+ elif frame_type == "request":
790
+ self._admit_request(frame, size, received_at, generation)
791
+ else:
792
+ self._options.logger.debug("Ignored unknown BPP frame type %s", frame_type)
793
+
794
+ def _handle_resume_ack(self, frame: dict[str, Any], generation: int) -> None:
795
+ if generation != self._active_generation or self._resume_ack_seen or self._resume_request is None:
796
+ raise ResumeAckError("unexpected or duplicate session.resume_ack")
797
+ requested_mode, since_cursor = self._resume_request
798
+ replay_mode = frame.get("replay_mode")
799
+ count = frame.get("count")
800
+ high_water = frame.get("high_water")
801
+ reset = frame.get("reset")
802
+ reason = frame.get("reason")
803
+ if replay_mode != requested_mode.value:
804
+ raise ResumeAckError("session.resume_ack replay mode does not match the request")
805
+ if not _is_int(count) or count < 0 or count > MAX_INCOMING_FRAMES:
806
+ raise ResumeAckError("session.resume_ack count is outside the supported bounds")
807
+ if not _is_int(high_water) or high_water < 0:
808
+ raise ResumeAckError("session.resume_ack high_water is invalid")
809
+ if not isinstance(reset, bool) or not isinstance(reason, str):
810
+ raise ResumeAckError("session.resume_ack reset fields are invalid")
811
+ if reset:
812
+ raise ResumeAckError(f"server requires cursor reset: {reason}", "bpp.resume_reset_required")
813
+ if high_water < since_cursor:
814
+ raise ResumeAckError("session.resume_ack high_water precedes the requested cursor")
815
+ if requested_mode is ReplayMode.SUMMARY and count != 1:
816
+ raise ResumeAckError("summary resume must announce exactly one summary frame")
817
+ self._resume_count = count
818
+ self._resume_high_water = high_water
819
+ self._resume_ack_seen = True
820
+ waiter = self._resume_ack
821
+ if waiter is not None and not waiter.done():
822
+ waiter.set_result(None)
823
+ if count == 0:
824
+ self._replay_arrived.set()
825
+ self._spawn(self._finish_zero_replay(generation), "borgee-zero-replay-checkpoint")
826
+ else:
827
+ self._spawn(self._wait_for_replay_arrival(generation), "borgee-replay-arrival-timeout")
828
+
829
+ def _handle_action_result(self, frame: dict[str, Any]) -> None:
830
+ nonce = frame.get("nonce")
831
+ if not isinstance(nonce, str):
832
+ raise ProtocolError("semantic_action_result nonce is invalid")
833
+ pending = self._pending_actions.pop(nonce, None)
834
+ if pending is None:
835
+ return
836
+ status = frame.get("status")
837
+ if status == "ok":
838
+ payload = frame.get("payload")
839
+ if not isinstance(payload, str):
840
+ pending.future.set_exception(ProtocolError("semantic action payload is not a string"))
841
+ raise ProtocolError("semantic action payload is not a string")
842
+ try:
843
+ result = None if payload == "" else json.loads(payload)
844
+ except json.JSONDecodeError as exc:
845
+ pending.future.set_exception(ProtocolError("semantic action payload is invalid JSON"))
846
+ raise ProtocolError("semantic action payload is invalid JSON") from exc
847
+ if pending.operation == "send_message" and isinstance(result, dict):
848
+ cursor = frame.get("cursor")
849
+ if not _is_int(cursor) or cursor < 0:
850
+ pending.future.set_exception(ProtocolError("semantic action cursor is invalid"))
851
+ raise ProtocolError("semantic action cursor is invalid")
852
+ result = {**result, "cursor": cursor}
853
+ pending.future.set_result(result)
854
+ return
855
+ if status == "error":
856
+ code = frame.get("error")
857
+ if not isinstance(code, str) or not code:
858
+ pending.future.set_exception(ProtocolError("semantic action error code is invalid"))
859
+ raise ProtocolError("semantic action error code is invalid")
860
+ pending.future.set_exception(BorgeeError(f"semantic action {pending.operation} failed", code))
861
+ return
862
+ pending.future.set_exception(ProtocolError("semantic action result status is invalid"))
863
+ raise ProtocolError("semantic action result status is invalid")
864
+
865
+ def _handle_permission_denied(self, frame: dict[str, Any]) -> None:
866
+ request_id = frame.get("request_id")
867
+ action = frame.get("attempted_action")
868
+ capability = frame.get("required_capability")
869
+ scope = frame.get("current_scope")
870
+ if not isinstance(request_id, str):
871
+ raise ProtocolError("permission_denied request_id is invalid")
872
+ if not isinstance(action, str):
873
+ raise ProtocolError("permission_denied action is invalid")
874
+ if not isinstance(capability, str):
875
+ raise ProtocolError("permission_denied capability is invalid")
876
+ if not isinstance(scope, str):
877
+ raise ProtocolError("permission_denied frame is invalid")
878
+ pending = self._pending_actions.pop(request_id, None)
879
+ if pending is not None:
880
+ pending.future.set_exception(
881
+ PermissionDeniedError(
882
+ attempted_action=action,
883
+ required_capability=capability,
884
+ current_scope=scope,
885
+ )
886
+ )
887
+
888
+ def _handle_inbound(self, frame: dict[str, Any], size: int, generation: int) -> None:
889
+ if not self._resume_ack_seen:
890
+ raise ProtocolError("inbound message arrived before session.resume_ack")
891
+ if self._resume_request is not None and self._resume_request[0] is ReplayMode.SUMMARY:
892
+ raise ProtocolError("summary resume received an inbound message")
893
+ cursor = _required_int(frame, "cursor", minimum=1)
894
+ if cursor in self._seen_delivery_cursors:
895
+ return
896
+ replay_pending = self._replay_received < self._resume_count
897
+ if replay_pending:
898
+ if cursor <= self._last_checkpoint:
899
+ raise ProtocolError("inbound cursor is stale", "bpp.cursor_non_monotonic")
900
+ elif cursor < self._last_received:
901
+ raise ProtocolError("inbound cursor moved backwards", "bpp.cursor_non_monotonic")
902
+ elif cursor == self._last_received:
903
+ return
904
+ event = _map_inbound(frame, cursor)
905
+ self._seen_delivery_cursors.add(cursor)
906
+ self._last_received = max(self._last_received, cursor)
907
+ self._enqueue_delivery(event, cursor, size, generation)
908
+
909
+ def _handle_summary(self, frame: dict[str, Any], size: int, generation: int) -> None:
910
+ if not self._resume_ack_seen or self._resume_request is None:
911
+ raise ProtocolError("session summary arrived before session.resume_ack")
912
+ mode, since_cursor = self._resume_request
913
+ cursor = _required_int(frame, "cursor", minimum=0)
914
+ missed_count = _required_int(frame, "missed_count", minimum=0)
915
+ since = _required_int(frame, "since_cursor", minimum=0)
916
+ through = _required_int(frame, "through_cursor", minimum=0)
917
+ summary = frame.get("summary")
918
+ if (
919
+ mode is not ReplayMode.SUMMARY
920
+ or self._resume_count != 1
921
+ or self._replay_received != 0
922
+ or cursor != self._resume_high_water
923
+ or through != self._resume_high_water
924
+ or since != since_cursor
925
+ or not isinstance(summary, str)
926
+ ):
927
+ raise ProtocolError("session summary does not correlate with the resume request")
928
+ if cursor < self._last_checkpoint:
929
+ raise ProtocolError("summary cursor is stale", "bpp.cursor_non_monotonic")
930
+ event = ReplaySummaryEvent(cursor, missed_count, since, through, summary)
931
+ self._last_received = max(self._last_received, cursor)
932
+ self._enqueue_delivery(event, cursor, size, generation)
933
+
934
+ def _enqueue_delivery(self, event: DeliveryEvent, cursor: int, size: int, generation: int) -> None:
935
+ buffered_count = len(self._deliveries) + len(self._deferred_live)
936
+ if buffered_count >= MAX_INCOMING_FRAMES or self._delivery_bytes + size > MAX_INCOMING_BYTES:
937
+ raise ProtocolCapacityError("application cursor buffer capacity exceeded")
938
+ replay_pending = self._replay_received < self._resume_count
939
+ replay = replay_pending and cursor <= self._resume_high_water
940
+ if (
941
+ not replay_pending
942
+ and cursor <= self._resume_high_water
943
+ and self._state.status is ConnectionStatus.RESUMING
944
+ ):
945
+ raise ProtocolError("received an unannounced replay frame")
946
+ buffered = _BufferedDelivery(event, cursor, size, generation, replay)
947
+ self._delivery_bytes += size
948
+ if replay_pending and not replay:
949
+ self._deferred_live.append(buffered)
950
+ return
951
+ if replay:
952
+ self._replay_received += 1
953
+ self._deliveries.append(buffered)
954
+ if replay and self._replay_received == self._resume_count:
955
+ self._replay_arrived.set()
956
+ self._deliveries.extend(sorted(self._deferred_live, key=lambda item: item.cursor))
957
+ self._deferred_live.clear()
958
+ self._delivery_wakeup.set()
959
+
960
+ async def _wait_for_replay_arrival(self, generation: int) -> None:
961
+ try:
962
+ await asyncio.wait_for(self._replay_arrived.wait(), REPLAY_ARRIVAL_TIMEOUT)
963
+ except TimeoutError as exc:
964
+ if generation == self._active_generation:
965
+ raise ResumeAckError(
966
+ "declared replay frames did not arrive",
967
+ "bpp.replay_incomplete",
968
+ ) from exc
969
+
970
+ def _admit_config(self, frame: dict[str, Any], size: int, generation: int) -> None:
971
+ queue = self._config_queue
972
+ if (
973
+ queue is None
974
+ or queue.full()
975
+ or size > CONFIG_MAX_BYTES
976
+ or self._config_queue_bytes + size > CONFIG_MAX_BYTES
977
+ ):
978
+ raise ProtocolCapacityError("configuration queue capacity exceeded")
979
+ queue.put_nowait((frame, size, generation))
980
+ self._config_queue_bytes += size
981
+
982
+ def _admit_request(
983
+ self,
984
+ frame: dict[str, Any],
985
+ size: int,
986
+ received_at: float,
987
+ generation: int,
988
+ ) -> None:
989
+ request_id = frame.get("id")
990
+ if not isinstance(request_id, str) or not request_id:
991
+ raise ProtocolError("server request id is invalid")
992
+ deadline = received_at + SERVER_REQUEST_DEADLINE
993
+ if size > REQUEST_SINGLE_MAX_BYTES:
994
+ self._send_reserved_nowait(
995
+ {"type": "response", "id": request_id, "error": "server_request_too_large"},
996
+ deadline=deadline,
997
+ )
998
+ return
999
+ queue = self._request_queue
1000
+ if queue is None or queue.full() or self._request_queue_bytes + size > REQUEST_MAX_BYTES:
1001
+ self._send_reserved_nowait(
1002
+ {"type": "response", "id": request_id, "error": "server_request_busy"},
1003
+ deadline=deadline,
1004
+ )
1005
+ return
1006
+ queue.put_nowait((frame, size, received_at, generation))
1007
+ self._request_queue_bytes += size
1008
+
1009
+ async def _config_worker(self, generation: int) -> None:
1010
+ queue = self._config_queue
1011
+ assert queue is not None
1012
+ while generation == self._active_generation:
1013
+ frame, size, item_generation = await queue.get()
1014
+ self._config_queue_bytes -= size
1015
+ if item_generation != self._active_generation:
1016
+ continue
1017
+ update = self._decode_config_update(frame)
1018
+ cached = self._config_acks.get(update.idempotency_key)
1019
+ if cached is not None:
1020
+ self._config_acks.move_to_end(update.idempotency_key)
1021
+ await self._send_reserved(cached)
1022
+ continue
1023
+
1024
+ result = await self._apply_config(update, generation)
1025
+ if generation != self._active_generation:
1026
+ continue
1027
+ reason = (
1028
+ ""
1029
+ if result.status is ConfigApplyStatus.APPLIED
1030
+ else (result.reason or FaultReason.UNKNOWN).value
1031
+ )
1032
+ ack = {
1033
+ "type": "agent_config_ack",
1034
+ "cursor": update.cursor,
1035
+ "agent_id": update.agent_id,
1036
+ "schema_version": update.schema_version,
1037
+ "idempotency_key": update.idempotency_key,
1038
+ "status": result.status.value,
1039
+ "reason": reason,
1040
+ "applied_at": int(time.time() * 1000) if result.status is ConfigApplyStatus.APPLIED else 0,
1041
+ }
1042
+ self._config_acks[update.idempotency_key] = ack
1043
+ self._config_acks.move_to_end(update.idempotency_key)
1044
+ while len(self._config_acks) > 256:
1045
+ self._config_acks.popitem(last=False)
1046
+ self._enqueue_config_observer(update, size)
1047
+ await self._send_reserved(ack)
1048
+
1049
+ def _decode_config_update(self, frame: dict[str, Any]) -> ConfigUpdate:
1050
+ agent_id = frame.get("agent_id")
1051
+ idempotency_key = frame.get("idempotency_key")
1052
+ blob = frame.get("blob")
1053
+ if not isinstance(agent_id, str) or not isinstance(idempotency_key, str) or not idempotency_key:
1054
+ raise ProtocolError("configuration update identity is invalid")
1055
+ if not isinstance(blob, str):
1056
+ raise ProtocolError("configuration update blob is not a string")
1057
+ try:
1058
+ decoded_blob = json.loads(blob)
1059
+ except json.JSONDecodeError as exc:
1060
+ raise ProtocolError("configuration update blob is invalid JSON") from exc
1061
+ return ConfigUpdate(
1062
+ agent_id=agent_id,
1063
+ schema_version=_required_int(frame, "schema_version", minimum=0),
1064
+ blob=decoded_blob,
1065
+ idempotency_key=idempotency_key,
1066
+ cursor=_required_int(frame, "cursor", minimum=1),
1067
+ )
1068
+
1069
+ async def _apply_config(self, update: ConfigUpdate, generation: int) -> ConfigApplyResult:
1070
+ handler = self._options.config_update_handler
1071
+ if handler is None:
1072
+ return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.UNKNOWN)
1073
+ cancelled = asyncio.Event()
1074
+
1075
+ async def invoke() -> ConfigApplyResult:
1076
+ value = handler(update, ConfigApplyContext(cancelled))
1077
+ if inspect.isawaitable(value):
1078
+ value = await value
1079
+ return value
1080
+
1081
+ if not self._try_acquire_handler_slot():
1082
+ raise ProtocolCapacityError("configuration handler capacity exceeded")
1083
+ try:
1084
+ task = asyncio.create_task(invoke(), name="borgee-config-handler")
1085
+ except BaseException:
1086
+ self._handler_slots_available += 1
1087
+ raise
1088
+ self._register_handler_task(task)
1089
+ try:
1090
+ done, _ = await asyncio.wait({task}, timeout=self._options.config_apply_timeout)
1091
+ except asyncio.CancelledError:
1092
+ cancelled.set()
1093
+ self._detach_handler_task(task)
1094
+ raise
1095
+ if not done:
1096
+ cancelled.set()
1097
+ self._detach_handler_task(task)
1098
+ return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.RUNTIME_TIMEOUT)
1099
+ try:
1100
+ result = task.result()
1101
+ except BaseException:
1102
+ self._options.logger.exception("Configuration handler failed")
1103
+ return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.UNKNOWN)
1104
+ finally:
1105
+ cancelled.set()
1106
+ self._release_handler_task(task)
1107
+ if generation != self._active_generation or not isinstance(result, ConfigApplyResult):
1108
+ return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.UNKNOWN)
1109
+ status: object = result.status
1110
+ if not isinstance(status, ConfigApplyStatus):
1111
+ return ConfigApplyResult(ConfigApplyStatus.REJECTED, FaultReason.UNKNOWN)
1112
+ if status is ConfigApplyStatus.APPLIED:
1113
+ return ConfigApplyResult(ConfigApplyStatus.APPLIED)
1114
+ reason = result.reason if result.reason in set(FaultReason) else FaultReason.UNKNOWN
1115
+ return ConfigApplyResult(status, reason)
1116
+
1117
+ def _enqueue_config_observer(self, update: ConfigUpdate, size: int) -> None:
1118
+ if self._options.config_update_observer is None:
1119
+ return
1120
+ dropped = 0
1121
+ while self._observer_queue and (
1122
+ len(self._observer_queue) >= 64 or self._observer_bytes + size > (1 << 20)
1123
+ ):
1124
+ _, old_size = self._observer_queue.popleft()
1125
+ self._observer_bytes -= old_size
1126
+ dropped += 1
1127
+ if size > 1 << 20:
1128
+ dropped += 1
1129
+ else:
1130
+ self._observer_queue.append((update, size))
1131
+ self._observer_bytes += size
1132
+ self._observer_wakeup.set()
1133
+ if dropped:
1134
+ self._options.logger.warning("Dropped %d old configuration observer event(s)", dropped)
1135
+
1136
+ async def _run_config_observer(self) -> None:
1137
+ while True:
1138
+ if not self._observer_queue:
1139
+ self._observer_wakeup.clear()
1140
+ await self._observer_wakeup.wait()
1141
+ continue
1142
+ update, size = self._observer_queue.popleft()
1143
+ self._observer_bytes -= size
1144
+ observer = self._options.config_update_observer
1145
+ if observer is None:
1146
+ continue
1147
+ try:
1148
+ result = observer(update)
1149
+ if inspect.isawaitable(result):
1150
+ await result
1151
+ except asyncio.CancelledError:
1152
+ raise
1153
+ except BaseException:
1154
+ self._options.logger.exception("Configuration observer failed")
1155
+
1156
+ async def _request_worker(self, generation: int) -> None:
1157
+ queue = self._request_queue
1158
+ assert queue is not None
1159
+ while generation == self._active_generation:
1160
+ frame, size, received_at, item_generation = await queue.get()
1161
+ self._request_queue_bytes -= size
1162
+ if item_generation != self._active_generation:
1163
+ continue
1164
+ request_id = frame["id"]
1165
+ deadline = received_at + SERVER_REQUEST_DEADLINE
1166
+ if time.monotonic() - received_at >= SERVER_REQUEST_QUEUE_WAIT:
1167
+ await self._send_request_error(request_id, "server_request_busy", deadline, generation)
1168
+ continue
1169
+ handler = self._options.server_request_handler
1170
+ if handler is None:
1171
+ await self._send_request_error(request_id, "no_server_request_handler", deadline, generation)
1172
+ continue
1173
+ data = frame.get("data")
1174
+ action = data.get("action", "") if isinstance(data, dict) else ""
1175
+ if not isinstance(action, str):
1176
+ action = ""
1177
+ remaining = deadline - time.monotonic()
1178
+ handler_timeout = min(SERVER_REQUEST_HANDLER_TIMEOUT, remaining - 1.0)
1179
+ if handler_timeout <= 0:
1180
+ await self._send_request_error(request_id, "server_request_busy", deadline, generation)
1181
+ continue
1182
+
1183
+ if not self._try_acquire_handler_slot():
1184
+ await self._send_request_error(request_id, "server_request_busy", deadline, generation)
1185
+ continue
1186
+ try:
1187
+ task = asyncio.create_task(
1188
+ self._invoke_server_handler(handler, action, data),
1189
+ name="borgee-server-request-handler",
1190
+ )
1191
+ except BaseException:
1192
+ self._handler_slots_available += 1
1193
+ raise
1194
+ self._register_handler_task(task)
1195
+ try:
1196
+ done, _ = await asyncio.wait({task}, timeout=handler_timeout)
1197
+ except asyncio.CancelledError:
1198
+ self._detach_handler_task(task)
1199
+ raise
1200
+ if not done:
1201
+ self._detach_handler_task(task)
1202
+ await self._send_request_error(request_id, "server_request_timeout", deadline, generation)
1203
+ continue
1204
+ try:
1205
+ result = task.result()
1206
+ except BaseException:
1207
+ await self._send_request_error(request_id, "server_request_failed", deadline, generation)
1208
+ continue
1209
+ finally:
1210
+ self._release_handler_task(task)
1211
+ if generation != self._active_generation:
1212
+ continue
1213
+ response = {"type": "response", "id": request_id, "data": result}
1214
+ try:
1215
+ await self._send_reserved(response, deadline=deadline)
1216
+ except ProtocolCapacityError:
1217
+ await self._send_request_error(request_id, "server_request_too_large", deadline, generation)
1218
+
1219
+ @staticmethod
1220
+ async def _invoke_server_handler(
1221
+ handler: ServerRequestHandler,
1222
+ action: str,
1223
+ data: Any,
1224
+ ) -> Any:
1225
+ value = handler(action, data)
1226
+ return await value if inspect.isawaitable(value) else value
1227
+
1228
+ async def _send_request_error(
1229
+ self,
1230
+ request_id: str,
1231
+ code: str,
1232
+ deadline: float,
1233
+ generation: int,
1234
+ ) -> None:
1235
+ if generation != self._active_generation or time.monotonic() >= deadline:
1236
+ return
1237
+ await self._send_reserved(
1238
+ {"type": "response", "id": request_id, "error": code},
1239
+ deadline=deadline,
1240
+ )
1241
+
1242
+ async def _perform(self, operation: str, payload: dict[str, Any], *, allow_pre_resume: bool) -> Any:
1243
+ # One action_timeout bounds waiting-for-online AND the response wait together (FIX C).
1244
+ loop = asyncio.get_running_loop()
1245
+ deadline = loop.time() + self._options.action_timeout
1246
+ if not allow_pre_resume:
1247
+ await self._wait_until_online_for_action(operation, deadline)
1248
+ elif self._writer is None or operation != "get_me":
1249
+ raise NotOnlineError("only get_me is allowed before resume")
1250
+ if len(self._pending_actions) >= ACTION_PENDING_MAX:
1251
+ raise ProtocolCapacityError("too many pending semantic actions")
1252
+ nonce = str(uuid.uuid4())
1253
+ future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
1254
+ self._pending_actions[nonce] = _PendingAction(operation, future)
1255
+ try:
1256
+ await self._send_regular(
1257
+ {
1258
+ "type": "semantic_action",
1259
+ "agent_id": self._agent_id,
1260
+ "action": operation,
1261
+ "payload": json.dumps(payload, separators=(",", ":"), ensure_ascii=False),
1262
+ "nonce": nonce,
1263
+ }
1264
+ )
1265
+ remaining = max(0.0, deadline - loop.time())
1266
+ return await asyncio.wait_for(future, remaining)
1267
+ except TimeoutError as exc:
1268
+ raise BorgeeError(f"semantic action {operation} timed out", "bpp.action_timeout") from exc
1269
+ finally:
1270
+ self._pending_actions.pop(nonce, None)
1271
+
1272
+ async def _read_cursor_detached_on_cancel(self) -> int:
1273
+ task = asyncio.create_task(
1274
+ self._options.cursor_store.read(self._cursor_key),
1275
+ name="borgee-cursor-read",
1276
+ )
1277
+ try:
1278
+ return await asyncio.shield(task)
1279
+ except asyncio.CancelledError:
1280
+ self._detach_task(task)
1281
+ raise
1282
+
1283
+ @staticmethod
1284
+ def _consume_future_exception(future: asyncio.Future[Any]) -> None:
1285
+ if future.cancelled():
1286
+ return
1287
+ with contextlib.suppress(BaseException):
1288
+ future.exception()
1289
+
1290
+ async def _heartbeat_loop(self, generation: int) -> None:
1291
+ while generation == self._active_generation:
1292
+ await asyncio.sleep(self._options.heartbeat_interval)
1293
+ if generation != self._active_generation:
1294
+ return
1295
+ await self._send_regular(
1296
+ {
1297
+ "type": "heartbeat",
1298
+ "plugin_id": self._options.plugin_id,
1299
+ "agent_id": self._agent_id,
1300
+ "status": "online",
1301
+ "reason": "",
1302
+ "timestamp": int(time.time() * 1000),
1303
+ }
1304
+ )
1305
+
1306
+ async def _next_delivery(self) -> Delivery | None:
1307
+ while True:
1308
+ if self._active_delivery is None and self._deliveries:
1309
+ buffered = self._deliveries.popleft()
1310
+ self._delivery_bytes -= buffered.size
1311
+ delivery = Delivery(self, buffered)
1312
+ self._active_delivery = delivery
1313
+ return delivery
1314
+ if (
1315
+ self._active_delivery is None
1316
+ and not self._deliveries
1317
+ and (self._closing or self._terminal_error is not None)
1318
+ ):
1319
+ return None
1320
+ self._delivery_wakeup.clear()
1321
+ if self._active_delivery is None and self._deliveries:
1322
+ continue
1323
+ await self._delivery_wakeup.wait()
1324
+
1325
+ async def _settle_delivery(
1326
+ self,
1327
+ delivery: Delivery,
1328
+ *,
1329
+ checkpoint: bool,
1330
+ error: BaseException | None,
1331
+ ) -> None:
1332
+ try:
1333
+ async with self._cursor_write_lock:
1334
+ if delivery is not self._active_delivery or delivery._generation != self._active_generation:
1335
+ raise StaleDeliveryError()
1336
+ if checkpoint:
1337
+ if delivery.cursor < self._last_checkpoint:
1338
+ raise StaleDeliveryError("delivery cursor is older than the durable checkpoint")
1339
+ if delivery.cursor > self._last_checkpoint:
1340
+ await self._options.cursor_store.write(self._cursor_key, delivery.cursor)
1341
+ self._last_checkpoint = delivery.cursor
1342
+ if delivery._replay:
1343
+ self._replay_checkpointed += 1
1344
+ if self._replay_checkpointed == self._resume_count:
1345
+ await self._finish_replay_locked(delivery._generation)
1346
+ self._active_delivery = None
1347
+ self._delivery_wakeup.set()
1348
+ return
1349
+ self._active_delivery = None
1350
+ self._delivery_wakeup.set()
1351
+ except StaleDeliveryError:
1352
+ raise
1353
+ except Exception as exc:
1354
+ await self._terminal(exc)
1355
+ raise
1356
+ await self._terminal(error or BorgeeError("delivery failed", "bpp.delivery_failed"))
1357
+
1358
+ async def _finish_zero_replay(self, generation: int) -> None:
1359
+ async with self._cursor_write_lock:
1360
+ if generation != self._active_generation:
1361
+ return
1362
+ await self._finish_replay_locked(generation)
1363
+
1364
+ async def _finish_replay_locked(self, generation: int) -> None:
1365
+ if generation != self._active_generation:
1366
+ raise StaleDeliveryError()
1367
+ if self._resume_high_water > self._last_checkpoint:
1368
+ await self._options.cursor_store.write(self._cursor_key, self._resume_high_water)
1369
+ self._last_checkpoint = self._resume_high_water
1370
+ if generation == self._active_generation:
1371
+ self._set_state(ConnectionState(ConnectionStatus.ONLINE))
1372
+
1373
+ def _require_online(self) -> None:
1374
+ if self._state.status is not ConnectionStatus.ONLINE:
1375
+ raise NotOnlineError()
1376
+
1377
+ async def _wait_until_online_for_action(self, operation: str, deadline: float) -> None:
1378
+ if self._state.status is ConnectionStatus.ONLINE and self._writer is not None:
1379
+ return
1380
+ # Fail fast on a terminal fault or an explicit close so the real reason (e.g.
1381
+ # bpp.auth_failed) surfaces immediately, instead of blocking for the whole action_timeout.
1382
+ if self._is_stopping() or self._closed_permanently:
1383
+ raise self._terminal_error or NotOnlineError("connection is not available")
1384
+ if not self._ever_online:
1385
+ self._require_online()
1386
+ if not self._options.auto_reconnect:
1387
+ raise NotOnlineError("auto reconnect is disabled")
1388
+ loop = asyncio.get_running_loop()
1389
+ remaining = deadline - loop.time()
1390
+ if remaining <= 0:
1391
+ raise BorgeeError(f"semantic action {operation} timed out", "bpp.action_timeout")
1392
+ online_task = asyncio.ensure_future(self._online_ready.wait())
1393
+ terminal_task = asyncio.ensure_future(self._terminal_event.wait())
1394
+ try:
1395
+ done, _pending = await asyncio.wait(
1396
+ {online_task, terminal_task},
1397
+ timeout=remaining,
1398
+ return_when=asyncio.FIRST_COMPLETED,
1399
+ )
1400
+ finally:
1401
+ for task in (online_task, terminal_task):
1402
+ task.cancel()
1403
+ await asyncio.gather(online_task, terminal_task, return_exceptions=True)
1404
+ if self._is_stopping() or self._closed_permanently or terminal_task in done:
1405
+ raise self._terminal_error or BorgeeError("connection closed", "bpp.client_closed")
1406
+ if not done:
1407
+ raise BorgeeError(f"semantic action {operation} timed out", "bpp.action_timeout")
1408
+ if self._state.status is not ConnectionStatus.ONLINE or self._writer is None:
1409
+ raise NotOnlineError("connection left ONLINE before semantic action could be sent")
1410
+
1411
+ async def _send_regular(self, frame: dict[str, Any]) -> None:
1412
+ writer = self._writer
1413
+ if writer is None:
1414
+ raise NotOnlineError("WebSocket writer is unavailable")
1415
+ await writer.send(frame, lane="regular")
1416
+
1417
+ async def _send_reserved(
1418
+ self,
1419
+ frame: dict[str, Any],
1420
+ *,
1421
+ deadline: float | None = None,
1422
+ ) -> None:
1423
+ writer = self._writer
1424
+ if writer is None:
1425
+ raise NotOnlineError("WebSocket writer is unavailable")
1426
+ await writer.send(frame, lane="reserved", deadline=deadline)
1427
+
1428
+ def _send_reserved_nowait(
1429
+ self,
1430
+ frame: dict[str, Any],
1431
+ *,
1432
+ deadline: float | None = None,
1433
+ send_timeout: float = 0.1,
1434
+ ) -> None:
1435
+ writer = self._writer
1436
+ if writer is None:
1437
+ raise NotOnlineError("WebSocket writer is unavailable")
1438
+ completion = writer.send_nowait(
1439
+ frame,
1440
+ lane="reserved",
1441
+ deadline=deadline,
1442
+ send_timeout=send_timeout,
1443
+ )
1444
+ completion.add_done_callback(self._consume_send_result)
1445
+
1446
+ def _consume_send_result(self, future: asyncio.Future[None]) -> None:
1447
+ if future.cancelled():
1448
+ return
1449
+ error = future.exception()
1450
+ if error is not None and not self._closing:
1451
+ self._options.logger.warning("Reserved BPP write failed: %s", type(error).__name__)
1452
+ websocket = self._websocket
1453
+ if websocket is not None:
1454
+ websocket.transport.abort()
1455
+
1456
+ def _spawn(self, coroutine: Coroutine[Any, Any, Any], name: str) -> asyncio.Task[Any]:
1457
+ task: asyncio.Task[Any] = asyncio.create_task(coroutine, name=name)
1458
+ self._generation_tasks.add(task)
1459
+ task.add_done_callback(self._generation_task_done)
1460
+ return task
1461
+
1462
+ def _generation_task_done(self, task: asyncio.Task[Any]) -> None:
1463
+ self._generation_tasks.discard(task)
1464
+ if task.cancelled() or self._closing or self._terminal_set:
1465
+ return
1466
+ error = task.exception()
1467
+ if error is not None:
1468
+ if isinstance(error, ConnectionClosed):
1469
+ return
1470
+ self._background_failure = error
1471
+ websocket = self._websocket
1472
+ if websocket is not None:
1473
+ websocket.transport.abort()
1474
+
1475
+ def _detach_task(self, task: asyncio.Task[Any]) -> None:
1476
+ if task.done():
1477
+ self._detached_task_done(task)
1478
+ return
1479
+ task.cancel()
1480
+ _DETACHED_TASK_GUARDIAN.add(task)
1481
+ self._detached_tasks.add(task)
1482
+ task.add_done_callback(self._detached_task_done)
1483
+
1484
+ def _detached_task_done(self, task: asyncio.Task[Any]) -> None:
1485
+ _DETACHED_TASK_GUARDIAN.discard(task)
1486
+ self._detached_tasks.discard(task)
1487
+ if task.cancelled():
1488
+ return
1489
+ with contextlib.suppress(BaseException):
1490
+ task.exception()
1491
+
1492
+ def _try_acquire_handler_slot(self) -> bool:
1493
+ if self._handler_slots_available == 0:
1494
+ return False
1495
+ self._handler_slots_available -= 1
1496
+ return True
1497
+
1498
+ def _register_handler_task(self, task: asyncio.Task[Any]) -> None:
1499
+ self._handler_slot_tasks.add(task)
1500
+
1501
+ def _release_handler_task(self, task: asyncio.Task[Any]) -> None:
1502
+ if task not in self._handler_slot_tasks:
1503
+ return
1504
+ self._handler_slot_tasks.remove(task)
1505
+ self._handler_slots_available += 1
1506
+
1507
+ def _detach_handler_task(self, task: asyncio.Task[Any]) -> None:
1508
+ if task.done():
1509
+ self._handler_task_done(task)
1510
+ return
1511
+ task.cancel()
1512
+ _DETACHED_TASK_GUARDIAN.add(task)
1513
+ self._detached_tasks.add(task)
1514
+ task.add_done_callback(self._handler_task_done)
1515
+
1516
+ def _handler_task_done(self, task: asyncio.Task[Any]) -> None:
1517
+ _DETACHED_TASK_GUARDIAN.discard(task)
1518
+ self._detached_tasks.discard(task)
1519
+ if not task.cancelled():
1520
+ with contextlib.suppress(BaseException):
1521
+ task.exception()
1522
+ self._release_handler_task(task)
1523
+
1524
+ async def _invalidate_generation(self, error: BaseException) -> None:
1525
+ async with self._cursor_write_lock:
1526
+ self._active_generation = None
1527
+ self._online_ready.clear()
1528
+ self._deliveries.clear()
1529
+ self._deferred_live.clear()
1530
+ self._seen_delivery_cursors.clear()
1531
+ self._delivery_bytes = 0
1532
+ self._active_delivery = None
1533
+ self._delivery_wakeup.set()
1534
+ self._fail_pending(error)
1535
+ waiter = self._resume_ack
1536
+ if waiter is not None and not waiter.done():
1537
+ waiter.set_exception(error)
1538
+
1539
+ async def _cleanup_generation(self, error: BaseException) -> None:
1540
+ async with self._generation_cleanup_lock:
1541
+ current = asyncio.current_task()
1542
+ tasks = [task for task in self._generation_tasks if task is not current]
1543
+ for task in tasks:
1544
+ task.cancel()
1545
+ if tasks:
1546
+ await asyncio.gather(*tasks, return_exceptions=True)
1547
+ self._generation_tasks.clear()
1548
+ writer = self._writer
1549
+ self._writer = None
1550
+ if writer is not None:
1551
+ await writer.close(error)
1552
+ websocket = self._websocket
1553
+ self._websocket = None
1554
+ if websocket is not None:
1555
+ try:
1556
+ await asyncio.wait_for(websocket.close(), 1.0)
1557
+ except (TimeoutError, ConnectionClosed):
1558
+ websocket.transport.abort()
1559
+ self._config_queue = None
1560
+ self._request_queue = None
1561
+ self._config_queue_bytes = 0
1562
+ self._request_queue_bytes = 0
1563
+
1564
+ async def _terminal(self, error: BaseException) -> None:
1565
+ async with self._lifecycle_lock:
1566
+ if self._terminal_set or self._closing or self._closed_permanently:
1567
+ return
1568
+ self._terminal_set = True
1569
+ self._terminal_error = error
1570
+ self._terminal_event.set()
1571
+ future = self._connect_future
1572
+ if future is not None and not future.done():
1573
+ future.set_exception(error)
1574
+ supervisor = self._supervisor_task
1575
+ current = asyncio.current_task()
1576
+ if supervisor is not None and supervisor is not current:
1577
+ supervisor.cancel()
1578
+ code = error.code if isinstance(error, BorgeeError) else "bpp.internal"
1579
+ reason = FaultReason.API_KEY_INVALID if code == "bpp.auth_failed" else FaultReason.UNKNOWN
1580
+ self._set_state(ConnectionState(ConnectionStatus.ERROR, reason=reason, code=code))
1581
+ await self._invalidate_generation(error)
1582
+ websocket = self._websocket
1583
+ if websocket is not None:
1584
+ websocket.transport.abort()
1585
+ if supervisor is not None and supervisor is not current:
1586
+ with contextlib.suppress(asyncio.CancelledError):
1587
+ await supervisor
1588
+ await self._cleanup_generation(error)
1589
+
1590
+ def _fail_pending(self, error: BaseException) -> None:
1591
+ for pending in self._pending_actions.values():
1592
+ if not pending.future.done():
1593
+ pending.future.set_exception(error)
1594
+ self._pending_actions.clear()
1595
+
1596
+ def _set_state(self, state: ConnectionState) -> None:
1597
+ if self._terminal_set and state.status not in {ConnectionStatus.ERROR, ConnectionStatus.CLOSED}:
1598
+ return
1599
+ if self._closed_permanently and state.status is not ConnectionStatus.CLOSED:
1600
+ return
1601
+ self._state = state
1602
+ if state.status is ConnectionStatus.ONLINE:
1603
+ self._ever_online = True
1604
+ self._online_ready.set()
1605
+ else:
1606
+ self._online_ready.clear()
1607
+ if self._state_queue.full():
1608
+ with contextlib.suppress(asyncio.QueueEmpty):
1609
+ self._state_queue.get_nowait()
1610
+ self._state_queue.put_nowait(state)
1611
+
1612
+
1613
+ def _is_int(value: object) -> TypeGuard[int]:
1614
+ return isinstance(value, int) and not isinstance(value, bool)
1615
+
1616
+
1617
+ def _connection_close_code(error: ConnectionClosed) -> int | None:
1618
+ if error.rcvd is not None:
1619
+ return error.rcvd.code
1620
+ if error.sent is not None:
1621
+ return error.sent.code
1622
+ return None
1623
+
1624
+
1625
+ def _required_int(frame: dict[str, Any], field: str, *, minimum: int) -> int:
1626
+ value = frame.get(field)
1627
+ if not _is_int(value) or value < minimum:
1628
+ raise ProtocolError(f"{field} must be an integer greater than or equal to {minimum}")
1629
+ return value
1630
+
1631
+
1632
+ def _required_str(frame: dict[str, Any], field: str) -> str:
1633
+ value = frame.get(field)
1634
+ if not isinstance(value, str):
1635
+ raise ProtocolError(f"{field} must be a string")
1636
+ return value
1637
+
1638
+
1639
+ def _map_inbound(frame: dict[str, Any], cursor: int) -> InboundMessageEvent:
1640
+ try:
1641
+ kind = InboundKind(_required_str(frame, "kind"))
1642
+ except ValueError as exc:
1643
+ raise ProtocolError("inbound message kind is invalid") from exc
1644
+ channel_id = _required_str(frame, "channel_id")
1645
+ channel_type = _required_str(frame, "channel_type") or None
1646
+ message_id = _required_str(frame, "message_id")
1647
+ author_id = _required_str(frame, "author_id")
1648
+ author_name = _required_str(frame, "author_name") or None
1649
+ body = _required_str(frame, "body")
1650
+ created_at = _required_int(frame, "created_at", minimum=0)
1651
+ message: Message | None = None
1652
+ reaction: InboundReaction | None = None
1653
+ if kind in {InboundKind.MESSAGE, InboundKind.EDITED, InboundKind.MENTION}:
1654
+ message = Message(
1655
+ id=message_id,
1656
+ channel_id=channel_id,
1657
+ author_id=author_id,
1658
+ body=body,
1659
+ created_at=created_at,
1660
+ )
1661
+ if kind is InboundKind.REACTION:
1662
+ added = frame.get("reaction_added")
1663
+ if not isinstance(added, bool):
1664
+ raise ProtocolError("reaction_added must be a boolean")
1665
+ reaction = InboundReaction(
1666
+ emoji=_required_str(frame, "emoji"),
1667
+ user_id=_required_str(frame, "reactor_id"),
1668
+ added=added,
1669
+ )
1670
+ return InboundMessageEvent(
1671
+ kind=kind,
1672
+ cursor=cursor,
1673
+ channel_id=channel_id,
1674
+ created_at=created_at,
1675
+ channel_type=channel_type,
1676
+ message=message,
1677
+ message_id=message_id if kind in {InboundKind.DELETED, InboundKind.REACTION} else None,
1678
+ reaction=reaction,
1679
+ author_name=author_name,
1680
+ )