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.
@@ -0,0 +1,404 @@
1
+ """WebSocket helper functions for daemon communication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ from typing import Any
9
+
10
+ from soothe_client.websocket import WebSocketClient
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def websocket_url_from_config(cfg: Any) -> str:
16
+ """Construct WebSocket URL from a config-like object.
17
+
18
+ Duck-typed across the three workspace configs:
19
+
20
+ * ``CLIConfig`` (`soothe-cli`) exposes ``daemon_host`` / ``daemon_port``
21
+ and a ``websocket_url()`` helper.
22
+ * ``SootheDaemonConfig`` (`soothe-daemon`) exposes
23
+ ``transports.websocket.host`` / ``.port``.
24
+ * Legacy callers may still pass an object with
25
+ ``daemon.transports.websocket.host`` / ``.port``.
26
+
27
+ The SDK deliberately does not import any of those classes — keeping
28
+ `soothe_sdk` independent of `soothe`, `soothe_cli`, and `soothe_daemon`.
29
+
30
+ Args:
31
+ cfg: Any object exposing one of the shapes above.
32
+
33
+ Returns:
34
+ WebSocket URL string (e.g., ``"ws://127.0.0.1:8765"``).
35
+
36
+ Raises:
37
+ AttributeError: If ``cfg`` exposes none of the supported shapes.
38
+ """
39
+ if hasattr(cfg, "websocket_url") and callable(cfg.websocket_url):
40
+ url = cfg.websocket_url()
41
+ if isinstance(url, str) and url:
42
+ return url
43
+
44
+ if hasattr(cfg, "daemon_host") and hasattr(cfg, "daemon_port"):
45
+ return f"ws://{cfg.daemon_host}:{cfg.daemon_port}"
46
+
47
+ transports = getattr(cfg, "transports", None)
48
+ if transports is None:
49
+ daemon = getattr(cfg, "daemon", None)
50
+ transports = getattr(daemon, "transports", None) if daemon is not None else None
51
+
52
+ if transports is not None:
53
+ websocket = getattr(transports, "websocket", None)
54
+ if websocket is not None:
55
+ return f"ws://{websocket.host}:{websocket.port}"
56
+
57
+ raise AttributeError(
58
+ "websocket_url_from_config: object does not expose websocket settings; "
59
+ "expected daemon_host/daemon_port, transports.websocket, or "
60
+ "daemon.transports.websocket"
61
+ )
62
+
63
+
64
+ async def _ensure_handshake(client: WebSocketClient, *, timeout: float) -> None:
65
+ """Complete the protocol-1 readiness handshake when not already done (RFC-450 §8.2).
66
+
67
+ Args:
68
+ client: Connected WebSocketClient.
69
+ timeout: Maximum seconds to wait for ``connection_ack``.
70
+
71
+ Raises:
72
+ ConnectionError: If the WebSocket is closed or protocol is incompatible.
73
+ RuntimeError: If the daemon reports a non-ready terminal state.
74
+ TimeoutError: If ``connection_ack`` does not arrive in time.
75
+ """
76
+ if client._handshake_complete:
77
+ return
78
+ await client.request_connection_init()
79
+ await client.wait_for_connection_ack(ack_timeout_s=timeout)
80
+
81
+
82
+ async def check_daemon_status(
83
+ client: WebSocketClient,
84
+ timeout: float = 5.0,
85
+ *,
86
+ min_interval_s: float = 1.0,
87
+ handshake_timeout: float | None = None,
88
+ ) -> dict:
89
+ """Check daemon status via RPC.
90
+
91
+ Performs ``connection_init`` / ``connection_ack`` when needed, then uses
92
+ ``WebSocketClient.fetch_daemon_status`` so rapid or overlapping polls on the
93
+ same connection coalesce into one wire request per ``min_interval_s``.
94
+
95
+ Args:
96
+ client: Connected WebSocketClient
97
+ timeout: Request timeout in seconds for ``daemon_status`` RPC
98
+ min_interval_s: Minimum seconds between real ``daemon_status`` RPCs; ``0``
99
+ always queries the daemon.
100
+ handshake_timeout: Seconds to wait for ``connection_ack``; defaults to
101
+ ``timeout``.
102
+
103
+ Returns:
104
+ Parsed `daemon_status_response` payload (typically includes `running`,
105
+ `port_live`, and a numeric count of in-flight client query work).
106
+
107
+ Raises:
108
+ ConnectionError: If daemon not reachable
109
+ """
110
+ ack_timeout = handshake_timeout if handshake_timeout is not None else timeout
111
+ await _ensure_handshake(client, timeout=ack_timeout)
112
+ return await client.fetch_daemon_status(timeout=timeout, min_interval_s=min_interval_s)
113
+
114
+
115
+ def _daemon_status_indicates_live(status: dict) -> bool:
116
+ """Infer liveness from a ``daemon_status`` response payload.
117
+
118
+ Uses ``readiness_state`` (RFC-450 §8.2): transitional states (``starting``,
119
+ ``warming``) mean the daemon is not yet ready for loops; terminal states
120
+ (``error``, ``degraded``, ``stopped``) mean it cannot serve loops; only
121
+ ``ready`` is live for loop operations.
122
+
123
+ Args:
124
+ status: Daemon status response dict.
125
+
126
+ Returns:
127
+ True if daemon is live and ready for loop operations, False otherwise.
128
+ """
129
+ readiness_state = status.get("readiness_state")
130
+ if readiness_state in {"starting", "warming"}:
131
+ return False
132
+ if readiness_state in {"error", "degraded", "stopped"}:
133
+ return False
134
+ return readiness_state == "ready"
135
+
136
+
137
+ async def is_daemon_live(
138
+ ws_url: str,
139
+ timeout: float = 5.0,
140
+ wait_for_ready: bool = False,
141
+ ready_timeout: float = 30.0,
142
+ ) -> bool:
143
+ """Composite health check: connection + status RPC.
144
+
145
+ Optionally waits for daemon to reach "ready" state (IG-489), polling during
146
+ transitional states like "starting" and "warming".
147
+
148
+ Args:
149
+ ws_url: WebSocket URL to check
150
+ timeout: Per-request timeout for connection + RPC
151
+ wait_for_ready: If True, poll until daemon is "ready" (not transitional)
152
+ ready_timeout: Max seconds to wait for ready state when wait_for_ready=True
153
+
154
+ Returns:
155
+ True if daemon is live (and ready if wait_for_ready=True), False otherwise
156
+ """
157
+ attempts = 3
158
+ delay_s = 0.35
159
+ last_error: Exception | None = None
160
+
161
+ # When waiting for ready, we need to poll during transitional states
162
+ if wait_for_ready:
163
+ # Use monotonic time via asyncio for consistent timing
164
+ try:
165
+ loop = asyncio.get_running_loop()
166
+ start_time = loop.time()
167
+ except RuntimeError:
168
+ start_time = 0.0
169
+
170
+ while True:
171
+ for attempt in range(attempts):
172
+ client: WebSocketClient | None = None
173
+ try:
174
+ client = WebSocketClient(url=ws_url)
175
+ await client.connect()
176
+ try:
177
+ loop = asyncio.get_running_loop()
178
+ elapsed = loop.time() - start_time
179
+ except RuntimeError:
180
+ elapsed = 0.0
181
+ remaining = max(0.1, ready_timeout - elapsed)
182
+ status = await check_daemon_status(
183
+ client,
184
+ timeout=timeout,
185
+ handshake_timeout=min(timeout, remaining),
186
+ )
187
+
188
+ # Check if daemon is ready
189
+ readiness_state = status.get("readiness_state")
190
+ if readiness_state == "ready":
191
+ return True
192
+
193
+ # Check if transitional - continue polling
194
+ if readiness_state in {"starting", "warming"}:
195
+ # Calculate remaining time
196
+ try:
197
+ loop = asyncio.get_running_loop()
198
+ elapsed = loop.time() - start_time
199
+ except RuntimeError:
200
+ elapsed = 0.0
201
+
202
+ if elapsed >= ready_timeout:
203
+ logger.debug(
204
+ "Daemon not ready after %s seconds (state: %s)",
205
+ ready_timeout,
206
+ readiness_state,
207
+ )
208
+ return False
209
+ # Wait and retry
210
+ await asyncio.sleep(delay_s)
211
+ break # Exit attempt loop, continue polling
212
+
213
+ # Terminal state (error, degraded, stopped) or unknown
214
+ return _daemon_status_indicates_live(status)
215
+ except Exception as exc:
216
+ last_error = exc
217
+ if attempt < attempts - 1:
218
+ await asyncio.sleep(delay_s)
219
+ finally:
220
+ if client is not None:
221
+ with contextlib.suppress(Exception):
222
+ await client.close()
223
+
224
+ # Check timeout after exhausting attempts
225
+ try:
226
+ loop = asyncio.get_running_loop()
227
+ elapsed = loop.time() - start_time
228
+ except RuntimeError:
229
+ elapsed = 0.0
230
+
231
+ if elapsed >= ready_timeout:
232
+ break
233
+
234
+ if last_error is not None:
235
+ logger.debug("Daemon health check failed for %s: %s", ws_url, last_error)
236
+ return False
237
+
238
+ # Standard liveness check without waiting
239
+ for attempt in range(attempts):
240
+ client: WebSocketClient | None = None
241
+ try:
242
+ client = WebSocketClient(url=ws_url)
243
+ await client.connect()
244
+ status = await check_daemon_status(client, timeout=timeout)
245
+ return _daemon_status_indicates_live(status)
246
+ except Exception as exc:
247
+ last_error = exc
248
+ if attempt < attempts - 1:
249
+ await asyncio.sleep(delay_s)
250
+ finally:
251
+ if client is not None:
252
+ with contextlib.suppress(Exception):
253
+ await client.close()
254
+
255
+ if last_error is not None:
256
+ logger.debug("Daemon health check failed for %s: %s", ws_url, last_error)
257
+ return False
258
+
259
+
260
+ async def request_daemon_shutdown(client: WebSocketClient, timeout: float = 10.0) -> None:
261
+ """Request daemon shutdown via RPC.
262
+
263
+ Args:
264
+ client: Connected WebSocketClient
265
+ timeout: Shutdown timeout in seconds
266
+
267
+ Raises:
268
+ RuntimeError: If shutdown fails
269
+ """
270
+ try:
271
+ response = await client.request("daemon_shutdown", {}, timeout=timeout)
272
+ if response.get("status") != "acknowledged":
273
+ raise RuntimeError(f"Shutdown failed: {response}")
274
+ except Exception as e:
275
+ raise RuntimeError(f"Shutdown failed: {e}") from e
276
+
277
+
278
+ async def request_daemon_config_reload(
279
+ client: WebSocketClient, timeout: float = 5.0
280
+ ) -> dict[str, Any]:
281
+ """Request daemon config reload via RPC.
282
+
283
+ Args:
284
+ client: Connected WebSocketClient
285
+ timeout: Request timeout in seconds
286
+
287
+ Returns:
288
+ Response dict with success status and optional error message
289
+
290
+ Raises:
291
+ ConnectionError: If daemon not reachable
292
+ RuntimeError: If reload request fails
293
+ """
294
+ await _ensure_handshake(client, timeout=timeout)
295
+ response = await client.request("config_reload", {}, timeout=timeout)
296
+ return response
297
+
298
+
299
+ async def fetch_skills_catalog(client: WebSocketClient, timeout: float = 15.0) -> list[dict]:
300
+ """Fetch skills catalog via RPC.
301
+
302
+ Args:
303
+ client: Connected WebSocketClient
304
+ timeout: Request timeout in seconds
305
+
306
+ Returns:
307
+ List of skill metadata dicts (wire-safe, no local parsing)
308
+
309
+ Raises:
310
+ ConnectionError: If daemon not reachable
311
+ """
312
+ response = await client.request("skills_list", {}, timeout=timeout)
313
+ return response.get("skills", [])
314
+
315
+
316
+ async def fetch_config_section(client: WebSocketClient, section: str, timeout: float = 5.0) -> dict:
317
+ """Fetch daemon config section via RPC.
318
+
319
+ Performs ``connection_init`` / ``connection_ack`` handshake when needed
320
+ before sending the request (RFC-450 §8.2).
321
+
322
+ Args:
323
+ client: Connected WebSocketClient
324
+ section: Config section name (e.g., "providers", "defaults")
325
+ timeout: Request timeout in seconds
326
+
327
+ Returns:
328
+ Wire-safe config section dict
329
+
330
+ Raises:
331
+ ConnectionError: If daemon not reachable
332
+ """
333
+ await _ensure_handshake(client, timeout=timeout)
334
+ response = await client.request("config_get", {"section": section}, timeout=timeout)
335
+ return response.get(section, {})
336
+
337
+
338
+ async def fetch_loop_history(
339
+ client: WebSocketClient,
340
+ loop_id: str,
341
+ *,
342
+ timeout: float = 30.0,
343
+ ) -> dict[str, Any]:
344
+ """Fetch goal display snapshots plus live card tail via RPC."""
345
+ lid = str(loop_id or "").strip()
346
+ if not lid:
347
+ raise ValueError("loop_id is required")
348
+ await _ensure_handshake(client, timeout=timeout)
349
+ return await client.loop_history_fetch(lid, timeout=timeout)
350
+
351
+
352
+ async def fetch_loop_cards(
353
+ client: WebSocketClient,
354
+ loop_id: str,
355
+ *,
356
+ timeout: float = 30.0,
357
+ ) -> dict[str, Any]:
358
+ """Fetch the daemon's bound display-card snapshot for a loop."""
359
+ lid = str(loop_id or "").strip()
360
+ if not lid:
361
+ raise ValueError("loop_id is required")
362
+ await _ensure_handshake(client, timeout=timeout)
363
+ return await client.loop_cards_fetch(lid, timeout=timeout)
364
+
365
+
366
+ async def fetch_loop_messages(
367
+ client: WebSocketClient,
368
+ loop_id: str,
369
+ *,
370
+ limit: int = 100,
371
+ offset: int = 0,
372
+ include_events: bool = False,
373
+ timeout: float = 10.0,
374
+ ) -> list[dict[str, Any]]:
375
+ """Load persisted conversation rows for a loop."""
376
+ lid = str(loop_id or "").strip()
377
+ if not lid:
378
+ return []
379
+ await _ensure_handshake(client, timeout=timeout)
380
+ resp = await client.loop_messages(
381
+ lid,
382
+ limit=limit,
383
+ offset=offset,
384
+ include_events=include_events,
385
+ timeout=timeout,
386
+ )
387
+ raw = resp.get("messages")
388
+ if not isinstance(raw, list):
389
+ return []
390
+ return [m for m in raw if isinstance(m, dict)]
391
+
392
+
393
+ __all__ = [
394
+ "websocket_url_from_config",
395
+ "check_daemon_status",
396
+ "is_daemon_live",
397
+ "request_daemon_shutdown",
398
+ "request_daemon_config_reload",
399
+ "fetch_skills_catalog",
400
+ "fetch_config_section",
401
+ "fetch_loop_history",
402
+ "fetch_loop_cards",
403
+ "fetch_loop_messages",
404
+ ]
@@ -0,0 +1,43 @@
1
+ """``loop_input.intent_hint`` constants and client-side validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ TEXT_COMPLETION: Final = "text_completion"
8
+ IMAGE_TO_TEXT: Final = "image_to_text"
9
+ OCR: Final = "ocr"
10
+ EMBED: Final = "embed"
11
+
12
+ REMOVED_INTENT_HINTS: frozenset[str] = frozenset({"direct_llm", "quiz"})
13
+
14
+ # Default deliverable phases for turn-ending replies (excludes plan_direct narration).
15
+ DEFAULT_DELIVERABLE_PHASES: frozenset[str] = frozenset(
16
+ {
17
+ "quiz",
18
+ "goal_completion",
19
+ "direct_model",
20
+ "text_completion",
21
+ "image_to_text",
22
+ "ocr",
23
+ "embed",
24
+ }
25
+ )
26
+
27
+ _REMOVED_INTENT_HINT_MESSAGES: dict[str, str] = {
28
+ "direct_llm": (
29
+ "intent_hint direct_llm is removed; "
30
+ "use text_completion (text-only) or image_to_text (with attachments)"
31
+ ),
32
+ "quiz": "intent_hint quiz is removed; omit intent_hint and let intake classify the turn",
33
+ }
34
+
35
+
36
+ def validate_loop_input_intent_hint(hint: str) -> str | None:
37
+ """Return an error message when ``hint`` is a removed legacy value.
38
+
39
+ Direct model hints and agent-path pass-through values (e.g.
40
+ ``resume_clarification``, ``skill:foo``) are allowed.
41
+ """
42
+ key = hint.strip().lower()
43
+ return _REMOVED_INTENT_HINT_MESSAGES.get(key)