soothe-client-python 0.9.4__py3-none-any.whl → 0.9.7__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 +17 -13
- soothe_client/appkit/daemon_session.py +47 -15
- soothe_client/appkit/managed_client.py +75 -7
- 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 +24 -21
- soothe_client/helpers.py +104 -27
- soothe_client/protocol_params.py +131 -67
- soothe_client/py.typed +0 -0
- soothe_client/schemas.py +3 -9
- soothe_client/session.py +6 -7
- soothe_client/stream_terminal.py +73 -0
- soothe_client/websocket.py +65 -94
- soothe_client/ws_command_client.py +80 -60
- soothe_client_python-0.9.7.dist-info/METADATA +85 -0
- soothe_client_python-0.9.7.dist-info/RECORD +30 -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.7.dist-info}/WHEEL +0 -0
- {soothe_client_python-0.9.4.dist-info → soothe_client_python-0.9.7.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,4 +1,4 @@
|
|
|
1
|
-
"""Event classifier for appkit
|
|
1
|
+
"""Event classifier for appkit.
|
|
2
2
|
|
|
3
3
|
Maps a stream of decoded daemon events into deliverable/streaming/terminal
|
|
4
4
|
outcomes, keyed on (namespace, mode, phase). Product apps pass their own
|
|
@@ -155,13 +155,12 @@ class EventClassifier:
|
|
|
155
155
|
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
156
156
|
|
|
157
157
|
if typ == "error":
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)
|
|
158
|
+
raw_err = msg.get("error")
|
|
159
|
+
err_obj: dict[str, Any] = raw_err if isinstance(raw_err, dict) else {}
|
|
160
|
+
raw_code = err_obj.get("code")
|
|
161
|
+
code = raw_code if isinstance(raw_code, int) else -32603
|
|
162
|
+
raw_message = err_obj.get("message")
|
|
163
|
+
message = raw_message if isinstance(raw_message, str) else "daemon error"
|
|
165
164
|
return self._failed_result(DaemonError(code, message, err_obj.get("data")))
|
|
166
165
|
|
|
167
166
|
if typ == "event":
|
|
@@ -174,8 +173,10 @@ class EventClassifier:
|
|
|
174
173
|
return ChatEventResult(terminal=ChatEventTerminal.CONTINUE)
|
|
175
174
|
|
|
176
175
|
def _classify_next_envelope(self, env: dict[str, Any]) -> ChatEventResult:
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
raw_payload = env.get("payload")
|
|
177
|
+
payload: dict[str, Any] = raw_payload if isinstance(raw_payload, dict) else {}
|
|
178
|
+
raw_inner = payload.get("data")
|
|
179
|
+
inner_data: dict[str, Any] | None = raw_inner if isinstance(raw_inner, dict) else None
|
|
179
180
|
if inner_data is not None:
|
|
180
181
|
inner_mode = str(inner_data.get("mode") or "")
|
|
181
182
|
if inner_mode:
|
|
@@ -372,8 +373,10 @@ def _first_message_payload(data: Any) -> tuple[str, str, str, bool]:
|
|
|
372
373
|
msg_map = data[0]
|
|
373
374
|
if not isinstance(msg_map, dict):
|
|
374
375
|
return "", "", "", False
|
|
375
|
-
|
|
376
|
-
|
|
376
|
+
type_raw = msg_map.get("type")
|
|
377
|
+
msg_type = type_raw if isinstance(type_raw, str) else ""
|
|
378
|
+
phase_raw = msg_map.get("phase")
|
|
379
|
+
phase = phase_raw if isinstance(phase_raw, str) else ""
|
|
377
380
|
content = _extract_content_from_message(msg_map)
|
|
378
381
|
return msg_type, content, phase, True
|
|
379
382
|
|
|
@@ -388,7 +391,8 @@ def _loop_ai_message(data: Any) -> dict[str, str] | None:
|
|
|
388
391
|
phase = phase_raw.strip() if isinstance(phase_raw, str) else ""
|
|
389
392
|
if not phase:
|
|
390
393
|
return None
|
|
391
|
-
|
|
394
|
+
type_raw = msg_map.get("type")
|
|
395
|
+
msg_type = type_raw if isinstance(type_raw, str) else ""
|
|
392
396
|
content = _extract_content_from_message(msg_map)
|
|
393
397
|
return {"type": msg_type, "content": content, "phase": phase}
|
|
394
398
|
|
|
@@ -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
|
|
@@ -19,13 +18,13 @@ from soothe_client.appkit.chunk_filter import should_drop_stream_chunk_early
|
|
|
19
18
|
from soothe_client.appkit.events import unwrap_next
|
|
20
19
|
from soothe_client.appkit.observability import TurnEventStats
|
|
21
20
|
from soothe_client.session import bootstrap_loop_session, connect_websocket_with_retries
|
|
21
|
+
from soothe_client.stream_terminal import STREAM_END, is_turn_end_custom_data
|
|
22
22
|
from soothe_client.websocket import WebSocketClient
|
|
23
23
|
|
|
24
24
|
logger = logging.getLogger(__name__)
|
|
25
25
|
|
|
26
26
|
DEFAULT_POST_IDLE_DRAIN_S = 0.5
|
|
27
27
|
_RPC_HANDSHAKE_TIMEOUT_S = 20.0
|
|
28
|
-
_TURN_END_CUSTOM_TYPES = frozenset({"soothe.cognition.strange_loop.completed"})
|
|
29
28
|
|
|
30
29
|
EarlyDropFn = Callable[[tuple[Any, ...], str, Any], bool]
|
|
31
30
|
StatsFactory = Callable[[], Any]
|
|
@@ -210,6 +209,7 @@ class DaemonSession:
|
|
|
210
209
|
clarification_mode: str | None = None,
|
|
211
210
|
clarification_answer: bool = False,
|
|
212
211
|
clarification_answers: list[str] | None = None,
|
|
212
|
+
intent_hint: str | None = None,
|
|
213
213
|
) -> None:
|
|
214
214
|
"""Send a new user turn to the daemon."""
|
|
215
215
|
if not self._loop_id:
|
|
@@ -227,6 +227,7 @@ class DaemonSession:
|
|
|
227
227
|
clarification_mode=clarification_mode,
|
|
228
228
|
clarification_answer=clarification_answer,
|
|
229
229
|
clarification_answers=clarification_answers,
|
|
230
|
+
intent_hint=intent_hint,
|
|
230
231
|
)
|
|
231
232
|
|
|
232
233
|
async def cancel_remote_query(self) -> None:
|
|
@@ -289,8 +290,19 @@ class DaemonSession:
|
|
|
289
290
|
await self._ensure_rpc_connected()
|
|
290
291
|
return await self._rpc_client.request("loop_list", {"limit": limit}, timeout=15.0)
|
|
291
292
|
|
|
292
|
-
async def iter_turn_chunks(
|
|
293
|
-
|
|
293
|
+
async def iter_turn_chunks(
|
|
294
|
+
self,
|
|
295
|
+
*,
|
|
296
|
+
max_wait_s: float | None = None,
|
|
297
|
+
) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
|
|
298
|
+
"""Yield ``(namespace, mode, data)`` chunks for the active daemon turn.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
max_wait_s: Optional absolute deadline for the whole turn. When set,
|
|
302
|
+
raises ``TimeoutError`` if the daemon never emits a turn-end
|
|
303
|
+
signal in time (idle after payload, stopped, stream.end, or
|
|
304
|
+
strange_loop.completed).
|
|
305
|
+
"""
|
|
294
306
|
self.turn_event_stats = self._new_turn_stats()
|
|
295
307
|
self.last_turn_end_state = None
|
|
296
308
|
self.last_turn_cancellation_seen = False
|
|
@@ -303,6 +315,9 @@ class DaemonSession:
|
|
|
303
315
|
turn_read_started = time.monotonic()
|
|
304
316
|
first_event_logged = False
|
|
305
317
|
progress_seen = False
|
|
318
|
+
absolute_deadline = (
|
|
319
|
+
time.monotonic() + max_wait_s if max_wait_s is not None and max_wait_s > 0 else None
|
|
320
|
+
)
|
|
306
321
|
peel = getattr(self._client, "peel_stale_pending_control_events", None)
|
|
307
322
|
stale_pending = peel() if callable(peel) else []
|
|
308
323
|
if stale_pending:
|
|
@@ -315,6 +330,11 @@ class DaemonSession:
|
|
|
315
330
|
async with self._read_lock:
|
|
316
331
|
try:
|
|
317
332
|
while True:
|
|
333
|
+
if absolute_deadline is not None and time.monotonic() >= absolute_deadline:
|
|
334
|
+
raise TimeoutError(
|
|
335
|
+
f"Turn timed out after {max_wait_s:.0f}s "
|
|
336
|
+
f"(loop={expected_loop_id or '?'})"
|
|
337
|
+
)
|
|
318
338
|
if not progress_seen and time.monotonic() - turn_read_started > 30.0:
|
|
319
339
|
logger.warning(
|
|
320
340
|
"No daemon stream progress after %.0fs (loop=%s, "
|
|
@@ -401,18 +421,30 @@ class DaemonSession:
|
|
|
401
421
|
if self._should_drop(namespace, mode, data):
|
|
402
422
|
self.turn_event_stats.filtered_early += 1
|
|
403
423
|
continue
|
|
424
|
+
|
|
425
|
+
# Prior-goal terminal frames can arrive before status=running
|
|
426
|
+
# for the new turn; ignoring them prevents a blank second query.
|
|
427
|
+
if mode == "custom" and is_turn_end_custom_data(data) and not query_started:
|
|
428
|
+
logger.debug(
|
|
429
|
+
"Ignoring pre-start turn-end frame %s (loop=%s)",
|
|
430
|
+
str(data.get("type", "")).strip(),
|
|
431
|
+
(expected_loop_id or "?")[:16],
|
|
432
|
+
)
|
|
433
|
+
continue
|
|
434
|
+
|
|
404
435
|
progress_seen = True
|
|
405
436
|
stream_payload_seen = True
|
|
406
437
|
yield (namespace, mode, data)
|
|
407
|
-
if mode == "custom" and
|
|
438
|
+
if mode == "custom" and is_turn_end_custom_data(data):
|
|
408
439
|
custom_type = str(data.get("type", "")).strip()
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
440
|
+
self.last_turn_end_state = (
|
|
441
|
+
"stream_end" if custom_type == STREAM_END else "completed"
|
|
442
|
+
)
|
|
443
|
+
async for chunk in self._drain_stream_events_after_idle(
|
|
444
|
+
expected_loop_id=expected_loop_id,
|
|
445
|
+
):
|
|
446
|
+
yield chunk
|
|
447
|
+
break
|
|
416
448
|
if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
|
|
417
449
|
continue
|
|
418
450
|
except Exception as exc:
|
|
@@ -1,13 +1,13 @@
|
|
|
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
|
|
8
8
|
|
|
9
9
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
10
|
-
from typing import Any, Protocol, runtime_checkable
|
|
10
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
11
11
|
|
|
12
12
|
from soothe_client.errors import DisconnectCause
|
|
13
13
|
from soothe_client.websocket import WebSocketClient
|
|
@@ -18,10 +18,78 @@ 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 cast(
|
|
64
|
+
dict[str, Any],
|
|
65
|
+
WireEnvelope(
|
|
66
|
+
proto="1",
|
|
67
|
+
type=MessageType.NOTIFICATION.value,
|
|
68
|
+
method=msg_type,
|
|
69
|
+
params=params,
|
|
70
|
+
).to_wire_dict(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if msg_type in _REQUEST_METHOD_ALIASES or msg_type:
|
|
74
|
+
request_method = _REQUEST_METHOD_ALIASES.get(msg_type, msg_type)
|
|
75
|
+
req_id = str(msg.get("request_id") or msg.get("id") or client._next_request_id())
|
|
76
|
+
return cast(
|
|
77
|
+
dict[str, Any],
|
|
78
|
+
WireEnvelope(
|
|
79
|
+
proto="1",
|
|
80
|
+
type=MessageType.REQUEST.value,
|
|
81
|
+
method=request_method,
|
|
82
|
+
params=params,
|
|
83
|
+
id=req_id,
|
|
84
|
+
).to_wire_dict(),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return msg
|
|
88
|
+
|
|
21
89
|
|
|
22
90
|
@runtime_checkable
|
|
23
91
|
class ManagedClient(Protocol):
|
|
24
|
-
"""Subset of
|
|
92
|
+
"""Subset of WebSocketClient that ConnectionPool and TurnRunner depend on."""
|
|
25
93
|
|
|
26
94
|
async def connect(self) -> None:
|
|
27
95
|
"""Dial and handshake."""
|
|
@@ -72,7 +140,7 @@ class WebSocketManagedClient:
|
|
|
72
140
|
|
|
73
141
|
@property
|
|
74
142
|
def underlying(self) -> WebSocketClient:
|
|
75
|
-
"""Return the wrapped
|
|
143
|
+
"""Return the wrapped WebSocketClient."""
|
|
76
144
|
return self._client
|
|
77
145
|
|
|
78
146
|
async def connect(self) -> None:
|
|
@@ -89,7 +157,7 @@ class WebSocketManagedClient:
|
|
|
89
157
|
async def send_message(self, msg: Any) -> None:
|
|
90
158
|
if not isinstance(msg, dict):
|
|
91
159
|
raise TypeError("send_message expects a dict payload")
|
|
92
|
-
await self._client.send(msg)
|
|
160
|
+
await self._client.send(_coerce_appkit_wire_message(msg, self._client))
|
|
93
161
|
|
|
94
162
|
async def receive_messages(
|
|
95
163
|
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
|
|
@@ -241,23 +241,23 @@ class TurnRunner:
|
|
|
241
241
|
input_msg = self._build_input(message, loop_id, atts, opts)
|
|
242
242
|
try:
|
|
243
243
|
await conn.client.send_message(input_msg)
|
|
244
|
-
except Exception as
|
|
245
|
-
await self._persist_failed(session_id, loop_id,
|
|
246
|
-
self._broadcast_error(session_id,
|
|
244
|
+
except Exception as send_err:
|
|
245
|
+
await self._persist_failed(session_id, loop_id, send_err)
|
|
246
|
+
self._broadcast_error(session_id, send_err)
|
|
247
247
|
if self._on_error is not None:
|
|
248
|
-
self._on_error(session_id, loop_id,
|
|
248
|
+
self._on_error(session_id, loop_id, send_err)
|
|
249
249
|
raise
|
|
250
250
|
|
|
251
251
|
event_stream = conn.event_stream
|
|
252
252
|
if event_stream is None:
|
|
253
|
-
|
|
253
|
+
missing = RuntimeError(
|
|
254
254
|
f"missing event stream for session {session_id} (loop {loop_id})"
|
|
255
255
|
)
|
|
256
|
-
await self._persist_failed(session_id, loop_id,
|
|
257
|
-
self._broadcast_error(session_id,
|
|
256
|
+
await self._persist_failed(session_id, loop_id, missing)
|
|
257
|
+
self._broadcast_error(session_id, missing)
|
|
258
258
|
if self._on_error is not None:
|
|
259
|
-
self._on_error(session_id, loop_id,
|
|
260
|
-
raise
|
|
259
|
+
self._on_error(session_id, loop_id, missing)
|
|
260
|
+
raise missing
|
|
261
261
|
|
|
262
262
|
assistant_content = ""
|
|
263
263
|
started_at = time.time()
|
|
@@ -266,9 +266,9 @@ class TurnRunner:
|
|
|
266
266
|
|
|
267
267
|
while True:
|
|
268
268
|
if cancel_event is not None and cancel_event.is_set():
|
|
269
|
-
|
|
270
|
-
await self._fail_turn(session_id, loop_id,
|
|
271
|
-
raise
|
|
269
|
+
aborted = RuntimeError("aborted")
|
|
270
|
+
await self._fail_turn(session_id, loop_id, aborted)
|
|
271
|
+
raise aborted
|
|
272
272
|
|
|
273
273
|
if query_timed_out.is_set():
|
|
274
274
|
with contextlib.suppress(Exception):
|
|
@@ -298,7 +298,10 @@ class TurnRunner:
|
|
|
298
298
|
)
|
|
299
299
|
return
|
|
300
300
|
|
|
301
|
-
|
|
301
|
+
async def _pull_next() -> Any:
|
|
302
|
+
return await agen.__anext__()
|
|
303
|
+
|
|
304
|
+
next_task: asyncio.Task[Any] = asyncio.create_task(_pull_next())
|
|
302
305
|
waiters: list[asyncio.Task[Any]] = [
|
|
303
306
|
next_task,
|
|
304
307
|
asyncio.create_task(query_timed_out.wait()),
|
|
@@ -350,9 +353,9 @@ class TurnRunner:
|
|
|
350
353
|
return
|
|
351
354
|
|
|
352
355
|
if caller_wait is not None and caller_wait in done:
|
|
353
|
-
|
|
354
|
-
await self._fail_turn(session_id, loop_id,
|
|
355
|
-
raise
|
|
356
|
+
aborted = RuntimeError("aborted")
|
|
357
|
+
await self._fail_turn(session_id, loop_id, aborted)
|
|
358
|
+
raise aborted
|
|
356
359
|
|
|
357
360
|
try:
|
|
358
361
|
msg = next_task.result()
|
|
@@ -369,9 +372,9 @@ class TurnRunner:
|
|
|
369
372
|
"stream_closed",
|
|
370
373
|
)
|
|
371
374
|
return
|
|
372
|
-
|
|
373
|
-
await self._fail_turn(session_id, loop_id,
|
|
374
|
-
raise
|
|
375
|
+
closed = RuntimeError("event stream closed")
|
|
376
|
+
await self._fail_turn(session_id, loop_id, closed)
|
|
377
|
+
raise closed from None
|
|
375
378
|
|
|
376
379
|
arm_idle()
|
|
377
380
|
|