soothe-client-python 0.9.4__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.
- soothe_client/__init__.py +171 -0
- soothe_client/appkit/__init__.py +138 -0
- soothe_client/appkit/attachments.py +118 -0
- soothe_client/appkit/broadcaster.py +116 -0
- soothe_client/appkit/chunk_filter.py +99 -0
- soothe_client/appkit/classifier.py +498 -0
- soothe_client/appkit/daemon_session.py +657 -0
- soothe_client/appkit/events.py +63 -0
- soothe_client/appkit/managed_client.py +163 -0
- soothe_client/appkit/observability.py +29 -0
- soothe_client/appkit/pool.py +258 -0
- soothe_client/appkit/query_gate.py +93 -0
- soothe_client/appkit/session_store.py +100 -0
- soothe_client/appkit/thinking_step.py +147 -0
- soothe_client/appkit/turn.py +362 -0
- soothe_client/appkit/turn_runner.py +565 -0
- soothe_client/errors.py +65 -0
- soothe_client/helpers.py +404 -0
- soothe_client/intent_hints.py +43 -0
- soothe_client/protocol_params.py +604 -0
- soothe_client/schemas.py +55 -0
- soothe_client/session.py +199 -0
- soothe_client/websocket.py +1626 -0
- soothe_client/ws_command_client.py +633 -0
- soothe_client_python-0.9.4.dist-info/METADATA +131 -0
- soothe_client_python-0.9.4.dist-info/RECORD +28 -0
- soothe_client_python-0.9.4.dist-info/WHEEL +4 -0
- soothe_client_python-0.9.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1626 @@
|
|
|
1
|
+
"""WebSocket client for daemon connections (RFC-0013)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import AsyncGenerator, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import websockets.asyncio.client
|
|
15
|
+
import websockets.exceptions
|
|
16
|
+
from soothe_sdk.ux.loop_stream import is_stream_terminal_wire_dict
|
|
17
|
+
from soothe_sdk.wire.protocol import decode_websocket_text, encode_websocket_text
|
|
18
|
+
|
|
19
|
+
from soothe_client.errors import DisconnectCause, ReconnectError, StaleLoopError
|
|
20
|
+
from soothe_client.intent_hints import validate_loop_input_intent_hint
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# IG-535 Optimization 1: Priority-aware drop policy for inbound queue.
|
|
25
|
+
# Lower values = keep, higher values = drop candidate.
|
|
26
|
+
_DROP_PRIORITY_CRITICAL = 0 # Never drop: terminal frames, goal_completion, status, errors
|
|
27
|
+
_DROP_PRIORITY_HIGH = 1 # Prefer keep: tool call updates, step events
|
|
28
|
+
_DROP_PRIORITY_NORMAL = 2 # Default drop candidate: streaming text, updates
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _wire_message_dict_from_event_data(data: Any) -> dict[str, Any] | None:
|
|
32
|
+
"""Extract the first message wire dict from an event ``data`` payload."""
|
|
33
|
+
if isinstance(data, dict):
|
|
34
|
+
return data
|
|
35
|
+
if isinstance(data, (tuple, list)) and data:
|
|
36
|
+
first = data[0]
|
|
37
|
+
return first if isinstance(first, dict) else None
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _event_messages_wire_terminal(data: Any) -> bool:
|
|
42
|
+
"""Return True when a messages-mode payload is a stream terminal frame."""
|
|
43
|
+
body = _wire_message_dict_from_event_data(data)
|
|
44
|
+
return is_stream_terminal_wire_dict(body) if body is not None else False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _inbound_frame_drop_priority(event: dict[str, Any] | None) -> int:
|
|
48
|
+
"""Return priority for drop decision (lower = keep, higher = drop candidate).
|
|
49
|
+
|
|
50
|
+
IG-535: Ensures terminal frames and goal_completion are never dropped
|
|
51
|
+
even when inbound queue is full.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
event: Wire frame dict or None sentinel.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Priority level: 0 (never drop) to 2 (drop candidate).
|
|
58
|
+
"""
|
|
59
|
+
if event is None:
|
|
60
|
+
return _DROP_PRIORITY_CRITICAL # Sentinel - never drop
|
|
61
|
+
|
|
62
|
+
event_type = event.get("type", "")
|
|
63
|
+
|
|
64
|
+
# Batched transport frames bundle user-visible events — prefer keep (IG-546).
|
|
65
|
+
if event_type == "event_batch":
|
|
66
|
+
return _DROP_PRIORITY_HIGH
|
|
67
|
+
if event_type == "tool_call_updates_batch":
|
|
68
|
+
return _DROP_PRIORITY_HIGH
|
|
69
|
+
|
|
70
|
+
# Unwrap protocol-1 next envelope (RFC-450 §9.3)
|
|
71
|
+
if event_type == "next":
|
|
72
|
+
payload = event.get("payload")
|
|
73
|
+
if isinstance(payload, dict):
|
|
74
|
+
inner_type = payload.get("type", "")
|
|
75
|
+
inner_mode = payload.get("mode", "")
|
|
76
|
+
inner_data = payload.get("data")
|
|
77
|
+
|
|
78
|
+
# Check for goal_completion in inner messages frame
|
|
79
|
+
if inner_mode == "messages" and isinstance(inner_data, (tuple, list)):
|
|
80
|
+
if _event_messages_wire_terminal(inner_data):
|
|
81
|
+
return _DROP_PRIORITY_CRITICAL
|
|
82
|
+
if inner_data and isinstance(inner_data[0], dict):
|
|
83
|
+
if inner_data[0].get("phase") == "goal_completion":
|
|
84
|
+
return _DROP_PRIORITY_CRITICAL
|
|
85
|
+
|
|
86
|
+
# Protocol-1 complete envelope
|
|
87
|
+
if inner_type == "complete":
|
|
88
|
+
return _DROP_PRIORITY_CRITICAL
|
|
89
|
+
|
|
90
|
+
# Recurse with inner frame type
|
|
91
|
+
event_type = inner_type
|
|
92
|
+
|
|
93
|
+
# Subscription complete — never drop
|
|
94
|
+
if event_type == "complete":
|
|
95
|
+
return _DROP_PRIORITY_CRITICAL
|
|
96
|
+
|
|
97
|
+
# Terminal frames - never drop
|
|
98
|
+
if event_type == "status":
|
|
99
|
+
state = event.get("state", "")
|
|
100
|
+
if state in ("idle", "running", "stopped", "detached"):
|
|
101
|
+
return _DROP_PRIORITY_CRITICAL
|
|
102
|
+
|
|
103
|
+
# Error frames - never drop
|
|
104
|
+
if event_type == "error":
|
|
105
|
+
return _DROP_PRIORITY_CRITICAL
|
|
106
|
+
|
|
107
|
+
# Connection ack - never drop
|
|
108
|
+
if event_type == "connection_ack":
|
|
109
|
+
return _DROP_PRIORITY_CRITICAL
|
|
110
|
+
|
|
111
|
+
# Custom events with specific types - prefer keep
|
|
112
|
+
if event_type == "event" or event.get("type") == "event":
|
|
113
|
+
mode = event.get("mode", "")
|
|
114
|
+
if mode == "custom":
|
|
115
|
+
data = event.get("data")
|
|
116
|
+
if isinstance(data, dict):
|
|
117
|
+
custom_type = data.get("type", "")
|
|
118
|
+
if custom_type == "soothe.stream.end":
|
|
119
|
+
return _DROP_PRIORITY_CRITICAL
|
|
120
|
+
if custom_type == "soothe.cognition.strange_loop.completed":
|
|
121
|
+
return _DROP_PRIORITY_CRITICAL
|
|
122
|
+
# Cognition events (step started/completed) - prefer keep
|
|
123
|
+
if custom_type.startswith("soothe.cognition."):
|
|
124
|
+
return _DROP_PRIORITY_HIGH
|
|
125
|
+
# Error events - never drop
|
|
126
|
+
if custom_type.startswith("soothe.error."):
|
|
127
|
+
return _DROP_PRIORITY_CRITICAL
|
|
128
|
+
# Stream degraded signal - never drop
|
|
129
|
+
if custom_type == "stream_degraded":
|
|
130
|
+
return _DROP_PRIORITY_CRITICAL
|
|
131
|
+
# Tool call updates batch - prefer keep
|
|
132
|
+
if custom_type == "soothe.ux.stream_tool_wire.tool_call_updates_batch":
|
|
133
|
+
return _DROP_PRIORITY_HIGH
|
|
134
|
+
|
|
135
|
+
# Messages mode - terminal or goal_completion phase
|
|
136
|
+
if mode == "messages":
|
|
137
|
+
data = event.get("data")
|
|
138
|
+
if _event_messages_wire_terminal(data):
|
|
139
|
+
return _DROP_PRIORITY_CRITICAL
|
|
140
|
+
if isinstance(data, (tuple, list)) and data:
|
|
141
|
+
first = data[0]
|
|
142
|
+
if isinstance(first, dict) and first.get("phase") == "goal_completion":
|
|
143
|
+
return _DROP_PRIORITY_CRITICAL
|
|
144
|
+
|
|
145
|
+
# Default - acceptable drop candidate
|
|
146
|
+
return _DROP_PRIORITY_NORMAL
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extract_loop_id_from_inbound(event: dict[str, Any]) -> str | None:
|
|
150
|
+
"""Return ``loop_id`` from a protocol-1 or legacy inbound frame."""
|
|
151
|
+
lid = event.get("loop_id")
|
|
152
|
+
if isinstance(lid, str) and lid.strip():
|
|
153
|
+
return lid.strip()
|
|
154
|
+
if event.get("type") == "next":
|
|
155
|
+
payload = event.get("payload")
|
|
156
|
+
if isinstance(payload, dict):
|
|
157
|
+
data = payload.get("data")
|
|
158
|
+
if isinstance(data, dict):
|
|
159
|
+
inner = data.get("loop_id")
|
|
160
|
+
if isinstance(inner, str) and inner.strip():
|
|
161
|
+
return inner.strip()
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _inbound_needs_delivery_ack(event: dict[str, Any]) -> bool:
|
|
166
|
+
"""True when the client should bump delivery ack sequence for ``event``."""
|
|
167
|
+
if event.get("type") == "complete":
|
|
168
|
+
return True
|
|
169
|
+
if event.get("type") == "next":
|
|
170
|
+
payload = event.get("payload")
|
|
171
|
+
if not isinstance(payload, dict):
|
|
172
|
+
return False
|
|
173
|
+
inner = payload.get("data")
|
|
174
|
+
if not isinstance(inner, dict):
|
|
175
|
+
return False
|
|
176
|
+
mode = payload.get("mode", "")
|
|
177
|
+
if mode == "event":
|
|
178
|
+
inner_mode = inner.get("mode", "")
|
|
179
|
+
data = inner.get("data")
|
|
180
|
+
if inner_mode == "messages" and isinstance(data, (tuple, list)) and data:
|
|
181
|
+
body = data[0]
|
|
182
|
+
return isinstance(body, dict) and is_stream_terminal_wire_dict(body)
|
|
183
|
+
if inner_mode == "custom" and isinstance(data, dict):
|
|
184
|
+
ctype = data.get("type", "")
|
|
185
|
+
return ctype in (
|
|
186
|
+
"soothe.cognition.strange_loop.completed",
|
|
187
|
+
"soothe.stream.end",
|
|
188
|
+
)
|
|
189
|
+
if event.get("type") == "event":
|
|
190
|
+
mode = event.get("mode", "")
|
|
191
|
+
data = event.get("data")
|
|
192
|
+
if mode == "messages" and isinstance(data, (tuple, list)) and data:
|
|
193
|
+
body = data[0]
|
|
194
|
+
return isinstance(body, dict) and is_stream_terminal_wire_dict(body)
|
|
195
|
+
if mode == "custom" and isinstance(data, dict):
|
|
196
|
+
ctype = data.get("type", "")
|
|
197
|
+
return ctype in (
|
|
198
|
+
"soothe.cognition.strange_loop.completed",
|
|
199
|
+
"soothe.stream.end",
|
|
200
|
+
)
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Align with soothe_daemon.config.models.WebSocketConfig.max_frame_size (default 10 MiB).
|
|
205
|
+
# The websockets library defaults max_size to 1 MiB, which closes the connection (1009)
|
|
206
|
+
# when the daemon streams larger JSON events to the client.
|
|
207
|
+
_DEFAULT_MAX_FRAME_SIZE = 10 * 1024 * 1024
|
|
208
|
+
|
|
209
|
+
# RFC-450: clients must wait (bounded) while the daemon is still starting; it does not
|
|
210
|
+
# necessarily push another ``daemon_ready`` when transitioning to ready, so we re-request.
|
|
211
|
+
_TRANSITIONAL_DAEMON_READY_STATES = frozenset({"starting", "warming"})
|
|
212
|
+
_DAEMON_READY_POLL_INTERVAL_S = 0.05
|
|
213
|
+
|
|
214
|
+
# Client version reported in the connection_init handshake (RFC-450 §8.2).
|
|
215
|
+
try:
|
|
216
|
+
from soothe_sdk import __version__ as client_version # noqa: N812
|
|
217
|
+
except Exception: # pragma: no cover
|
|
218
|
+
client_version = "0.0.0" # noqa: N812
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class WebSocketClient:
|
|
222
|
+
"""WebSocket client for communicating with Soothe daemon.
|
|
223
|
+
|
|
224
|
+
This client connects to the daemon via WebSocket and provides
|
|
225
|
+
streaming event access and bidirectional message passing.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
url: WebSocket URL (e.g., ``ws://localhost:8765``).
|
|
229
|
+
client_id: Optional client identifier for log differentiation. If not
|
|
230
|
+
provided, a short random ID is generated (8 hex chars).
|
|
231
|
+
max_frame_size: Maximum incoming WebSocket message size in bytes. Should be
|
|
232
|
+
at least the daemon's ``transport.websocket.max_frame_size`` when that
|
|
233
|
+
is customized.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
url: str = "ws://localhost:8765",
|
|
239
|
+
*,
|
|
240
|
+
client_id: str | None = None,
|
|
241
|
+
max_frame_size: int = _DEFAULT_MAX_FRAME_SIZE,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Initialize WebSocket client.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
url: WebSocket URL.
|
|
247
|
+
client_id: Optional client identifier for log differentiation.
|
|
248
|
+
max_frame_size: Max size for frames received from the daemon.
|
|
249
|
+
"""
|
|
250
|
+
self._url = url
|
|
251
|
+
self._client_id = client_id or uuid.uuid4().hex[:8]
|
|
252
|
+
self._max_frame_size = max_frame_size
|
|
253
|
+
self._ws: websockets.asyncio.client.ClientConnection | None = None
|
|
254
|
+
self._connected = False
|
|
255
|
+
self._pending_events: deque[dict[str, Any]] = deque()
|
|
256
|
+
# Background reader drains the socket so daemon sends are not blocked by a
|
|
257
|
+
# stalled consumer (e.g. heavy Textual UI work on the same event loop).
|
|
258
|
+
# IG-535: Increased from 10_000 to 20_000 for 32 concurrent loops with dense streaming
|
|
259
|
+
self._inbound_maxsize = 20_000
|
|
260
|
+
self._inbound_queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(
|
|
261
|
+
maxsize=self._inbound_maxsize
|
|
262
|
+
)
|
|
263
|
+
self._inbound_dropped = 0
|
|
264
|
+
# IG-534: Optional callback for stream degradation events
|
|
265
|
+
self._on_stream_degraded: Callable[[int, str], None] | None = None
|
|
266
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
267
|
+
# Coalesce high-frequency daemon_status polls on a long-lived connection.
|
|
268
|
+
self._daemon_status_cache: tuple[float, dict[str, Any]] | None = None
|
|
269
|
+
self._daemon_status_lock = asyncio.Lock()
|
|
270
|
+
self._daemon_status_inflight: asyncio.Task[dict[str, Any]] | None = None
|
|
271
|
+
# Protocol-1 handshake state (RFC-450 §8.2)
|
|
272
|
+
self._negotiated_capabilities: set[str] = set()
|
|
273
|
+
self._protocol_version: str | None = None
|
|
274
|
+
self._handshake_complete: bool = False
|
|
275
|
+
self._heartbeat_interval_ms: int = 0
|
|
276
|
+
self._heartbeat_timeout_ms: int = 10000
|
|
277
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
278
|
+
self._last_pong_monotonic: float = 0.0
|
|
279
|
+
# IG-556 P1.3: delivery ack sequence per loop for daemon drain gating.
|
|
280
|
+
self._delivery_recv_seq: dict[str, int] = {}
|
|
281
|
+
self._delivery_acked_seq: dict[str, int] = {}
|
|
282
|
+
# Mid-session drop signal (RFC-450 §8.3 / RFC-629 Layer 0).
|
|
283
|
+
self._disconn_event = asyncio.Event()
|
|
284
|
+
self._disconn_cause: DisconnectCause | None = None
|
|
285
|
+
self._disconn_fired = False
|
|
286
|
+
self._on_disconnected: Callable[[DisconnectCause], None] | None = None
|
|
287
|
+
|
|
288
|
+
async def connect(self) -> None:
|
|
289
|
+
"""Connect to the daemon.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
ConnectionError: If connection fails.
|
|
293
|
+
"""
|
|
294
|
+
if self._ws is not None:
|
|
295
|
+
await self.close()
|
|
296
|
+
|
|
297
|
+
# Fresh transport — do not inherit liveness timestamps from a prior socket.
|
|
298
|
+
self._last_pong_monotonic = time.monotonic()
|
|
299
|
+
self._handshake_complete = False
|
|
300
|
+
self._negotiated_capabilities = set()
|
|
301
|
+
self._heartbeat_interval_ms = 0
|
|
302
|
+
self._reset_disconnect_signal()
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
# Transport-level keepalive: daemon heartbeats are loop-scoped and only
|
|
306
|
+
# while a query runs; without client pings, long idle TCP can be dropped.
|
|
307
|
+
self._ws = await websockets.asyncio.client.connect(
|
|
308
|
+
self._url,
|
|
309
|
+
ping_interval=30,
|
|
310
|
+
ping_timeout=60,
|
|
311
|
+
max_size=self._max_frame_size,
|
|
312
|
+
)
|
|
313
|
+
self._connected = True
|
|
314
|
+
self._reader_task = asyncio.create_task(
|
|
315
|
+
self._socket_reader_loop(),
|
|
316
|
+
name=f"soothe-ws-reader-{self._client_id}",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
logger.info("[Client:%s] Connected to daemon at %s", self._client_id, self._url)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
self._connected = False
|
|
322
|
+
msg = f"Failed to connect to daemon: {e}"
|
|
323
|
+
raise ConnectionError(msg) from e
|
|
324
|
+
|
|
325
|
+
async def _stop_reader(self) -> None:
|
|
326
|
+
"""Cancel the background reader and clear queued inbound events."""
|
|
327
|
+
task = self._reader_task
|
|
328
|
+
self._reader_task = None
|
|
329
|
+
if task is not None and not task.done():
|
|
330
|
+
task.cancel()
|
|
331
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
332
|
+
await task
|
|
333
|
+
while True:
|
|
334
|
+
try:
|
|
335
|
+
self._inbound_queue.get_nowait()
|
|
336
|
+
except asyncio.QueueEmpty:
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
async def _socket_reader_loop(self) -> None:
|
|
340
|
+
"""Continuously read frames from the transport into ``_inbound_queue``."""
|
|
341
|
+
try:
|
|
342
|
+
while self._connected and self._ws is not None:
|
|
343
|
+
event = await self._read_from_socket()
|
|
344
|
+
if event is None:
|
|
345
|
+
await self._enqueue_inbound(None)
|
|
346
|
+
break
|
|
347
|
+
# Intercept heartbeat frames (RFC-450 §8.3): respond to ping
|
|
348
|
+
# with pong and swallow pong (liveness tracked by heartbeat loop).
|
|
349
|
+
etype = event.get("type")
|
|
350
|
+
if etype == "ping":
|
|
351
|
+
await self._respond_pong()
|
|
352
|
+
continue
|
|
353
|
+
if etype == "pong":
|
|
354
|
+
self._handle_pong()
|
|
355
|
+
continue
|
|
356
|
+
await self._enqueue_inbound(event)
|
|
357
|
+
except asyncio.CancelledError:
|
|
358
|
+
raise
|
|
359
|
+
except Exception:
|
|
360
|
+
logger.exception(
|
|
361
|
+
"[Client:%s] WebSocket reader loop failed",
|
|
362
|
+
self._client_id,
|
|
363
|
+
)
|
|
364
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
365
|
+
await self._enqueue_inbound(None)
|
|
366
|
+
finally:
|
|
367
|
+
was_connected = self._connected
|
|
368
|
+
self._connected = False
|
|
369
|
+
if was_connected:
|
|
370
|
+
self._signal_disconnect(DisconnectCause.UNCLEAN)
|
|
371
|
+
|
|
372
|
+
async def _respond_pong(self) -> None:
|
|
373
|
+
"""Respond to a daemon ``ping`` with ``pong`` (RFC-450 §8.3)."""
|
|
374
|
+
if not self._ws or not self._connected:
|
|
375
|
+
return
|
|
376
|
+
try:
|
|
377
|
+
await self._ws.send(encode_websocket_text({"proto": "1", "type": "pong"}))
|
|
378
|
+
except websockets.exceptions.ConnectionClosed:
|
|
379
|
+
self._connected = False
|
|
380
|
+
except Exception:
|
|
381
|
+
logger.debug("[Client:%s] Failed to send pong", self._client_id)
|
|
382
|
+
|
|
383
|
+
async def _enqueue_inbound(self, event: dict[str, Any] | None) -> None:
|
|
384
|
+
"""Queue one or more inbound wire messages (expands ``event_batch``)."""
|
|
385
|
+
if event is None:
|
|
386
|
+
await self._put_inbound_queue(None)
|
|
387
|
+
return
|
|
388
|
+
if event.get("type") == "event_batch":
|
|
389
|
+
sub_events = event.get("events")
|
|
390
|
+
if isinstance(sub_events, list):
|
|
391
|
+
for sub in sub_events:
|
|
392
|
+
if isinstance(sub, dict):
|
|
393
|
+
await self._put_inbound_queue(sub)
|
|
394
|
+
return
|
|
395
|
+
await self._put_inbound_queue(event)
|
|
396
|
+
|
|
397
|
+
async def _put_inbound_queue(self, event: dict[str, Any] | None) -> None:
|
|
398
|
+
"""Put event into inbound queue with priority-aware drop policy.
|
|
399
|
+
|
|
400
|
+
IG-535: When queue is full, find and drop a NORMAL priority frame
|
|
401
|
+
(streaming text/updates) instead of blindly dropping oldest, which
|
|
402
|
+
could lose terminal frames (goal_completion, status:idle).
|
|
403
|
+
"""
|
|
404
|
+
event_priority = _inbound_frame_drop_priority(event)
|
|
405
|
+
|
|
406
|
+
if self._inbound_queue.full():
|
|
407
|
+
# Priority-aware drop: find best drop candidate (highest priority value)
|
|
408
|
+
# by temporarily draining and scanning the queue.
|
|
409
|
+
temp_items: list[dict[str, Any] | None] = []
|
|
410
|
+
drop_candidate: dict[str, Any] | None = None
|
|
411
|
+
drop_priority = -1 # Will only drop if we find priority >= _DROP_PRIORITY_NORMAL
|
|
412
|
+
|
|
413
|
+
# Drain queue to scan for drop candidate
|
|
414
|
+
while True:
|
|
415
|
+
try:
|
|
416
|
+
item = self._inbound_queue.get_nowait()
|
|
417
|
+
temp_items.append(item)
|
|
418
|
+
except asyncio.QueueEmpty:
|
|
419
|
+
break
|
|
420
|
+
|
|
421
|
+
# Find highest-priority drop candidate (NORMAL = acceptable to drop)
|
|
422
|
+
for item in temp_items:
|
|
423
|
+
p = _inbound_frame_drop_priority(item)
|
|
424
|
+
if p > drop_priority:
|
|
425
|
+
drop_priority = p
|
|
426
|
+
drop_candidate = item
|
|
427
|
+
|
|
428
|
+
# Requeue all except drop candidate (if found with acceptable priority)
|
|
429
|
+
requeued_critical_or_high = False
|
|
430
|
+
for item in temp_items:
|
|
431
|
+
if item is drop_candidate and drop_priority >= _DROP_PRIORITY_NORMAL:
|
|
432
|
+
# Skip - this is our drop target
|
|
433
|
+
continue
|
|
434
|
+
self._inbound_queue.put_nowait(item)
|
|
435
|
+
p = _inbound_frame_drop_priority(item)
|
|
436
|
+
if p <= _DROP_PRIORITY_HIGH:
|
|
437
|
+
requeued_critical_or_high = True
|
|
438
|
+
|
|
439
|
+
if drop_candidate is not None and drop_priority >= _DROP_PRIORITY_NORMAL:
|
|
440
|
+
# Successfully found and dropped a NORMAL frame
|
|
441
|
+
self._inbound_dropped += 1
|
|
442
|
+
# IG-534: Emit stream_degraded callback on first drop
|
|
443
|
+
if self._on_stream_degraded and self._inbound_dropped == 1:
|
|
444
|
+
try:
|
|
445
|
+
self._on_stream_degraded(1, "inbound_queue_overflow")
|
|
446
|
+
except Exception:
|
|
447
|
+
logger.debug("Stream degraded callback error", exc_info=True)
|
|
448
|
+
if self._inbound_dropped == 1 or self._inbound_dropped % 1000 == 0:
|
|
449
|
+
logger.warning(
|
|
450
|
+
"[Client:%s] Stream degraded: inbound queue overflow "
|
|
451
|
+
"(dropped NORMAL frame, dropped=%d)",
|
|
452
|
+
self._client_id,
|
|
453
|
+
self._inbound_dropped,
|
|
454
|
+
)
|
|
455
|
+
else:
|
|
456
|
+
# No acceptable drop candidate - queue contains only CRITICAL/HIGH frames
|
|
457
|
+
# Briefly yield and retry, or if incoming frame is also CRITICAL/HIGH,
|
|
458
|
+
# force a slot by dropping oldest (rare edge case)
|
|
459
|
+
if event_priority >= _DROP_PRIORITY_NORMAL and requeued_critical_or_high:
|
|
460
|
+
# Incoming is NORMAL, but queue is full of CRITICAL/HIGH
|
|
461
|
+
# This shouldn't happen under normal operation; log and yield
|
|
462
|
+
logger.debug(
|
|
463
|
+
"[Client:%s] Inbound queue full of critical frames, "
|
|
464
|
+
"yielding for drain (incoming_priority=%d)",
|
|
465
|
+
self._client_id,
|
|
466
|
+
event_priority,
|
|
467
|
+
)
|
|
468
|
+
await asyncio.sleep(0.001)
|
|
469
|
+
elif event_priority < _DROP_PRIORITY_NORMAL:
|
|
470
|
+
# Incoming is CRITICAL/HIGH, force a slot by dropping oldest
|
|
471
|
+
# even if it's also critical (rare, but necessary to avoid deadlock)
|
|
472
|
+
if temp_items:
|
|
473
|
+
# Drop the first item we drained (oldest)
|
|
474
|
+
self._inbound_dropped += 1
|
|
475
|
+
logger.warning(
|
|
476
|
+
"[Client:%s] Force drop oldest to admit CRITICAL/HIGH frame "
|
|
477
|
+
"(queue full of critical, dropped=%d)",
|
|
478
|
+
self._client_id,
|
|
479
|
+
self._inbound_dropped,
|
|
480
|
+
)
|
|
481
|
+
# Requeue all but the first (oldest)
|
|
482
|
+
for item in temp_items[1:]:
|
|
483
|
+
self._inbound_queue.put_nowait(item)
|
|
484
|
+
|
|
485
|
+
await self._inbound_queue.put(event)
|
|
486
|
+
if isinstance(event, dict):
|
|
487
|
+
self._track_inbound_delivery_ack(event)
|
|
488
|
+
|
|
489
|
+
def _track_inbound_delivery_ack(self, event: dict[str, Any]) -> None:
|
|
490
|
+
"""Bump recv seq and schedule a delivery ack for terminal stream frames."""
|
|
491
|
+
if event.get("type") == "event_batch":
|
|
492
|
+
sub_events = event.get("events")
|
|
493
|
+
if isinstance(sub_events, list):
|
|
494
|
+
for sub in sub_events:
|
|
495
|
+
if isinstance(sub, dict):
|
|
496
|
+
self._track_inbound_delivery_ack(sub)
|
|
497
|
+
return
|
|
498
|
+
if not _inbound_needs_delivery_ack(event):
|
|
499
|
+
return
|
|
500
|
+
loop_id = _extract_loop_id_from_inbound(event)
|
|
501
|
+
if not loop_id:
|
|
502
|
+
return
|
|
503
|
+
self._delivery_recv_seq[loop_id] = self._delivery_recv_seq.get(loop_id, 0) + 1
|
|
504
|
+
try:
|
|
505
|
+
loop = asyncio.get_running_loop()
|
|
506
|
+
except RuntimeError:
|
|
507
|
+
return
|
|
508
|
+
loop.create_task(self._send_delivery_ack(loop_id))
|
|
509
|
+
|
|
510
|
+
async def _send_delivery_ack(self, loop_id: str) -> None:
|
|
511
|
+
"""Notify daemon that terminal frames through ``seq`` were received."""
|
|
512
|
+
seq = self._delivery_recv_seq.get(loop_id, 0)
|
|
513
|
+
if seq <= self._delivery_acked_seq.get(loop_id, 0):
|
|
514
|
+
return
|
|
515
|
+
self._delivery_acked_seq[loop_id] = seq
|
|
516
|
+
if not self._connected or self._ws is None:
|
|
517
|
+
return
|
|
518
|
+
try:
|
|
519
|
+
await self.send(
|
|
520
|
+
{
|
|
521
|
+
"proto": "1",
|
|
522
|
+
"type": "notification",
|
|
523
|
+
"method": "delivery_ack",
|
|
524
|
+
"params": {"loop_id": loop_id, "seq": seq},
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
except Exception:
|
|
528
|
+
logger.debug(
|
|
529
|
+
"[Client:%s] delivery_ack failed for loop %s",
|
|
530
|
+
self._client_id,
|
|
531
|
+
loop_id[:16],
|
|
532
|
+
exc_info=True,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
async def close(self, *, handshake_timeout: float = 2.0) -> None:
|
|
536
|
+
"""Close the connection with timeout to prevent exit hangs.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
handshake_timeout: Seconds to wait for the WebSocket close handshake.
|
|
540
|
+
Use a small value (e.g. 0.3) on interactive client exit.
|
|
541
|
+
"""
|
|
542
|
+
# Intentional teardown is a clean drop (loops keep running server-side).
|
|
543
|
+
if self._connected or self._ws is not None:
|
|
544
|
+
self._signal_disconnect(DisconnectCause.CLEAN)
|
|
545
|
+
|
|
546
|
+
# Cancel heartbeat task
|
|
547
|
+
hb_task = self._heartbeat_task
|
|
548
|
+
self._heartbeat_task = None
|
|
549
|
+
if hb_task is not None and not hb_task.done():
|
|
550
|
+
hb_task.cancel()
|
|
551
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
552
|
+
await hb_task
|
|
553
|
+
|
|
554
|
+
await self._stop_reader()
|
|
555
|
+
inflight: asyncio.Task[dict[str, Any]] | None = None
|
|
556
|
+
async with self._daemon_status_lock:
|
|
557
|
+
inflight = self._daemon_status_inflight
|
|
558
|
+
self._daemon_status_inflight = None
|
|
559
|
+
self._daemon_status_cache = None
|
|
560
|
+
|
|
561
|
+
if inflight is not None and not inflight.done():
|
|
562
|
+
inflight.cancel()
|
|
563
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
564
|
+
await inflight
|
|
565
|
+
|
|
566
|
+
if self._ws:
|
|
567
|
+
try:
|
|
568
|
+
await asyncio.wait_for(self._ws.close(), timeout=handshake_timeout)
|
|
569
|
+
except TimeoutError:
|
|
570
|
+
# Force close on timeout - daemon will handle graceful cleanup
|
|
571
|
+
logger.debug(
|
|
572
|
+
"WebSocket close timed out after %.1fs, forcing closure",
|
|
573
|
+
handshake_timeout,
|
|
574
|
+
)
|
|
575
|
+
except Exception:
|
|
576
|
+
# Suppress other errors (connection closed, network issues)
|
|
577
|
+
logger.debug("WebSocket close error (connection likely already closed)")
|
|
578
|
+
self._ws = None
|
|
579
|
+
self._connected = False
|
|
580
|
+
self._pending_events.clear()
|
|
581
|
+
self._handshake_complete = False
|
|
582
|
+
self._negotiated_capabilities = set()
|
|
583
|
+
self._heartbeat_interval_ms = 0
|
|
584
|
+
self._last_pong_monotonic = 0.0
|
|
585
|
+
|
|
586
|
+
async def send(self, message: dict[str, Any]) -> None:
|
|
587
|
+
"""Send a message to the daemon.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
message: Message dict to send.
|
|
591
|
+
|
|
592
|
+
Raises:
|
|
593
|
+
ConnectionError: If not connected or send fails.
|
|
594
|
+
"""
|
|
595
|
+
if not self._ws or not self._connected:
|
|
596
|
+
raise ConnectionError("Not connected to daemon")
|
|
597
|
+
|
|
598
|
+
if not self.is_connection_alive():
|
|
599
|
+
self._connected = False
|
|
600
|
+
raise ConnectionError("Connection closed")
|
|
601
|
+
|
|
602
|
+
try:
|
|
603
|
+
await self._ws.send(encode_websocket_text(message))
|
|
604
|
+
except websockets.exceptions.ConnectionClosed as e:
|
|
605
|
+
self._connected = False
|
|
606
|
+
raise ConnectionError("Connection closed") from e
|
|
607
|
+
except Exception as e:
|
|
608
|
+
msg = f"Failed to send message: {e}"
|
|
609
|
+
raise ConnectionError(msg) from e
|
|
610
|
+
|
|
611
|
+
async def receive(self) -> AsyncGenerator[dict[str, Any]]:
|
|
612
|
+
"""Receive messages from the daemon.
|
|
613
|
+
|
|
614
|
+
Yields:
|
|
615
|
+
Message dicts received from the daemon.
|
|
616
|
+
|
|
617
|
+
Raises:
|
|
618
|
+
ConnectionError: If not connected or receive fails.
|
|
619
|
+
"""
|
|
620
|
+
if not self._ws or not self._connected:
|
|
621
|
+
raise ConnectionError("Not connected to daemon")
|
|
622
|
+
|
|
623
|
+
try:
|
|
624
|
+
async for message in self._ws:
|
|
625
|
+
try:
|
|
626
|
+
message_str = message.decode("utf-8") if isinstance(message, bytes) else message
|
|
627
|
+
msg_dict = decode_websocket_text(message_str)
|
|
628
|
+
if msg_dict:
|
|
629
|
+
yield msg_dict
|
|
630
|
+
except Exception:
|
|
631
|
+
logger.exception("Error parsing message")
|
|
632
|
+
continue
|
|
633
|
+
except websockets.exceptions.ConnectionClosed:
|
|
634
|
+
self._connected = False
|
|
635
|
+
except Exception as e:
|
|
636
|
+
self._connected = False
|
|
637
|
+
msg = f"Connection error: {e}"
|
|
638
|
+
raise ConnectionError(msg) from e
|
|
639
|
+
|
|
640
|
+
@property
|
|
641
|
+
def client_id(self) -> str:
|
|
642
|
+
"""Get the client identifier.
|
|
643
|
+
|
|
644
|
+
Returns:
|
|
645
|
+
Client identifier string (8 hex chars).
|
|
646
|
+
"""
|
|
647
|
+
return self._client_id
|
|
648
|
+
|
|
649
|
+
@property
|
|
650
|
+
def is_connected(self) -> bool:
|
|
651
|
+
"""Check if connected to the daemon.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
True if connected, False otherwise.
|
|
655
|
+
"""
|
|
656
|
+
return self._connected
|
|
657
|
+
|
|
658
|
+
@property
|
|
659
|
+
def inbound_dropped(self) -> int:
|
|
660
|
+
"""Cumulative inbound frames evicted due to queue overflow."""
|
|
661
|
+
return self._inbound_dropped
|
|
662
|
+
|
|
663
|
+
def set_stream_degraded_callback(self, callback: Callable[[int, str], None] | None) -> None:
|
|
664
|
+
"""Set callback for stream degradation notifications (IG-534).
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
callback: Function called with (dropped_count, reason) when frames
|
|
668
|
+
are dropped due to inbound queue overflow. Pass None to disable.
|
|
669
|
+
"""
|
|
670
|
+
self._on_stream_degraded = callback
|
|
671
|
+
|
|
672
|
+
def is_connection_alive(self) -> bool:
|
|
673
|
+
"""Check if WebSocket connection is actually alive (not closed).
|
|
674
|
+
|
|
675
|
+
This is a deeper check than is_connected - it verifies the actual
|
|
676
|
+
WebSocket state, not just the client-side flag.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
True if WebSocket is open and not closed, False otherwise.
|
|
680
|
+
"""
|
|
681
|
+
from websockets.asyncio.connection import State
|
|
682
|
+
|
|
683
|
+
return self._ws is not None and self._ws.state == State.OPEN
|
|
684
|
+
|
|
685
|
+
# -----------------------------------------------------------------------
|
|
686
|
+
# Protocol-1 client API (RFC-450 §5/§9, IG-522 Phase 5)
|
|
687
|
+
# -----------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
def _next_request_id(self) -> str:
|
|
690
|
+
"""Generate a unique request correlation ID (RFC-450 §5.2).
|
|
691
|
+
|
|
692
|
+
Uses :func:`uuid.uuid4` to produce a globally unique hex string. The
|
|
693
|
+
same ID space serves ``request`` and ``subscribe`` operations.
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
32-character hex string.
|
|
697
|
+
"""
|
|
698
|
+
return uuid.uuid4().hex
|
|
699
|
+
|
|
700
|
+
async def request(
|
|
701
|
+
self,
|
|
702
|
+
method: str,
|
|
703
|
+
params: dict[str, Any] | None = None,
|
|
704
|
+
*,
|
|
705
|
+
timeout: float = 5.0,
|
|
706
|
+
proto: str = "1",
|
|
707
|
+
) -> dict[str, Any]:
|
|
708
|
+
"""Send a blocking RPC request and await the matching response (RFC-450 §5/§9).
|
|
709
|
+
|
|
710
|
+
Constructs a ``WireEnvelope`` with ``type='request'``, sends it over
|
|
711
|
+
the transport, and waits for a ``response`` or ``error`` message
|
|
712
|
+
whose ``id`` matches the generated correlation ID.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
method: RPC method name (e.g. ``"loop_get"``, ``"daemon_status"``).
|
|
716
|
+
params: Structured parameters object for the method.
|
|
717
|
+
timeout: Maximum seconds to wait for a response.
|
|
718
|
+
proto: Protocol version string (default ``"1"``).
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
The ``result`` dict from the matching ``response`` envelope.
|
|
722
|
+
|
|
723
|
+
Raises:
|
|
724
|
+
ConnectionError: If not connected or the connection closes while
|
|
725
|
+
waiting.
|
|
726
|
+
ProtocolError: If the daemon returns an ``error`` envelope with a
|
|
727
|
+
matching ``id``.
|
|
728
|
+
TimeoutError: If no matching response arrives within ``timeout``.
|
|
729
|
+
"""
|
|
730
|
+
from soothe_sdk.wire.codec import MessageType, ProtocolError, WireEnvelope
|
|
731
|
+
|
|
732
|
+
req_id = self._next_request_id()
|
|
733
|
+
envelope = WireEnvelope(
|
|
734
|
+
proto=proto,
|
|
735
|
+
type=MessageType.REQUEST.value,
|
|
736
|
+
method=method,
|
|
737
|
+
params=params or {},
|
|
738
|
+
id=req_id,
|
|
739
|
+
)
|
|
740
|
+
await self.send(envelope.to_wire_dict())
|
|
741
|
+
|
|
742
|
+
try:
|
|
743
|
+
async with asyncio.timeout(timeout):
|
|
744
|
+
while True:
|
|
745
|
+
event = await self._read_inbound_event()
|
|
746
|
+
if not event:
|
|
747
|
+
if not self.is_connection_alive():
|
|
748
|
+
self._connected = False
|
|
749
|
+
raise ConnectionError("Connection closed")
|
|
750
|
+
raise TimeoutError(
|
|
751
|
+
f"WebSocket closed while waiting for response to {method} (id={req_id})"
|
|
752
|
+
)
|
|
753
|
+
event_id = event.get("id")
|
|
754
|
+
event_type = event.get("type")
|
|
755
|
+
# Route responses/errors by id; queue non-matching for later.
|
|
756
|
+
if event_id != req_id:
|
|
757
|
+
self._pending_events.append(event)
|
|
758
|
+
continue
|
|
759
|
+
if event_type == MessageType.ERROR.value:
|
|
760
|
+
err_obj = event.get("error") or {}
|
|
761
|
+
raise ProtocolError(
|
|
762
|
+
code=int(err_obj.get("code", -32603)),
|
|
763
|
+
message=str(err_obj.get("message", "daemon error")),
|
|
764
|
+
data=err_obj.get("data"),
|
|
765
|
+
)
|
|
766
|
+
if event_type == MessageType.RESPONSE.value:
|
|
767
|
+
return event.get("result") or {}
|
|
768
|
+
# complete/unsubscribe/etc with same id — unexpected for
|
|
769
|
+
# a blocking request; keep waiting.
|
|
770
|
+
continue
|
|
771
|
+
except TimeoutError:
|
|
772
|
+
raise TimeoutError(
|
|
773
|
+
f"Daemon did not respond to {method} within {timeout}s (id={req_id})"
|
|
774
|
+
) from None
|
|
775
|
+
|
|
776
|
+
async def notify(
|
|
777
|
+
self,
|
|
778
|
+
method: str,
|
|
779
|
+
params: dict[str, Any] | None = None,
|
|
780
|
+
*,
|
|
781
|
+
proto: str = "1",
|
|
782
|
+
) -> None:
|
|
783
|
+
"""Send a fire-and-forget notification (RFC-450 §5/§9).
|
|
784
|
+
|
|
785
|
+
Notifications carry no ``id`` and the daemon does not reply. Use for
|
|
786
|
+
operations like ``loop_input`` (fire-and-forget) and ``disconnect``.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
method: Notification method name (e.g. ``"loop_input"``).
|
|
790
|
+
params: Structured parameters object.
|
|
791
|
+
proto: Protocol version string (default ``"1"``).
|
|
792
|
+
|
|
793
|
+
Raises:
|
|
794
|
+
ConnectionError: If not connected or send fails.
|
|
795
|
+
"""
|
|
796
|
+
from soothe_sdk.wire.codec import MessageType, WireEnvelope
|
|
797
|
+
|
|
798
|
+
envelope = WireEnvelope(
|
|
799
|
+
proto=proto,
|
|
800
|
+
type=MessageType.NOTIFICATION.value,
|
|
801
|
+
method=method,
|
|
802
|
+
params=params or {},
|
|
803
|
+
)
|
|
804
|
+
await self.send(envelope.to_wire_dict())
|
|
805
|
+
|
|
806
|
+
async def subscribe(
|
|
807
|
+
self,
|
|
808
|
+
method: str,
|
|
809
|
+
params: dict[str, Any] | None = None,
|
|
810
|
+
*,
|
|
811
|
+
timeout: float = 5.0,
|
|
812
|
+
proto: str = "1",
|
|
813
|
+
) -> str:
|
|
814
|
+
"""Start a subscription stream (RFC-450 §5/§9).
|
|
815
|
+
|
|
816
|
+
Sends a ``subscribe`` envelope with a generated correlation ``id``.
|
|
817
|
+
The daemon delivers stream events as ``next`` messages carrying the
|
|
818
|
+
same ``id``; the stream terminates with ``complete`` or ``error``.
|
|
819
|
+
Subscription confirmation is implicit — if the daemon cannot honour
|
|
820
|
+
the subscription it sends an ``error`` with the subscription ``id``.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
method: Subscription target (e.g. ``"loop_events"``).
|
|
824
|
+
params: Subscription parameters (e.g. ``{"loop_id": "abc"}``).
|
|
825
|
+
timeout: Maximum seconds to wait for an initial error if the
|
|
826
|
+
daemon rejects the subscription. If no error arrives within
|
|
827
|
+
this window the subscription is assumed accepted.
|
|
828
|
+
proto: Protocol version string (default ``"1"``).
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
The subscription ``id`` for later correlation and ``unsubscribe()``.
|
|
832
|
+
|
|
833
|
+
Raises:
|
|
834
|
+
ConnectionError: If not connected.
|
|
835
|
+
ProtocolError: If the daemon sends an ``error`` with the matching
|
|
836
|
+
``id`` within the ``timeout`` window.
|
|
837
|
+
"""
|
|
838
|
+
from soothe_sdk.wire.codec import MessageType, ProtocolError, WireEnvelope
|
|
839
|
+
|
|
840
|
+
sub_id = self._next_request_id()
|
|
841
|
+
envelope = WireEnvelope(
|
|
842
|
+
proto=proto,
|
|
843
|
+
type=MessageType.SUBSCRIBE.value,
|
|
844
|
+
method=method,
|
|
845
|
+
params=params or {},
|
|
846
|
+
id=sub_id,
|
|
847
|
+
)
|
|
848
|
+
await self.send(envelope.to_wire_dict())
|
|
849
|
+
|
|
850
|
+
# Check for an immediate rejection (error with matching id). We poll
|
|
851
|
+
# inbound events with a short timeout; if no error arrives we assume
|
|
852
|
+
# the subscription was accepted and return the id for stream reading.
|
|
853
|
+
try:
|
|
854
|
+
async with asyncio.timeout(timeout):
|
|
855
|
+
while True:
|
|
856
|
+
event = await self._read_inbound_event()
|
|
857
|
+
if not event:
|
|
858
|
+
# Connection closed before any response — assume
|
|
859
|
+
# accepted; caller will discover closure via next().
|
|
860
|
+
break
|
|
861
|
+
event_id = event.get("id")
|
|
862
|
+
event_type = event.get("type")
|
|
863
|
+
if event_id == sub_id and event_type == MessageType.ERROR.value:
|
|
864
|
+
err_obj = event.get("error") or {}
|
|
865
|
+
raise ProtocolError(
|
|
866
|
+
code=int(err_obj.get("code", -32603)),
|
|
867
|
+
message=str(err_obj.get("message", "subscription rejected")),
|
|
868
|
+
data=err_obj.get("data"),
|
|
869
|
+
)
|
|
870
|
+
# Re-queue: a next/complete or unrelated event.
|
|
871
|
+
self._pending_events.append(event)
|
|
872
|
+
# If we already got a next or complete, subscription is live.
|
|
873
|
+
if event_id == sub_id and event_type in (
|
|
874
|
+
MessageType.NEXT.value,
|
|
875
|
+
MessageType.COMPLETE.value,
|
|
876
|
+
):
|
|
877
|
+
break
|
|
878
|
+
except TimeoutError:
|
|
879
|
+
pass # No error within timeout — subscription assumed accepted.
|
|
880
|
+
|
|
881
|
+
return sub_id
|
|
882
|
+
|
|
883
|
+
async def unsubscribe(
|
|
884
|
+
self,
|
|
885
|
+
subscription_id: str,
|
|
886
|
+
*,
|
|
887
|
+
proto: str = "1",
|
|
888
|
+
) -> None:
|
|
889
|
+
"""Cancel an active subscription (RFC-450 §5/§9).
|
|
890
|
+
|
|
891
|
+
Sends an ``unsubscribe`` envelope carrying the subscription ``id``.
|
|
892
|
+
The daemon stops delivering ``next`` events for that ``id`` and may
|
|
893
|
+
send a final ``complete``.
|
|
894
|
+
|
|
895
|
+
Args:
|
|
896
|
+
subscription_id: The ``id`` returned by :meth:`subscribe`.
|
|
897
|
+
proto: Protocol version string (default ``"1"``).
|
|
898
|
+
|
|
899
|
+
Raises:
|
|
900
|
+
ConnectionError: If not connected.
|
|
901
|
+
"""
|
|
902
|
+
from soothe_sdk.wire.codec import MessageType, WireEnvelope
|
|
903
|
+
|
|
904
|
+
envelope = WireEnvelope(
|
|
905
|
+
proto=proto,
|
|
906
|
+
type=MessageType.UNSUBSCRIBE.value,
|
|
907
|
+
id=subscription_id,
|
|
908
|
+
)
|
|
909
|
+
await self.send(envelope.to_wire_dict())
|
|
910
|
+
|
|
911
|
+
async def next(self) -> dict[str, Any] | None:
|
|
912
|
+
"""Read the next protocol-1 stream event from the daemon (RFC-450 §5/§9).
|
|
913
|
+
|
|
914
|
+
This is the primary stream-event reader for protocol-1 messages. It
|
|
915
|
+
replaces :meth:`read_event` for protocol-1 consumers. For ``next``
|
|
916
|
+
messages (subscription stream events) the ``payload`` is returned.
|
|
917
|
+
For ``complete`` and ``error`` messages the full envelope is returned
|
|
918
|
+
so the caller can inspect the ``id`` and error details. For all other
|
|
919
|
+
messages (e.g. ``response``, ``connection_ack``) the full envelope
|
|
920
|
+
is returned.
|
|
921
|
+
|
|
922
|
+
Returns:
|
|
923
|
+
The event ``payload`` for ``next`` messages, or the full envelope
|
|
924
|
+
dict for other message types. Returns ``None`` on EOF (connection
|
|
925
|
+
closed).
|
|
926
|
+
|
|
927
|
+
Raises:
|
|
928
|
+
ConnectionError: If not connected (only when no reader task is
|
|
929
|
+
active; the background reader returns ``None`` on EOF).
|
|
930
|
+
"""
|
|
931
|
+
from soothe_sdk.wire.codec import MessageType
|
|
932
|
+
|
|
933
|
+
if self._pending_events:
|
|
934
|
+
event = self._pending_events.popleft()
|
|
935
|
+
else:
|
|
936
|
+
event = await self._read_inbound_event()
|
|
937
|
+
|
|
938
|
+
if not event:
|
|
939
|
+
return None
|
|
940
|
+
|
|
941
|
+
event_type = event.get("type")
|
|
942
|
+
if event_type == MessageType.NEXT.value:
|
|
943
|
+
return event.get("payload") or {}
|
|
944
|
+
return event
|
|
945
|
+
|
|
946
|
+
async def send_input(
|
|
947
|
+
self,
|
|
948
|
+
loop_id: str,
|
|
949
|
+
text: str,
|
|
950
|
+
*,
|
|
951
|
+
autonomous: bool = False,
|
|
952
|
+
max_iterations: int | None = None,
|
|
953
|
+
preferred_subagent: str | None = None,
|
|
954
|
+
model: str | None = None,
|
|
955
|
+
model_params: dict[str, Any] | None = None,
|
|
956
|
+
router_profile: str | None = None,
|
|
957
|
+
attachments: list[dict[str, str]] | None = None,
|
|
958
|
+
intent_hint: str | None = None,
|
|
959
|
+
response_schema: dict[str, Any] | None = None,
|
|
960
|
+
response_schema_name: str | None = None,
|
|
961
|
+
response_schema_strict: bool | None = None,
|
|
962
|
+
clarification_mode: str | None = None,
|
|
963
|
+
clarification_answer: bool = False,
|
|
964
|
+
clarification_answers: list[str] | None = None,
|
|
965
|
+
) -> None:
|
|
966
|
+
"""Send user input to the daemon for a subscribed loop (``loop_input``).
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
loop_id: Loop identifier for the subscribed loop.
|
|
970
|
+
text: User input text.
|
|
971
|
+
autonomous: Enable autonomous iteration mode.
|
|
972
|
+
max_iterations: Maximum iterations for autonomous mode.
|
|
973
|
+
preferred_subagent: Preferred subagent hint for routing.
|
|
974
|
+
model: Provider:model override string.
|
|
975
|
+
model_params: Additional model parameters.
|
|
976
|
+
router_profile: Named router profile for chat-role overlay this turn.
|
|
977
|
+
attachments: Image attachments (mime_type + base64 data).
|
|
978
|
+
intent_hint: Daemon-only direct model hint. Supported values:
|
|
979
|
+
``text_completion`` (``default`` role, text-only),
|
|
980
|
+
``image_to_text`` (``image`` role, attachments required),
|
|
981
|
+
``ocr`` (``ocr`` role, attachments required),
|
|
982
|
+
``embed`` (``embedding`` role, text-only; returns JSON vector).
|
|
983
|
+
``response_schema`` is supported for ``text_completion`` and
|
|
984
|
+
``image_to_text``. Agent-path pass-through hints (e.g.
|
|
985
|
+
``resume_clarification``, ``skill:foo``) are forwarded unchanged.
|
|
986
|
+
Legacy ``direct_llm`` and ``quiz`` are rejected before send.
|
|
987
|
+
|
|
988
|
+
Raises:
|
|
989
|
+
ValueError: When ``intent_hint`` is a removed legacy value.
|
|
990
|
+
clarification_mode: RFC-622 clarification relay mode for this turn
|
|
991
|
+
(``"auto"`` / ``"manual"``). ``None`` lets the daemon fall back
|
|
992
|
+
to its configured default.
|
|
993
|
+
clarification_answer: When True, the daemon treats this input as the
|
|
994
|
+
answer to the loop's currently pending clarification interrupt
|
|
995
|
+
(RFC-622) and resumes the graph via ``Command(resume=...)``
|
|
996
|
+
instead of starting a new turn. Hint only — daemon verifies via
|
|
997
|
+
the loop's persisted state and falls back to a normal turn when
|
|
998
|
+
no clarification is pending.
|
|
999
|
+
clarification_answers: Optional per-question answer list paired with
|
|
1000
|
+
``clarification_answer=True`` for multi-question clarifications.
|
|
1001
|
+
When set, the daemon resumes with one answer per question (no
|
|
1002
|
+
broadcast), so the step card and working memory carry exactly
|
|
1003
|
+
what the user typed — instead of a single concatenated string
|
|
1004
|
+
being broadcast to every question.
|
|
1005
|
+
"""
|
|
1006
|
+
params: dict[str, Any] = {
|
|
1007
|
+
"loop_id": loop_id,
|
|
1008
|
+
"content": text,
|
|
1009
|
+
}
|
|
1010
|
+
if autonomous:
|
|
1011
|
+
params["autonomous"] = True
|
|
1012
|
+
if max_iterations is not None:
|
|
1013
|
+
params["max_iterations"] = max_iterations
|
|
1014
|
+
if preferred_subagent is not None:
|
|
1015
|
+
params["preferred_subagent"] = preferred_subagent
|
|
1016
|
+
if model:
|
|
1017
|
+
params["model"] = model
|
|
1018
|
+
if model_params:
|
|
1019
|
+
params["model_params"] = model_params
|
|
1020
|
+
if router_profile:
|
|
1021
|
+
params["router_profile"] = router_profile
|
|
1022
|
+
if attachments:
|
|
1023
|
+
params["attachments"] = attachments
|
|
1024
|
+
if intent_hint:
|
|
1025
|
+
hint_error = validate_loop_input_intent_hint(intent_hint)
|
|
1026
|
+
if hint_error is not None:
|
|
1027
|
+
raise ValueError(hint_error)
|
|
1028
|
+
params["intent_hint"] = intent_hint
|
|
1029
|
+
if response_schema:
|
|
1030
|
+
params["response_schema"] = response_schema
|
|
1031
|
+
if response_schema_name:
|
|
1032
|
+
params["response_schema_name"] = response_schema_name
|
|
1033
|
+
if response_schema_strict is not None:
|
|
1034
|
+
params["response_schema_strict"] = response_schema_strict
|
|
1035
|
+
if clarification_mode is not None:
|
|
1036
|
+
params["clarification_mode"] = clarification_mode
|
|
1037
|
+
if clarification_answer:
|
|
1038
|
+
params["clarification_answer"] = True
|
|
1039
|
+
if clarification_answers is not None:
|
|
1040
|
+
params["clarification_answers"] = list(clarification_answers)
|
|
1041
|
+
await self.notify("loop_input", params)
|
|
1042
|
+
|
|
1043
|
+
async def loop_list(
|
|
1044
|
+
self,
|
|
1045
|
+
*,
|
|
1046
|
+
limit: int = 20,
|
|
1047
|
+
filter: dict[str, Any] | None = None,
|
|
1048
|
+
timeout: float = 15.0,
|
|
1049
|
+
) -> dict[str, Any]:
|
|
1050
|
+
"""List loops via ``loop_list`` RPC."""
|
|
1051
|
+
params: dict[str, Any] = {"limit": limit}
|
|
1052
|
+
if filter:
|
|
1053
|
+
params["filter"] = filter
|
|
1054
|
+
return await self.request("loop_list", params, timeout=timeout)
|
|
1055
|
+
|
|
1056
|
+
async def loop_get(
|
|
1057
|
+
self,
|
|
1058
|
+
loop_id: str,
|
|
1059
|
+
*,
|
|
1060
|
+
verbose: bool = False,
|
|
1061
|
+
timeout: float = 15.0,
|
|
1062
|
+
) -> dict[str, Any]:
|
|
1063
|
+
"""Fetch one loop via ``loop_get`` RPC."""
|
|
1064
|
+
return await self.request(
|
|
1065
|
+
"loop_get",
|
|
1066
|
+
{"loop_id": loop_id, "verbose": verbose},
|
|
1067
|
+
timeout=timeout,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
async def loop_history_fetch(self, loop_id: str, *, timeout: float = 30.0) -> dict[str, Any]:
|
|
1071
|
+
"""Fetch goal display snapshots + live card tail (``loop_history_fetch``)."""
|
|
1072
|
+
return await self.request(
|
|
1073
|
+
"loop_history_fetch",
|
|
1074
|
+
{"loop_id": loop_id},
|
|
1075
|
+
timeout=timeout,
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
async def loop_cards_fetch(self, loop_id: str, *, timeout: float = 30.0) -> dict[str, Any]:
|
|
1079
|
+
"""Fetch bound display-card snapshot (``loop_cards_fetch``)."""
|
|
1080
|
+
return await self.request(
|
|
1081
|
+
"loop_cards_fetch",
|
|
1082
|
+
{"loop_id": loop_id},
|
|
1083
|
+
timeout=timeout,
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
async def loop_messages(
|
|
1087
|
+
self,
|
|
1088
|
+
loop_id: str,
|
|
1089
|
+
*,
|
|
1090
|
+
limit: int = 100,
|
|
1091
|
+
offset: int = 0,
|
|
1092
|
+
include_events: bool = False,
|
|
1093
|
+
timeout: float = 10.0,
|
|
1094
|
+
) -> dict[str, Any]:
|
|
1095
|
+
"""Load persisted conversation rows (``loop_messages``)."""
|
|
1096
|
+
return await self.request(
|
|
1097
|
+
"loop_messages",
|
|
1098
|
+
{
|
|
1099
|
+
"loop_id": loop_id,
|
|
1100
|
+
"limit": limit,
|
|
1101
|
+
"offset": offset,
|
|
1102
|
+
"include_events": include_events,
|
|
1103
|
+
},
|
|
1104
|
+
timeout=timeout,
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
async def loop_state_get(self, loop_id: str, *, timeout: float = 30.0) -> dict[str, Any]:
|
|
1108
|
+
"""Load StrangeLoop state channels (``loop_state_get``)."""
|
|
1109
|
+
return await self.request(
|
|
1110
|
+
"loop_state_get",
|
|
1111
|
+
{"loop_id": loop_id},
|
|
1112
|
+
timeout=timeout,
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
async def loop_state_update(
|
|
1116
|
+
self,
|
|
1117
|
+
loop_id: str,
|
|
1118
|
+
values: dict[str, Any],
|
|
1119
|
+
*,
|
|
1120
|
+
as_node: str | None = None,
|
|
1121
|
+
timeout: float = 10.0,
|
|
1122
|
+
) -> dict[str, Any]:
|
|
1123
|
+
"""Merge partial state into a loop (``loop_state_update``)."""
|
|
1124
|
+
from soothe_sdk.wire.protocol import _serialize_for_json
|
|
1125
|
+
|
|
1126
|
+
payload_values = _serialize_for_json(values)
|
|
1127
|
+
if not isinstance(payload_values, dict):
|
|
1128
|
+
return {}
|
|
1129
|
+
params: dict[str, Any] = {"loop_id": loop_id, "values": payload_values}
|
|
1130
|
+
if as_node:
|
|
1131
|
+
params["as_node"] = as_node
|
|
1132
|
+
return await self.request("loop_state_update", params, timeout=timeout)
|
|
1133
|
+
|
|
1134
|
+
def _reset_disconnect_signal(self) -> None:
|
|
1135
|
+
"""Clear the mid-session drop signal (called from ``connect``)."""
|
|
1136
|
+
self._disconn_event = asyncio.Event()
|
|
1137
|
+
self._disconn_cause = None
|
|
1138
|
+
self._disconn_fired = False
|
|
1139
|
+
|
|
1140
|
+
def _signal_disconnect(self, cause: DisconnectCause) -> None:
|
|
1141
|
+
"""Fire the disconnect signal exactly once."""
|
|
1142
|
+
if self._disconn_fired:
|
|
1143
|
+
return
|
|
1144
|
+
self._disconn_fired = True
|
|
1145
|
+
self._disconn_cause = cause
|
|
1146
|
+
self._disconn_event.set()
|
|
1147
|
+
callback = self._on_disconnected
|
|
1148
|
+
if callback is not None:
|
|
1149
|
+
with contextlib.suppress(Exception):
|
|
1150
|
+
callback(cause)
|
|
1151
|
+
|
|
1152
|
+
def set_disconnected_callback(
|
|
1153
|
+
self,
|
|
1154
|
+
callback: Callable[[DisconnectCause], None] | None,
|
|
1155
|
+
) -> None:
|
|
1156
|
+
"""Register a sync callback invoked once when the connection drops."""
|
|
1157
|
+
self._on_disconnected = callback
|
|
1158
|
+
|
|
1159
|
+
def is_disconnected(self) -> bool:
|
|
1160
|
+
"""Return whether the disconnect signal has fired."""
|
|
1161
|
+
return self._disconn_fired
|
|
1162
|
+
|
|
1163
|
+
def disconnect_cause(self) -> DisconnectCause | None:
|
|
1164
|
+
"""Return the disconnect cause, or None if still connected."""
|
|
1165
|
+
if not self._disconn_fired:
|
|
1166
|
+
return None
|
|
1167
|
+
return self._disconn_cause if self._disconn_cause is not None else DisconnectCause.UNCLEAN
|
|
1168
|
+
|
|
1169
|
+
async def wait_disconnected(self) -> DisconnectCause:
|
|
1170
|
+
"""Wait until the disconnect signal fires and return its cause."""
|
|
1171
|
+
await self._disconn_event.wait()
|
|
1172
|
+
cause = self.disconnect_cause()
|
|
1173
|
+
return cause if cause is not None else DisconnectCause.UNCLEAN
|
|
1174
|
+
|
|
1175
|
+
async def reconnect(
|
|
1176
|
+
self,
|
|
1177
|
+
*,
|
|
1178
|
+
max_attempts: int = 10,
|
|
1179
|
+
initial_delay_s: float = 0.5,
|
|
1180
|
+
max_delay_s: float = 10.0,
|
|
1181
|
+
) -> None:
|
|
1182
|
+
"""Re-dial and re-handshake after a drop (does not re-subscribe loops)."""
|
|
1183
|
+
last_err: BaseException | None = None
|
|
1184
|
+
delay = initial_delay_s
|
|
1185
|
+
for attempt in range(1, max_attempts + 1):
|
|
1186
|
+
try:
|
|
1187
|
+
await self.connect()
|
|
1188
|
+
await self.request_connection_init()
|
|
1189
|
+
await self.wait_for_connection_ack()
|
|
1190
|
+
return
|
|
1191
|
+
except Exception as exc:
|
|
1192
|
+
last_err = exc
|
|
1193
|
+
if attempt < max_attempts:
|
|
1194
|
+
await asyncio.sleep(delay)
|
|
1195
|
+
delay = min(delay * 2, max_delay_s)
|
|
1196
|
+
raise ReconnectError(self._url, max_attempts, last_err)
|
|
1197
|
+
|
|
1198
|
+
async def reattach_and_probe(
|
|
1199
|
+
self,
|
|
1200
|
+
loop_id: str,
|
|
1201
|
+
*,
|
|
1202
|
+
reattach_timeout_s: float = 15.0,
|
|
1203
|
+
subscribe_timeout_s: float = 10.0,
|
|
1204
|
+
probe_timeout_s: float = 5.0,
|
|
1205
|
+
stream_delivery: str = "adaptive",
|
|
1206
|
+
wire_tier: str = "full",
|
|
1207
|
+
) -> None:
|
|
1208
|
+
"""Resume a loop after reconnect: reattach, subscribe, ``loop_get`` probe.
|
|
1209
|
+
|
|
1210
|
+
Raises:
|
|
1211
|
+
ValueError: Empty ``loop_id``.
|
|
1212
|
+
StaleLoopError: Reattach accepted but liveness probe failed.
|
|
1213
|
+
ProtocolError / ConnectionError: Transport or RPC failures before probe.
|
|
1214
|
+
"""
|
|
1215
|
+
from soothe_sdk.wire.codec import ProtocolError
|
|
1216
|
+
|
|
1217
|
+
lid = str(loop_id or "").strip()
|
|
1218
|
+
if not lid:
|
|
1219
|
+
raise ValueError("reattach_and_probe requires a loop id")
|
|
1220
|
+
|
|
1221
|
+
try:
|
|
1222
|
+
await self.request(
|
|
1223
|
+
"loop_reattach",
|
|
1224
|
+
{"loop_id": lid},
|
|
1225
|
+
timeout=reattach_timeout_s,
|
|
1226
|
+
)
|
|
1227
|
+
except ProtocolError as exc:
|
|
1228
|
+
raise StaleLoopError(lid, exc) from exc
|
|
1229
|
+
|
|
1230
|
+
await self.subscribe(
|
|
1231
|
+
"loop_events",
|
|
1232
|
+
{
|
|
1233
|
+
"loop_id": lid,
|
|
1234
|
+
"stream_delivery": stream_delivery,
|
|
1235
|
+
"wire_tier": wire_tier,
|
|
1236
|
+
},
|
|
1237
|
+
timeout=subscribe_timeout_s,
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
try:
|
|
1241
|
+
await self.loop_get(lid, verbose=False, timeout=probe_timeout_s)
|
|
1242
|
+
except ProtocolError as exc:
|
|
1243
|
+
if getattr(exc, "code", None) == -32200:
|
|
1244
|
+
raise StaleLoopError(lid, exc) from exc
|
|
1245
|
+
raise StaleLoopError(lid, exc) from exc
|
|
1246
|
+
except Exception as exc:
|
|
1247
|
+
raise StaleLoopError(lid, exc) from exc
|
|
1248
|
+
|
|
1249
|
+
async def list_skills(self, *, timeout: float = 15.0) -> dict[str, Any]:
|
|
1250
|
+
"""Request wire-safe skill metadata from the daemon (RFC-400 ``skills_list``)."""
|
|
1251
|
+
return await self.request("skills_list", {}, timeout=timeout)
|
|
1252
|
+
|
|
1253
|
+
async def list_models(self, *, timeout: float = 15.0) -> dict[str, Any]:
|
|
1254
|
+
"""Request model catalog rows from the daemon host ``SootheConfig`` (RFC-400 ``models_list``)."""
|
|
1255
|
+
return await self.request("models_list", {}, timeout=timeout)
|
|
1256
|
+
|
|
1257
|
+
async def get_mcp_status(self, *, timeout: float = 15.0) -> dict[str, Any]:
|
|
1258
|
+
"""Request MCP server status from the daemon."""
|
|
1259
|
+
return await self.request("mcp_status", {}, timeout=timeout)
|
|
1260
|
+
|
|
1261
|
+
async def invoke_skill(
|
|
1262
|
+
self,
|
|
1263
|
+
skill: str,
|
|
1264
|
+
args: str = "",
|
|
1265
|
+
*,
|
|
1266
|
+
timeout: float = 120.0,
|
|
1267
|
+
clarification_mode: str | None = None,
|
|
1268
|
+
) -> dict[str, Any]:
|
|
1269
|
+
"""Resolve a skill on the daemon host and receive echo before streaming (RFC-400).
|
|
1270
|
+
|
|
1271
|
+
Args:
|
|
1272
|
+
skill: Skill identifier (e.g. ``"my-plugin:my-skill"``).
|
|
1273
|
+
args: Free-form argument string appended after the skill name.
|
|
1274
|
+
timeout: RPC timeout in seconds.
|
|
1275
|
+
clarification_mode: RFC-622 clarification relay mode for the
|
|
1276
|
+
synthetic turn the daemon will enqueue (``"auto"`` /
|
|
1277
|
+
``"manual"``). ``None`` lets the daemon fall back to its
|
|
1278
|
+
configured default. Without this, slash-skill turns ignore
|
|
1279
|
+
the client's Manual badge and always defer to the config
|
|
1280
|
+
default — typically ``"auto"`` (veritas).
|
|
1281
|
+
"""
|
|
1282
|
+
params: dict[str, Any] = {"skill": skill, "args": args}
|
|
1283
|
+
if clarification_mode is not None:
|
|
1284
|
+
params["clarification_mode"] = clarification_mode
|
|
1285
|
+
return await self.request("invoke_skill", params, timeout=timeout)
|
|
1286
|
+
|
|
1287
|
+
async def fetch_daemon_status(
|
|
1288
|
+
self,
|
|
1289
|
+
*,
|
|
1290
|
+
timeout: float = 5.0,
|
|
1291
|
+
min_interval_s: float = 1.0,
|
|
1292
|
+
) -> dict[str, Any]:
|
|
1293
|
+
"""Fetch ``daemon_status_response`` with TTL cache and in-flight coalescing.
|
|
1294
|
+
|
|
1295
|
+
Pollers that call this several times per second only trigger one RPC per
|
|
1296
|
+
``min_interval_s`` window; concurrent callers share a single in-flight
|
|
1297
|
+
request.
|
|
1298
|
+
|
|
1299
|
+
Args:
|
|
1300
|
+
timeout: Per-request timeout passed to :meth:`request`.
|
|
1301
|
+
min_interval_s: Minimum seconds between real RPCs. Use ``0`` to
|
|
1302
|
+
disable caching and always hit the daemon.
|
|
1303
|
+
|
|
1304
|
+
Returns:
|
|
1305
|
+
Parsed daemon status response dict.
|
|
1306
|
+
|
|
1307
|
+
Raises:
|
|
1308
|
+
Same as :meth:`request` (timeout, connection errors, etc.).
|
|
1309
|
+
"""
|
|
1310
|
+
if min_interval_s <= 0:
|
|
1311
|
+
return await self.request("daemon_status", {}, timeout=timeout)
|
|
1312
|
+
|
|
1313
|
+
async with self._daemon_status_lock:
|
|
1314
|
+
now = time.monotonic()
|
|
1315
|
+
if self._daemon_status_cache is not None:
|
|
1316
|
+
ts, cached = self._daemon_status_cache
|
|
1317
|
+
if now - ts < min_interval_s:
|
|
1318
|
+
return dict(cached)
|
|
1319
|
+
|
|
1320
|
+
if self._daemon_status_inflight is None:
|
|
1321
|
+
self._daemon_status_inflight = asyncio.create_task(
|
|
1322
|
+
self.request("daemon_status", {}, timeout=timeout)
|
|
1323
|
+
)
|
|
1324
|
+
inflight = self._daemon_status_inflight
|
|
1325
|
+
|
|
1326
|
+
assert inflight is not None
|
|
1327
|
+
try:
|
|
1328
|
+
result = await inflight
|
|
1329
|
+
except BaseException:
|
|
1330
|
+
async with self._daemon_status_lock:
|
|
1331
|
+
if self._daemon_status_inflight is inflight:
|
|
1332
|
+
self._daemon_status_inflight = None
|
|
1333
|
+
raise
|
|
1334
|
+
|
|
1335
|
+
async with self._daemon_status_lock:
|
|
1336
|
+
if self._daemon_status_inflight is inflight:
|
|
1337
|
+
self._daemon_status_inflight = None
|
|
1338
|
+
self._daemon_status_cache = (time.monotonic(), dict(result))
|
|
1339
|
+
return dict(result)
|
|
1340
|
+
|
|
1341
|
+
async def request_connection_init(self) -> None:
|
|
1342
|
+
"""Send ``connection_init`` to the daemon (RFC-450 §8.2).
|
|
1343
|
+
|
|
1344
|
+
This is the first message the client sends after the WebSocket upgrade.
|
|
1345
|
+
The daemon responds with ``connection_ack`` containing readiness state,
|
|
1346
|
+
negotiated protocol version, and capabilities.
|
|
1347
|
+
|
|
1348
|
+
This method is safe to call even if the connection may be closed.
|
|
1349
|
+
If the connection is closed, this method silently succeeds —
|
|
1350
|
+
``wait_for_connection_ack()`` will either find a pending ack or timeout.
|
|
1351
|
+
"""
|
|
1352
|
+
from soothe_sdk.wire.codec import ConnectionInitEnvelope, ConnectionInitParams
|
|
1353
|
+
|
|
1354
|
+
envelope = ConnectionInitEnvelope(
|
|
1355
|
+
params=ConnectionInitParams(
|
|
1356
|
+
client_version=client_version,
|
|
1357
|
+
client_name="soothe-sdk",
|
|
1358
|
+
accept_proto=["1"],
|
|
1359
|
+
capabilities=["streaming", "batch", "heartbeat"],
|
|
1360
|
+
)
|
|
1361
|
+
)
|
|
1362
|
+
try:
|
|
1363
|
+
await self.send(envelope.to_wire_dict())
|
|
1364
|
+
except ConnectionError:
|
|
1365
|
+
logger.debug(
|
|
1366
|
+
"[Client:%s] request_connection_init failed (connection closed), "
|
|
1367
|
+
"will check pending events",
|
|
1368
|
+
self._client_id,
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
async def wait_for_connection_ack(self, ack_timeout_s: float = 10.0) -> dict[str, Any]:
|
|
1372
|
+
"""Wait for ``connection_ack`` and require ready state (RFC-450 §8.2).
|
|
1373
|
+
|
|
1374
|
+
Args:
|
|
1375
|
+
ack_timeout_s: Maximum seconds to wait for the ack.
|
|
1376
|
+
|
|
1377
|
+
Returns:
|
|
1378
|
+
The ``connection_ack`` result dict on success.
|
|
1379
|
+
|
|
1380
|
+
Raises:
|
|
1381
|
+
ConnectionError: If protocol version is incompatible.
|
|
1382
|
+
RuntimeError: If daemon reports ``error``, ``degraded``, or another
|
|
1383
|
+
non-ready terminal state.
|
|
1384
|
+
TimeoutError: If timeout expires.
|
|
1385
|
+
"""
|
|
1386
|
+
async with asyncio.timeout(ack_timeout_s):
|
|
1387
|
+
while True:
|
|
1388
|
+
event = self._pop_pending_event_by_type("connection_ack")
|
|
1389
|
+
if event is None:
|
|
1390
|
+
event = await self._read_inbound_event()
|
|
1391
|
+
if not event:
|
|
1392
|
+
if not self.is_connection_alive():
|
|
1393
|
+
self._connected = False
|
|
1394
|
+
raise ConnectionError("Connection closed")
|
|
1395
|
+
raise TimeoutError("No connection_ack received")
|
|
1396
|
+
if event.get("type") != "connection_ack":
|
|
1397
|
+
# Discard the initial ``status`` frame — keeping it in
|
|
1398
|
+
# ``_pending_events`` would block ``connection_ack`` in
|
|
1399
|
+
# the inbound queue.
|
|
1400
|
+
if event.get("type") != "status":
|
|
1401
|
+
self._pending_events.append(event)
|
|
1402
|
+
continue
|
|
1403
|
+
|
|
1404
|
+
result = event.get("result") or {}
|
|
1405
|
+
state = result.get("readiness_state")
|
|
1406
|
+
proto_ver = result.get("protocol_version")
|
|
1407
|
+
caps = result.get("capabilities", [])
|
|
1408
|
+
hb_interval = result.get("heartbeat_interval_ms", 0)
|
|
1409
|
+
|
|
1410
|
+
# Store negotiated values
|
|
1411
|
+
self._protocol_version = proto_ver
|
|
1412
|
+
self._negotiated_capabilities = set(caps)
|
|
1413
|
+
self._heartbeat_interval_ms = int(hb_interval) if hb_interval else 0
|
|
1414
|
+
self._handshake_complete = True
|
|
1415
|
+
|
|
1416
|
+
if state == "incompatible":
|
|
1417
|
+
raise ConnectionError(
|
|
1418
|
+
f"Protocol version incompatible: daemon returned {proto_ver!r}"
|
|
1419
|
+
)
|
|
1420
|
+
if state == "ready":
|
|
1421
|
+
# Start heartbeat if negotiated
|
|
1422
|
+
if (
|
|
1423
|
+
"heartbeat" in self._negotiated_capabilities
|
|
1424
|
+
and self._heartbeat_interval_ms > 0
|
|
1425
|
+
):
|
|
1426
|
+
self._start_heartbeat()
|
|
1427
|
+
return event
|
|
1428
|
+
if state == "error":
|
|
1429
|
+
raise RuntimeError("Daemon startup failed")
|
|
1430
|
+
if state == "degraded":
|
|
1431
|
+
raise RuntimeError("Daemon is degraded")
|
|
1432
|
+
if state in _TRANSITIONAL_DAEMON_READY_STATES:
|
|
1433
|
+
await asyncio.sleep(_DAEMON_READY_POLL_INTERVAL_S)
|
|
1434
|
+
await self.request_connection_init()
|
|
1435
|
+
continue
|
|
1436
|
+
raise RuntimeError(f"Daemon state is {state}")
|
|
1437
|
+
|
|
1438
|
+
def _start_heartbeat(self) -> None:
|
|
1439
|
+
"""Start the client-side heartbeat ping sender (RFC-450 §8.3).
|
|
1440
|
+
|
|
1441
|
+
Sends ``ping`` frames at the negotiated interval. If no ``pong`` is
|
|
1442
|
+
received within the timeout window, the connection is considered dead.
|
|
1443
|
+
"""
|
|
1444
|
+
if self._heartbeat_task is not None and not self._heartbeat_task.done():
|
|
1445
|
+
return
|
|
1446
|
+
interval_s = self._heartbeat_interval_ms / 1000.0
|
|
1447
|
+
if interval_s <= 0:
|
|
1448
|
+
return
|
|
1449
|
+
# Baseline liveness for this socket — ignore pre-reconnect pong timestamps.
|
|
1450
|
+
self._last_pong_monotonic = time.monotonic()
|
|
1451
|
+
self._heartbeat_task = asyncio.create_task(
|
|
1452
|
+
self._heartbeat_loop(interval_s),
|
|
1453
|
+
name=f"soothe-ws-heartbeat-{self._client_id}",
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
async def _heartbeat_loop(self, interval_s: float) -> None:
|
|
1457
|
+
"""Send periodic ping frames and detect dead connections (RFC-450 §8.3).
|
|
1458
|
+
|
|
1459
|
+
Args:
|
|
1460
|
+
interval_s: Ping interval in seconds.
|
|
1461
|
+
"""
|
|
1462
|
+
import time
|
|
1463
|
+
|
|
1464
|
+
timeout_s = max(self._heartbeat_timeout_ms / 1000.0, interval_s * 2)
|
|
1465
|
+
try:
|
|
1466
|
+
while self._connected and self._ws is not None:
|
|
1467
|
+
await asyncio.sleep(interval_s)
|
|
1468
|
+
if not self._connected or self._ws is None:
|
|
1469
|
+
break
|
|
1470
|
+
now = time.monotonic()
|
|
1471
|
+
last_pong = self._last_pong_monotonic or now
|
|
1472
|
+
if now - last_pong > interval_s + timeout_s:
|
|
1473
|
+
logger.warning(
|
|
1474
|
+
"[Client:%s] Heartbeat timeout (no pong in %.1fs), closing",
|
|
1475
|
+
self._client_id,
|
|
1476
|
+
now - last_pong,
|
|
1477
|
+
)
|
|
1478
|
+
self._connected = False
|
|
1479
|
+
with contextlib.suppress(Exception):
|
|
1480
|
+
await self._ws.close()
|
|
1481
|
+
return
|
|
1482
|
+
try:
|
|
1483
|
+
await self._ws.send(encode_websocket_text({"proto": "1", "type": "ping"}))
|
|
1484
|
+
except websockets.exceptions.ConnectionClosed:
|
|
1485
|
+
self._connected = False
|
|
1486
|
+
return
|
|
1487
|
+
except Exception:
|
|
1488
|
+
logger.debug("[Client:%s] Failed to send heartbeat ping", self._client_id)
|
|
1489
|
+
return
|
|
1490
|
+
except asyncio.CancelledError:
|
|
1491
|
+
raise
|
|
1492
|
+
|
|
1493
|
+
def _handle_pong(self) -> None:
|
|
1494
|
+
"""Mark that a pong was received from the daemon (heartbeat liveness)."""
|
|
1495
|
+
import time
|
|
1496
|
+
|
|
1497
|
+
self._last_pong_monotonic = time.monotonic()
|
|
1498
|
+
|
|
1499
|
+
def _pop_pending_event_by_type(self, event_type: str) -> dict[str, Any] | None:
|
|
1500
|
+
"""Pop the first pending event of ``event_type`` while preserving queue order."""
|
|
1501
|
+
if not self._pending_events:
|
|
1502
|
+
return None
|
|
1503
|
+
|
|
1504
|
+
kept_events: deque[dict[str, Any]] = deque()
|
|
1505
|
+
matched: dict[str, Any] | None = None
|
|
1506
|
+
|
|
1507
|
+
while self._pending_events:
|
|
1508
|
+
event = self._pending_events.popleft()
|
|
1509
|
+
if matched is None and event.get("type") == event_type:
|
|
1510
|
+
matched = event
|
|
1511
|
+
continue
|
|
1512
|
+
kept_events.append(event)
|
|
1513
|
+
|
|
1514
|
+
self._pending_events = kept_events
|
|
1515
|
+
return matched
|
|
1516
|
+
|
|
1517
|
+
async def _read_inbound_event(self) -> dict[str, Any] | None:
|
|
1518
|
+
"""Read the next frame from the transport queue, ignoring ``_pending_events``.
|
|
1519
|
+
|
|
1520
|
+
Used for RPC/handshake waits so a stray ``status`` frame cannot block matching
|
|
1521
|
+
responses that arrive later on the inbound queue.
|
|
1522
|
+
"""
|
|
1523
|
+
if self._reader_task is not None:
|
|
1524
|
+
return await self._inbound_queue.get()
|
|
1525
|
+
|
|
1526
|
+
return await self._read_from_socket()
|
|
1527
|
+
|
|
1528
|
+
async def read_event(self) -> dict[str, Any] | None:
|
|
1529
|
+
"""Read the next event from the daemon.
|
|
1530
|
+
|
|
1531
|
+
Returns:
|
|
1532
|
+
Parsed event dict, or ``None`` on EOF.
|
|
1533
|
+
"""
|
|
1534
|
+
if self._pending_events:
|
|
1535
|
+
return self._pending_events.popleft()
|
|
1536
|
+
|
|
1537
|
+
return await self._read_inbound_event()
|
|
1538
|
+
|
|
1539
|
+
def clear_pending_events(self) -> None:
|
|
1540
|
+
"""Clear all pending events from the internal queue.
|
|
1541
|
+
|
|
1542
|
+
Useful in tests to discard setup-phase events that should not
|
|
1543
|
+
affect isolation verification.
|
|
1544
|
+
"""
|
|
1545
|
+
self._pending_events.clear()
|
|
1546
|
+
|
|
1547
|
+
# Handshake / RPC responses that must not count as turn progress (TUI stall detection).
|
|
1548
|
+
# ``card.replay_*`` / ``card.created`` are emitted by the daemon during
|
|
1549
|
+
# ``loop_subscribe`` for non-TUI clients (RFC-413). The TUI consumes its
|
|
1550
|
+
# cards via the synchronous ``loop_cards_fetch`` RPC so these frames are
|
|
1551
|
+
# peeled silently here. Under protocol-1, RPC responses arrive as
|
|
1552
|
+
# ``type:"response"`` correlated by ``id`` (peeled by the reader loop), so
|
|
1553
|
+
# the legacy ``*_response`` type entries are gone. Under protocol-1 (RFC-450
|
|
1554
|
+
# §9.3) card replay frames arrive wrapped in ``next`` envelopes with
|
|
1555
|
+
# ``payload.mode`` set to the originating frame type; the peel logic below
|
|
1556
|
+
# inspects both the raw ``type`` and the wrapped ``payload.mode``.
|
|
1557
|
+
_STALE_TURN_PENDING_TYPES = frozenset(
|
|
1558
|
+
{
|
|
1559
|
+
"connection_ack",
|
|
1560
|
+
"card.replay_begin",
|
|
1561
|
+
"card.replay_end",
|
|
1562
|
+
"card.created",
|
|
1563
|
+
}
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
def peel_stale_pending_control_events(self) -> list[str]:
|
|
1567
|
+
"""Remove stale handshake/RPC frames left in ``_pending_events`` before a turn.
|
|
1568
|
+
|
|
1569
|
+
``request`` queues unrelated inbound frames while waiting for a matching
|
|
1570
|
+
``id``. If a handshake/control frame remains at turn start, the TUI can
|
|
1571
|
+
mistake it for live progress and never log a stalled stream.
|
|
1572
|
+
|
|
1573
|
+
Returns:
|
|
1574
|
+
List of removed frame types (in order).
|
|
1575
|
+
"""
|
|
1576
|
+
if not self._pending_events:
|
|
1577
|
+
return []
|
|
1578
|
+
|
|
1579
|
+
kept: deque[dict[str, Any]] = deque()
|
|
1580
|
+
removed: list[str] = []
|
|
1581
|
+
while self._pending_events:
|
|
1582
|
+
event = self._pending_events.popleft()
|
|
1583
|
+
event_type = str(event.get("type") or "")
|
|
1584
|
+
# Protocol-1 wraps card replay frames in ``next`` envelopes; peel
|
|
1585
|
+
# them by inspecting ``payload.mode`` so they don't masquerade as
|
|
1586
|
+
# live progress at turn start.
|
|
1587
|
+
stale_mode = ""
|
|
1588
|
+
if event_type == "next":
|
|
1589
|
+
payload = event.get("payload")
|
|
1590
|
+
if isinstance(payload, dict):
|
|
1591
|
+
stale_mode = str(payload.get("mode") or "")
|
|
1592
|
+
if event_type in self._STALE_TURN_PENDING_TYPES:
|
|
1593
|
+
removed.append(event_type)
|
|
1594
|
+
continue
|
|
1595
|
+
if stale_mode and stale_mode in self._STALE_TURN_PENDING_TYPES:
|
|
1596
|
+
removed.append(stale_mode)
|
|
1597
|
+
continue
|
|
1598
|
+
kept.append(event)
|
|
1599
|
+
self._pending_events = kept
|
|
1600
|
+
return removed
|
|
1601
|
+
|
|
1602
|
+
async def _read_from_socket(self) -> dict[str, Any] | None:
|
|
1603
|
+
"""Read one event directly from the websocket transport."""
|
|
1604
|
+
|
|
1605
|
+
if not self._ws or not self._connected:
|
|
1606
|
+
return None
|
|
1607
|
+
|
|
1608
|
+
try:
|
|
1609
|
+
message = await self._ws.recv()
|
|
1610
|
+
if isinstance(message, bytes):
|
|
1611
|
+
message = message.decode("utf-8")
|
|
1612
|
+
return decode_websocket_text(message)
|
|
1613
|
+
except websockets.exceptions.ConnectionClosed:
|
|
1614
|
+
return None
|
|
1615
|
+
except Exception:
|
|
1616
|
+
logger.exception("Error reading event")
|
|
1617
|
+
return None
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
__all__ = [
|
|
1621
|
+
"WebSocketClient",
|
|
1622
|
+
"_inbound_frame_drop_priority", # IG-535: Exported for testing
|
|
1623
|
+
"_DROP_PRIORITY_CRITICAL",
|
|
1624
|
+
"_DROP_PRIORITY_HIGH",
|
|
1625
|
+
"_DROP_PRIORITY_NORMAL",
|
|
1626
|
+
]
|