soothe-client-python 0.9.4__py3-none-any.whl → 0.9.6__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 +27 -4
- soothe_client/appkit/__init__.py +7 -10
- soothe_client/appkit/attachments.py +4 -7
- soothe_client/appkit/broadcaster.py +1 -1
- soothe_client/appkit/classifier.py +1 -1
- soothe_client/appkit/daemon_session.py +40 -8
- soothe_client/appkit/managed_client.py +68 -6
- soothe_client/appkit/pool.py +1 -1
- soothe_client/appkit/query_gate.py +1 -1
- soothe_client/appkit/session_store.py +1 -1
- soothe_client/appkit/thinking_step.py +1 -1
- soothe_client/appkit/turn_runner.py +2 -2
- soothe_client/helpers.py +93 -18
- soothe_client/protocol_params.py +131 -67
- soothe_client/schemas.py +3 -9
- soothe_client/session.py +6 -7
- soothe_client/websocket.py +53 -45
- soothe_client/ws_command_client.py +29 -21
- soothe_client_python-0.9.6.dist-info/METADATA +85 -0
- soothe_client_python-0.9.6.dist-info/RECORD +28 -0
- soothe_client_python-0.9.4.dist-info/METADATA +0 -131
- soothe_client_python-0.9.4.dist-info/RECORD +0 -28
- {soothe_client_python-0.9.4.dist-info → soothe_client_python-0.9.6.dist-info}/WHEEL +0 -0
- {soothe_client_python-0.9.4.dist-info → soothe_client_python-0.9.6.dist-info}/licenses/LICENSE +0 -0
soothe_client/__init__.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
"""Soothe WebSocket client for soothe-daemon.
|
|
1
|
+
"""Soothe WebSocket client for talking to a running soothe-daemon.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
soothe-sdk (`soothe_sdk.wire`, `soothe_sdk.paths`).
|
|
3
|
+
Transport helpers (connect, loops, RPCs) live here. Higher-level session
|
|
4
|
+
and multi-user turn tools live in ``soothe_client.appkit``.
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
from __future__ import annotations
|
|
@@ -21,12 +20,14 @@ from soothe_client.errors import (
|
|
|
21
20
|
)
|
|
22
21
|
from soothe_client.helpers import (
|
|
23
22
|
check_daemon_status,
|
|
23
|
+
connected_websocket,
|
|
24
24
|
fetch_config_section,
|
|
25
25
|
fetch_loop_cards,
|
|
26
26
|
fetch_loop_history,
|
|
27
27
|
fetch_loop_messages,
|
|
28
28
|
fetch_skills_catalog,
|
|
29
29
|
is_daemon_live,
|
|
30
|
+
protocol1_rpc,
|
|
30
31
|
request_daemon_config_reload,
|
|
31
32
|
request_daemon_shutdown,
|
|
32
33
|
websocket_url_from_config,
|
|
@@ -42,7 +43,17 @@ from soothe_client.intent_hints import (
|
|
|
42
43
|
from soothe_client.protocol_params import (
|
|
43
44
|
AuthParams,
|
|
44
45
|
AuthRefreshParams,
|
|
46
|
+
AutopilotCancelGoalParams,
|
|
47
|
+
AutopilotDreamParams,
|
|
48
|
+
AutopilotGetGoalParams,
|
|
49
|
+
AutopilotGetJobParams,
|
|
50
|
+
AutopilotListGoalsParams,
|
|
51
|
+
AutopilotListJobsParams,
|
|
52
|
+
AutopilotResumeParams,
|
|
53
|
+
AutopilotStatusParams,
|
|
54
|
+
AutopilotSubmitParams,
|
|
45
55
|
AutopilotSubscribeParams,
|
|
56
|
+
AutopilotWakeParams,
|
|
46
57
|
ConfigGetParams,
|
|
47
58
|
ConfigReloadParams,
|
|
48
59
|
CronAddParams,
|
|
@@ -114,6 +125,8 @@ __all__ = [
|
|
|
114
125
|
"bootstrap_loop_session",
|
|
115
126
|
"connect_websocket_with_retries",
|
|
116
127
|
"websocket_url_from_config",
|
|
128
|
+
"connected_websocket",
|
|
129
|
+
"protocol1_rpc",
|
|
117
130
|
"check_daemon_status",
|
|
118
131
|
"is_daemon_live",
|
|
119
132
|
"request_daemon_config_reload",
|
|
@@ -144,6 +157,16 @@ __all__ = [
|
|
|
144
157
|
"LoopDetachParams",
|
|
145
158
|
"SubscribeParams",
|
|
146
159
|
"AutopilotSubscribeParams",
|
|
160
|
+
"AutopilotStatusParams",
|
|
161
|
+
"AutopilotSubmitParams",
|
|
162
|
+
"AutopilotListGoalsParams",
|
|
163
|
+
"AutopilotGetGoalParams",
|
|
164
|
+
"AutopilotCancelGoalParams",
|
|
165
|
+
"AutopilotWakeParams",
|
|
166
|
+
"AutopilotDreamParams",
|
|
167
|
+
"AutopilotResumeParams",
|
|
168
|
+
"AutopilotListJobsParams",
|
|
169
|
+
"AutopilotGetJobParams",
|
|
147
170
|
"JobCreateParams",
|
|
148
171
|
"JobStatusParams",
|
|
149
172
|
"JobPauseParams",
|
soothe_client/appkit/__init__.py
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Application helpers built on WebSocketClient.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
in the application (e.g. soothe-cli). Building blocks:
|
|
3
|
+
Product-agnostic building blocks for agent UIs and backends:
|
|
5
4
|
|
|
6
|
-
- ``
|
|
7
|
-
- ``
|
|
8
|
-
- ``
|
|
9
|
-
- ``
|
|
10
|
-
- ``
|
|
11
|
-
- ``SSEBroadcaster`` — drop-on-full SSE-style fan-out
|
|
12
|
-
- ``ConnectionPool`` / ``TurnRunner`` — pooled multi-session turn execution
|
|
5
|
+
- ``DaemonSession`` — dual-socket loop session + streamed turns
|
|
6
|
+
- ``ConnectionPool`` / ``TurnRunner`` — multi-session turn execution
|
|
7
|
+
- ``QueryGate`` — one-in-flight query per session
|
|
8
|
+
- ``EventClassifier`` / ``extract_thinking_step`` — stream → deliverable mapping
|
|
9
|
+
- ``SSEBroadcaster`` — drop-on-full fan-out to subscribers
|
|
13
10
|
- ``SessionStore`` — persistence seam (Protocol)
|
|
14
11
|
"""
|
|
15
12
|
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
"""Image attachment compaction for appkit
|
|
1
|
+
"""Image attachment compaction for appkit.
|
|
2
2
|
|
|
3
3
|
Downscales ``image/*`` payloads when either dimension exceeds a max size.
|
|
4
|
-
Non-images and decode failures pass through unchanged.
|
|
5
|
-
compaction is requested; without Pillow, inputs are returned unchanged.
|
|
4
|
+
Non-images and decode failures pass through unchanged.
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
from __future__ import annotations
|
|
@@ -12,6 +11,8 @@ import io
|
|
|
12
11
|
from dataclasses import dataclass
|
|
13
12
|
from typing import Any
|
|
14
13
|
|
|
14
|
+
from PIL import Image
|
|
15
|
+
|
|
15
16
|
|
|
16
17
|
@dataclass(slots=True)
|
|
17
18
|
class CompactImageOptions:
|
|
@@ -49,10 +50,6 @@ def compact_image_attachment(
|
|
|
49
50
|
"""
|
|
50
51
|
if not data_b64 or not mime_type.startswith("image/"):
|
|
51
52
|
return mime_type, data_b64
|
|
52
|
-
try:
|
|
53
|
-
from PIL import Image
|
|
54
|
-
except ImportError:
|
|
55
|
-
return mime_type, data_b64
|
|
56
53
|
|
|
57
54
|
try:
|
|
58
55
|
raw = base64.b64decode(data_b64, validate=False)
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
"""Dual-socket daemon loop session with turn streaming
|
|
1
|
+
"""Dual-socket daemon loop session with turn streaming.
|
|
2
2
|
|
|
3
3
|
Owns a subscribed stream WebSocket plus an RPC sidecar so metadata calls do not
|
|
4
|
-
starve
|
|
5
|
-
|
|
6
|
-
originated in soothe-cli and is not yet mirrored in Go/TS TurnRunner.
|
|
4
|
+
starve loop events. ``iter_turn_chunks`` handles idle timeout, post-idle drain,
|
|
5
|
+
loop scoping, and connection-loss detection.
|
|
7
6
|
"""
|
|
8
7
|
|
|
9
8
|
from __future__ import annotations
|
|
@@ -25,7 +24,12 @@ logger = logging.getLogger(__name__)
|
|
|
25
24
|
|
|
26
25
|
DEFAULT_POST_IDLE_DRAIN_S = 0.5
|
|
27
26
|
_RPC_HANDSHAKE_TIMEOUT_S = 20.0
|
|
28
|
-
_TURN_END_CUSTOM_TYPES = frozenset(
|
|
27
|
+
_TURN_END_CUSTOM_TYPES = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"soothe.cognition.strange_loop.completed",
|
|
30
|
+
"soothe.stream.end",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
29
33
|
|
|
30
34
|
EarlyDropFn = Callable[[tuple[Any, ...], str, Any], bool]
|
|
31
35
|
StatsFactory = Callable[[], Any]
|
|
@@ -210,6 +214,7 @@ class DaemonSession:
|
|
|
210
214
|
clarification_mode: str | None = None,
|
|
211
215
|
clarification_answer: bool = False,
|
|
212
216
|
clarification_answers: list[str] | None = None,
|
|
217
|
+
intent_hint: str | None = None,
|
|
213
218
|
) -> None:
|
|
214
219
|
"""Send a new user turn to the daemon."""
|
|
215
220
|
if not self._loop_id:
|
|
@@ -227,6 +232,7 @@ class DaemonSession:
|
|
|
227
232
|
clarification_mode=clarification_mode,
|
|
228
233
|
clarification_answer=clarification_answer,
|
|
229
234
|
clarification_answers=clarification_answers,
|
|
235
|
+
intent_hint=intent_hint,
|
|
230
236
|
)
|
|
231
237
|
|
|
232
238
|
async def cancel_remote_query(self) -> None:
|
|
@@ -289,8 +295,19 @@ class DaemonSession:
|
|
|
289
295
|
await self._ensure_rpc_connected()
|
|
290
296
|
return await self._rpc_client.request("loop_list", {"limit": limit}, timeout=15.0)
|
|
291
297
|
|
|
292
|
-
async def iter_turn_chunks(
|
|
293
|
-
|
|
298
|
+
async def iter_turn_chunks(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
max_wait_s: float | None = None,
|
|
302
|
+
) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
|
|
303
|
+
"""Yield ``(namespace, mode, data)`` chunks for the active daemon turn.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
max_wait_s: Optional absolute deadline for the whole turn. When set,
|
|
307
|
+
raises ``TimeoutError`` if the daemon never emits a turn-end
|
|
308
|
+
signal in time (idle after payload, stopped, stream.end, or
|
|
309
|
+
strange_loop.completed).
|
|
310
|
+
"""
|
|
294
311
|
self.turn_event_stats = self._new_turn_stats()
|
|
295
312
|
self.last_turn_end_state = None
|
|
296
313
|
self.last_turn_cancellation_seen = False
|
|
@@ -303,6 +320,9 @@ class DaemonSession:
|
|
|
303
320
|
turn_read_started = time.monotonic()
|
|
304
321
|
first_event_logged = False
|
|
305
322
|
progress_seen = False
|
|
323
|
+
absolute_deadline = (
|
|
324
|
+
time.monotonic() + max_wait_s if max_wait_s is not None and max_wait_s > 0 else None
|
|
325
|
+
)
|
|
306
326
|
peel = getattr(self._client, "peel_stale_pending_control_events", None)
|
|
307
327
|
stale_pending = peel() if callable(peel) else []
|
|
308
328
|
if stale_pending:
|
|
@@ -315,6 +335,11 @@ class DaemonSession:
|
|
|
315
335
|
async with self._read_lock:
|
|
316
336
|
try:
|
|
317
337
|
while True:
|
|
338
|
+
if absolute_deadline is not None and time.monotonic() >= absolute_deadline:
|
|
339
|
+
raise TimeoutError(
|
|
340
|
+
f"Turn timed out after {max_wait_s:.0f}s "
|
|
341
|
+
f"(loop={expected_loop_id or '?'})"
|
|
342
|
+
)
|
|
318
343
|
if not progress_seen and time.monotonic() - turn_read_started > 30.0:
|
|
319
344
|
logger.warning(
|
|
320
345
|
"No daemon stream progress after %.0fs (loop=%s, "
|
|
@@ -407,7 +432,14 @@ class DaemonSession:
|
|
|
407
432
|
if mode == "custom" and isinstance(data, dict):
|
|
408
433
|
custom_type = str(data.get("type", "")).strip()
|
|
409
434
|
if custom_type in _TURN_END_CUSTOM_TYPES:
|
|
410
|
-
|
|
435
|
+
# stream.end may be scope-tagged; treat blank / turn as end.
|
|
436
|
+
if custom_type == "soothe.stream.end":
|
|
437
|
+
scope = str(data.get("scope") or "turn").strip().lower()
|
|
438
|
+
if scope not in {"", "turn"}:
|
|
439
|
+
continue
|
|
440
|
+
self.last_turn_end_state = (
|
|
441
|
+
"stream_end" if custom_type == "soothe.stream.end" else "completed"
|
|
442
|
+
)
|
|
411
443
|
async for chunk in self._drain_stream_events_after_idle(
|
|
412
444
|
expected_loop_id=expected_loop_id,
|
|
413
445
|
):
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
"""ManagedClient
|
|
1
|
+
"""ManagedClient adapter used by ConnectionPool and TurnRunner.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Wraps ``WebSocketClient`` and upgrades flat appkit payloads
|
|
4
|
+
(``loop_input``, ``command_request``, …) to protocol-1 envelopes before send.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -18,10 +18,72 @@ BootstrapFunc = Callable[
|
|
|
18
18
|
Awaitable[str],
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
+
# Flat appkit payloads still use legacy ``{"type": "<op>", ...}`` maps (parity
|
|
22
|
+
# with Go InputMessageForLoop). The live daemon accepts protocol-1 envelopes
|
|
23
|
+
# only — coerce at the ManagedClient boundary before send.
|
|
24
|
+
_ALREADY_ENVELOPE_TYPES = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"request",
|
|
27
|
+
"response",
|
|
28
|
+
"notification",
|
|
29
|
+
"subscribe",
|
|
30
|
+
"next",
|
|
31
|
+
"error",
|
|
32
|
+
"complete",
|
|
33
|
+
"unsubscribe",
|
|
34
|
+
"connection_init",
|
|
35
|
+
"connection_ack",
|
|
36
|
+
"ping",
|
|
37
|
+
"pong",
|
|
38
|
+
"receipt_response",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
_NOTIFICATION_METHODS = frozenset({"loop_input", "slash_command", "disconnect", "delivery_ack"})
|
|
42
|
+
_REQUEST_METHOD_ALIASES = {
|
|
43
|
+
# TurnRunner cancel uses flat command_request; wire method is rpc_command.
|
|
44
|
+
"command_request": "rpc_command",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _coerce_appkit_wire_message(msg: dict[str, Any], client: WebSocketClient) -> dict[str, Any]:
|
|
49
|
+
"""Upgrade flat appkit dicts to protocol-1 envelopes when needed."""
|
|
50
|
+
from soothe_sdk.wire.codec import MessageType, WireEnvelope
|
|
51
|
+
|
|
52
|
+
msg_type = str(msg.get("type") or "")
|
|
53
|
+
if msg.get("proto") == "1" or msg_type in _ALREADY_ENVELOPE_TYPES:
|
|
54
|
+
return msg
|
|
55
|
+
|
|
56
|
+
params = {
|
|
57
|
+
key: value
|
|
58
|
+
for key, value in msg.items()
|
|
59
|
+
if key not in {"type", "proto", "method", "params", "id", "request_id"}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if msg_type in _NOTIFICATION_METHODS:
|
|
63
|
+
return WireEnvelope(
|
|
64
|
+
proto="1",
|
|
65
|
+
type=MessageType.NOTIFICATION.value,
|
|
66
|
+
method=msg_type,
|
|
67
|
+
params=params,
|
|
68
|
+
).to_wire_dict()
|
|
69
|
+
|
|
70
|
+
if msg_type in _REQUEST_METHOD_ALIASES or msg_type:
|
|
71
|
+
request_method = _REQUEST_METHOD_ALIASES.get(msg_type, msg_type)
|
|
72
|
+
req_id = str(msg.get("request_id") or msg.get("id") or client._next_request_id())
|
|
73
|
+
return WireEnvelope(
|
|
74
|
+
proto="1",
|
|
75
|
+
type=MessageType.REQUEST.value,
|
|
76
|
+
method=request_method,
|
|
77
|
+
params=params,
|
|
78
|
+
id=req_id,
|
|
79
|
+
).to_wire_dict()
|
|
80
|
+
|
|
81
|
+
return msg
|
|
82
|
+
|
|
21
83
|
|
|
22
84
|
@runtime_checkable
|
|
23
85
|
class ManagedClient(Protocol):
|
|
24
|
-
"""Subset of
|
|
86
|
+
"""Subset of WebSocketClient that ConnectionPool and TurnRunner depend on."""
|
|
25
87
|
|
|
26
88
|
async def connect(self) -> None:
|
|
27
89
|
"""Dial and handshake."""
|
|
@@ -72,7 +134,7 @@ class WebSocketManagedClient:
|
|
|
72
134
|
|
|
73
135
|
@property
|
|
74
136
|
def underlying(self) -> WebSocketClient:
|
|
75
|
-
"""Return the wrapped
|
|
137
|
+
"""Return the wrapped WebSocketClient."""
|
|
76
138
|
return self._client
|
|
77
139
|
|
|
78
140
|
async def connect(self) -> None:
|
|
@@ -89,7 +151,7 @@ class WebSocketManagedClient:
|
|
|
89
151
|
async def send_message(self, msg: Any) -> None:
|
|
90
152
|
if not isinstance(msg, dict):
|
|
91
153
|
raise TypeError("send_message expects a dict payload")
|
|
92
|
-
await self._client.send(msg)
|
|
154
|
+
await self._client.send(_coerce_appkit_wire_message(msg, self._client))
|
|
93
155
|
|
|
94
156
|
async def receive_messages(
|
|
95
157
|
self,
|
soothe_client/appkit/pool.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
"""Turn runner for appkit
|
|
1
|
+
"""Turn runner for appkit.
|
|
2
2
|
|
|
3
3
|
Executes one query turn end-to-end: acquire a pooled connection, enforce
|
|
4
4
|
single-flight, send loop_input, consume the event stream, classify events,
|
|
5
5
|
resolve the deliverable, persist the reply, and broadcast completion.
|
|
6
6
|
|
|
7
7
|
Supports absolute query timeout, optional idle silence watchdog, soft-complete
|
|
8
|
-
policies
|
|
8
|
+
policies, and optional attachment compaction before send.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
soothe_client/helpers.py
CHANGED
|
@@ -5,36 +5,35 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextlib
|
|
7
7
|
import logging
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
8
10
|
from typing import Any
|
|
9
11
|
|
|
12
|
+
from soothe_sdk.wire.codec import ProtocolError
|
|
13
|
+
|
|
10
14
|
from soothe_client.websocket import WebSocketClient
|
|
11
15
|
|
|
12
16
|
logger = logging.getLogger(__name__)
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
def websocket_url_from_config(cfg: Any) -> str:
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
Duck-typed across the three workspace configs:
|
|
20
|
+
"""Build a WebSocket URL from a config-like object.
|
|
19
21
|
|
|
20
|
-
|
|
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``.
|
|
22
|
+
Accepts any object that provides one of:
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
* ``websocket_url()`` callable
|
|
25
|
+
* ``daemon_host`` / ``daemon_port``
|
|
26
|
+
* ``transports.websocket.host`` / ``.port``
|
|
27
|
+
* ``daemon.transports.websocket.host`` / ``.port``
|
|
29
28
|
|
|
30
29
|
Args:
|
|
31
|
-
cfg:
|
|
30
|
+
cfg: Config-like object.
|
|
32
31
|
|
|
33
32
|
Returns:
|
|
34
|
-
WebSocket URL
|
|
33
|
+
WebSocket URL (e.g. ``"ws://127.0.0.1:8765"``).
|
|
35
34
|
|
|
36
35
|
Raises:
|
|
37
|
-
AttributeError: If
|
|
36
|
+
AttributeError: If none of the supported shapes are present.
|
|
38
37
|
"""
|
|
39
38
|
if hasattr(cfg, "websocket_url") and callable(cfg.websocket_url):
|
|
40
39
|
url = cfg.websocket_url()
|
|
@@ -62,7 +61,7 @@ def websocket_url_from_config(cfg: Any) -> str:
|
|
|
62
61
|
|
|
63
62
|
|
|
64
63
|
async def _ensure_handshake(client: WebSocketClient, *, timeout: float) -> None:
|
|
65
|
-
"""Complete the protocol-1 readiness handshake when not already done
|
|
64
|
+
"""Complete the protocol-1 readiness handshake when not already done.
|
|
66
65
|
|
|
67
66
|
Args:
|
|
68
67
|
client: Connected WebSocketClient.
|
|
@@ -79,6 +78,80 @@ async def _ensure_handshake(client: WebSocketClient, *, timeout: float) -> None:
|
|
|
79
78
|
await client.wait_for_connection_ack(ack_timeout_s=timeout)
|
|
80
79
|
|
|
81
80
|
|
|
81
|
+
@asynccontextmanager
|
|
82
|
+
async def connected_websocket(
|
|
83
|
+
ws_url: str,
|
|
84
|
+
*,
|
|
85
|
+
timeout: float = 30.0,
|
|
86
|
+
) -> AsyncIterator[WebSocketClient]:
|
|
87
|
+
"""Connect, handshake, and yield a ready ``WebSocketClient``.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
ws_url: Daemon WebSocket URL.
|
|
91
|
+
timeout: Handshake / overall connect budget in seconds.
|
|
92
|
+
|
|
93
|
+
Yields:
|
|
94
|
+
A connected client with protocol-1 handshake complete.
|
|
95
|
+
"""
|
|
96
|
+
client = WebSocketClient(url=ws_url)
|
|
97
|
+
try:
|
|
98
|
+
await client.connect()
|
|
99
|
+
await asyncio.wait_for(_ensure_handshake(client, timeout=timeout), timeout=timeout)
|
|
100
|
+
yield client
|
|
101
|
+
finally:
|
|
102
|
+
await client.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def protocol1_rpc(
|
|
106
|
+
ws_url: str,
|
|
107
|
+
method: str,
|
|
108
|
+
params: dict[str, Any] | None = None,
|
|
109
|
+
*,
|
|
110
|
+
mode: str = "request",
|
|
111
|
+
timeout: float = 30.0,
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""One-shot protocol-1 RPC / notify / subscribe with dict error contract.
|
|
114
|
+
|
|
115
|
+
Callers check ``if "error" in response``. Used by Typer commands that do not
|
|
116
|
+
hold a long-lived session.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
ws_url: WebSocket URL.
|
|
120
|
+
method: RPC method, notify target, or subscribe channel.
|
|
121
|
+
params: Structured parameters.
|
|
122
|
+
mode: ``request``, ``notify``, or ``subscribe``.
|
|
123
|
+
timeout: Seconds for handshake and the RPC itself.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Result dict, ``{}`` for notify, ``{"subscription_id": ...}`` for
|
|
127
|
+
subscribe, or ``{"error": "..."}`` on failure.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
async with connected_websocket(ws_url, timeout=timeout) as client:
|
|
131
|
+
if mode == "notify":
|
|
132
|
+
await asyncio.wait_for(client.notify(method, params or {}), timeout=timeout)
|
|
133
|
+
return {}
|
|
134
|
+
if mode == "subscribe":
|
|
135
|
+
sub_id = await asyncio.wait_for(
|
|
136
|
+
client.subscribe(method, params or {}, timeout=timeout),
|
|
137
|
+
timeout=timeout,
|
|
138
|
+
)
|
|
139
|
+
return {"subscription_id": sub_id}
|
|
140
|
+
result = await asyncio.wait_for(
|
|
141
|
+
client.request(method, params or {}, timeout=timeout),
|
|
142
|
+
timeout=timeout,
|
|
143
|
+
)
|
|
144
|
+
return result if isinstance(result, dict) else {"result": result}
|
|
145
|
+
except TimeoutError:
|
|
146
|
+
return {"error": "Timed out waiting for daemon response"}
|
|
147
|
+
except ProtocolError as exc:
|
|
148
|
+
return {"error": str(exc)}
|
|
149
|
+
except (ConnectionError, OSError) as exc:
|
|
150
|
+
return {"error": f"Connection error: {exc}"}
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
return {"error": str(exc)}
|
|
153
|
+
|
|
154
|
+
|
|
82
155
|
async def check_daemon_status(
|
|
83
156
|
client: WebSocketClient,
|
|
84
157
|
timeout: float = 5.0,
|
|
@@ -115,7 +188,7 @@ async def check_daemon_status(
|
|
|
115
188
|
def _daemon_status_indicates_live(status: dict) -> bool:
|
|
116
189
|
"""Infer liveness from a ``daemon_status`` response payload.
|
|
117
190
|
|
|
118
|
-
Uses ``readiness_state
|
|
191
|
+
Uses ``readiness_state``: transitional states (``starting``,
|
|
119
192
|
``warming``) mean the daemon is not yet ready for loops; terminal states
|
|
120
193
|
(``error``, ``degraded``, ``stopped``) mean it cannot serve loops; only
|
|
121
194
|
``ready`` is live for loop operations.
|
|
@@ -142,7 +215,7 @@ async def is_daemon_live(
|
|
|
142
215
|
) -> bool:
|
|
143
216
|
"""Composite health check: connection + status RPC.
|
|
144
217
|
|
|
145
|
-
Optionally waits for daemon to reach "ready" state
|
|
218
|
+
Optionally waits for daemon to reach "ready" state, polling during
|
|
146
219
|
transitional states like "starting" and "warming".
|
|
147
220
|
|
|
148
221
|
Args:
|
|
@@ -317,7 +390,7 @@ async def fetch_config_section(client: WebSocketClient, section: str, timeout: f
|
|
|
317
390
|
"""Fetch daemon config section via RPC.
|
|
318
391
|
|
|
319
392
|
Performs ``connection_init`` / ``connection_ack`` handshake when needed
|
|
320
|
-
before sending the request
|
|
393
|
+
before sending the request.
|
|
321
394
|
|
|
322
395
|
Args:
|
|
323
396
|
client: Connected WebSocketClient
|
|
@@ -392,6 +465,8 @@ async def fetch_loop_messages(
|
|
|
392
465
|
|
|
393
466
|
__all__ = [
|
|
394
467
|
"websocket_url_from_config",
|
|
468
|
+
"connected_websocket",
|
|
469
|
+
"protocol1_rpc",
|
|
395
470
|
"check_daemon_status",
|
|
396
471
|
"is_daemon_live",
|
|
397
472
|
"request_daemon_shutdown",
|