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,657 @@
|
|
|
1
|
+
"""Dual-socket daemon loop session with turn streaming (CLI-grade mechanics).
|
|
2
|
+
|
|
3
|
+
Owns a subscribed stream WebSocket plus an RPC sidecar so metadata calls do not
|
|
4
|
+
starve ``loop_events``. ``iter_turn_chunks`` implements stale-idle guarding,
|
|
5
|
+
post-idle drain, loop scoping, and connection-loss detection — behavior that
|
|
6
|
+
originated in soothe-cli and is not yet mirrored in Go/TS TurnRunner.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import AsyncIterator, Callable
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from soothe_client.appkit.chunk_filter import should_drop_stream_chunk_early
|
|
19
|
+
from soothe_client.appkit.events import unwrap_next
|
|
20
|
+
from soothe_client.appkit.observability import TurnEventStats
|
|
21
|
+
from soothe_client.session import bootstrap_loop_session, connect_websocket_with_retries
|
|
22
|
+
from soothe_client.websocket import WebSocketClient
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
DEFAULT_POST_IDLE_DRAIN_S = 0.5
|
|
27
|
+
_RPC_HANDSHAKE_TIMEOUT_S = 20.0
|
|
28
|
+
_TURN_END_CUSTOM_TYPES = frozenset({"soothe.cognition.strange_loop.completed"})
|
|
29
|
+
|
|
30
|
+
EarlyDropFn = Callable[[tuple[Any, ...], str, Any], bool]
|
|
31
|
+
StatsFactory = Callable[[], Any]
|
|
32
|
+
StreamDeliveryResolver = Callable[[], str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DaemonSession:
|
|
36
|
+
"""Daemon-backed loop session with stream + RPC sockets."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
ws_url: str,
|
|
41
|
+
*,
|
|
42
|
+
workspace: str | None = None,
|
|
43
|
+
stream_delivery: str | StreamDeliveryResolver = "adaptive",
|
|
44
|
+
post_idle_drain_deadline: float = DEFAULT_POST_IDLE_DRAIN_S,
|
|
45
|
+
early_drop_fn: EarlyDropFn | None = None,
|
|
46
|
+
stats_factory: StatsFactory | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._ws_url = ws_url
|
|
49
|
+
self._workspace = workspace
|
|
50
|
+
self._stream_delivery = stream_delivery
|
|
51
|
+
self._client = WebSocketClient(url=ws_url)
|
|
52
|
+
self._rpc_client = WebSocketClient(url=ws_url)
|
|
53
|
+
self._loop_id: str | None = None
|
|
54
|
+
self._read_lock = asyncio.Lock()
|
|
55
|
+
self._rpc_lock = asyncio.Lock()
|
|
56
|
+
self._rpc_connected = False
|
|
57
|
+
self._streaming = False
|
|
58
|
+
self._post_idle_drain_deadline = post_idle_drain_deadline
|
|
59
|
+
self._closed = False
|
|
60
|
+
self._early_drop_fn = early_drop_fn or should_drop_stream_chunk_early
|
|
61
|
+
self._stats_factory = stats_factory or TurnEventStats
|
|
62
|
+
self.turn_event_stats = self._stats_factory()
|
|
63
|
+
self.last_turn_end_state: str | None = None
|
|
64
|
+
self.last_turn_cancellation_seen: bool = False
|
|
65
|
+
self.last_turn_error_message: str | None = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def client(self) -> WebSocketClient:
|
|
69
|
+
"""Subscribed stream WebSocket (loop events)."""
|
|
70
|
+
return self._client
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def rpc_client(self) -> WebSocketClient:
|
|
74
|
+
"""RPC sidecar WebSocket (metadata calls)."""
|
|
75
|
+
return self._rpc_client
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def loop_id(self) -> str | None:
|
|
79
|
+
"""Active StrangeLoop id for this WebSocket session."""
|
|
80
|
+
return self._loop_id
|
|
81
|
+
|
|
82
|
+
def _resolve_stream_delivery_mode(self) -> str:
|
|
83
|
+
delivery = getattr(self, "_stream_delivery", "adaptive")
|
|
84
|
+
if callable(delivery):
|
|
85
|
+
return str(delivery() or "adaptive")
|
|
86
|
+
return str(delivery or "adaptive")
|
|
87
|
+
|
|
88
|
+
def _should_drop(self, namespace: tuple[Any, ...], mode: str, data: Any) -> bool:
|
|
89
|
+
drop_fn = getattr(self, "_early_drop_fn", None) or should_drop_stream_chunk_early
|
|
90
|
+
return bool(drop_fn(namespace, mode, data))
|
|
91
|
+
|
|
92
|
+
def _new_turn_stats(self) -> Any:
|
|
93
|
+
factory = getattr(self, "_stats_factory", None) or TurnEventStats
|
|
94
|
+
return factory()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def _drain_deadline(self) -> float:
|
|
98
|
+
return float(
|
|
99
|
+
getattr(self, "_post_idle_drain_deadline", DEFAULT_POST_IDLE_DRAIN_S)
|
|
100
|
+
or DEFAULT_POST_IDLE_DRAIN_S
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def connect(self, *, resume_loop_id: str | None = None) -> dict[str, Any]:
|
|
104
|
+
"""Connect and bootstrap a daemon loop session."""
|
|
105
|
+
await connect_websocket_with_retries(self._client)
|
|
106
|
+
return await self._bootstrap_loop(resume_loop_id=resume_loop_id)
|
|
107
|
+
|
|
108
|
+
async def _bootstrap_loop(self, *, resume_loop_id: str | None = None) -> dict[str, Any]:
|
|
109
|
+
status_event = await bootstrap_loop_session(
|
|
110
|
+
self._client,
|
|
111
|
+
resume_loop_id=resume_loop_id,
|
|
112
|
+
stream_delivery=self._resolve_stream_delivery_mode(),
|
|
113
|
+
workspace=getattr(self, "_workspace", None),
|
|
114
|
+
)
|
|
115
|
+
if status_event.get("type") == "error":
|
|
116
|
+
raise RuntimeError(str(status_event.get("message", "daemon bootstrap failed")))
|
|
117
|
+
self._loop_id = status_event.get("loop_id")
|
|
118
|
+
return status_event
|
|
119
|
+
|
|
120
|
+
async def new_loop(self) -> dict[str, Any]:
|
|
121
|
+
"""Start a new StrangeLoop conversation."""
|
|
122
|
+
return await self._bootstrap_loop(resume_loop_id=None)
|
|
123
|
+
|
|
124
|
+
async def switch_loop(self, loop_id: str) -> dict[str, Any]:
|
|
125
|
+
"""Subscribe to an existing loop (re-bootstrap on the same connection)."""
|
|
126
|
+
return await self._bootstrap_loop(resume_loop_id=loop_id)
|
|
127
|
+
|
|
128
|
+
async def ensure_connected(self) -> None:
|
|
129
|
+
"""Reconnect and re-subscribe when the stream WebSocket died.
|
|
130
|
+
|
|
131
|
+
Prefers ``reconnect`` + ``reattach_and_probe`` when a loop id is known;
|
|
132
|
+
falls back to bootstrap on ``StaleLoopError``.
|
|
133
|
+
"""
|
|
134
|
+
from soothe_client.errors import StaleLoopError
|
|
135
|
+
|
|
136
|
+
is_disconn = getattr(self._client, "is_disconnected", None)
|
|
137
|
+
disconnected = bool(is_disconn()) if callable(is_disconn) else False
|
|
138
|
+
if self._client.is_connection_alive() and not disconnected:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
resume_loop_id = self._loop_id
|
|
142
|
+
logger.info(
|
|
143
|
+
"Daemon WebSocket closed; reconnecting%s",
|
|
144
|
+
f" to loop {resume_loop_id[:8]}..." if resume_loop_id else "",
|
|
145
|
+
)
|
|
146
|
+
if self._rpc_connected:
|
|
147
|
+
await self._rpc_client.close()
|
|
148
|
+
self._rpc_connected = False
|
|
149
|
+
|
|
150
|
+
reconnect = getattr(self._client, "reconnect", None)
|
|
151
|
+
if callable(reconnect):
|
|
152
|
+
await reconnect()
|
|
153
|
+
else:
|
|
154
|
+
await self._client.close()
|
|
155
|
+
await connect_websocket_with_retries(self._client)
|
|
156
|
+
|
|
157
|
+
if resume_loop_id:
|
|
158
|
+
reattach = getattr(self._client, "reattach_and_probe", None)
|
|
159
|
+
if callable(reattach):
|
|
160
|
+
try:
|
|
161
|
+
await reattach(
|
|
162
|
+
resume_loop_id,
|
|
163
|
+
stream_delivery=self._resolve_stream_delivery_mode(),
|
|
164
|
+
)
|
|
165
|
+
self._loop_id = resume_loop_id
|
|
166
|
+
return
|
|
167
|
+
except StaleLoopError:
|
|
168
|
+
logger.warning(
|
|
169
|
+
"Loop %s stale after reattach; bootstrapping fresh session",
|
|
170
|
+
resume_loop_id[:16],
|
|
171
|
+
exc_info=True,
|
|
172
|
+
)
|
|
173
|
+
resume_loop_id = None
|
|
174
|
+
|
|
175
|
+
await self._bootstrap_loop(resume_loop_id=resume_loop_id)
|
|
176
|
+
|
|
177
|
+
async def close(self, *, handshake_timeout: float = 2.0) -> None:
|
|
178
|
+
"""Close stream and RPC sockets (idempotent)."""
|
|
179
|
+
if self._closed:
|
|
180
|
+
return
|
|
181
|
+
self._closed = True
|
|
182
|
+
await asyncio.gather(
|
|
183
|
+
self._client.close(handshake_timeout=handshake_timeout),
|
|
184
|
+
self._rpc_client.close(handshake_timeout=handshake_timeout),
|
|
185
|
+
return_exceptions=True,
|
|
186
|
+
)
|
|
187
|
+
self._rpc_connected = False
|
|
188
|
+
|
|
189
|
+
async def detach(self) -> None:
|
|
190
|
+
"""Notify the daemon that this client is leaving (``disconnect``)."""
|
|
191
|
+
if not self._client.is_connected:
|
|
192
|
+
logger.debug("Skipping detach — connection already closed")
|
|
193
|
+
return
|
|
194
|
+
try:
|
|
195
|
+
await self._client.notify("disconnect", {})
|
|
196
|
+
except ConnectionError:
|
|
197
|
+
logger.debug("Daemon connection closed before detach")
|
|
198
|
+
|
|
199
|
+
async def send_turn(
|
|
200
|
+
self,
|
|
201
|
+
text: str,
|
|
202
|
+
*,
|
|
203
|
+
autonomous: bool = False,
|
|
204
|
+
max_iterations: int | None = None,
|
|
205
|
+
preferred_subagent: str | None = None,
|
|
206
|
+
model: str | None = None,
|
|
207
|
+
model_params: dict[str, Any] | None = None,
|
|
208
|
+
router_profile: str | None = None,
|
|
209
|
+
attachments: list[dict[str, str]] | None = None,
|
|
210
|
+
clarification_mode: str | None = None,
|
|
211
|
+
clarification_answer: bool = False,
|
|
212
|
+
clarification_answers: list[str] | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Send a new user turn to the daemon."""
|
|
215
|
+
if not self._loop_id:
|
|
216
|
+
raise RuntimeError("No active loop session")
|
|
217
|
+
await self._client.send_input(
|
|
218
|
+
self._loop_id,
|
|
219
|
+
text,
|
|
220
|
+
autonomous=autonomous,
|
|
221
|
+
max_iterations=max_iterations,
|
|
222
|
+
preferred_subagent=preferred_subagent,
|
|
223
|
+
model=model,
|
|
224
|
+
model_params=model_params,
|
|
225
|
+
router_profile=router_profile,
|
|
226
|
+
attachments=attachments,
|
|
227
|
+
clarification_mode=clarification_mode,
|
|
228
|
+
clarification_answer=clarification_answer,
|
|
229
|
+
clarification_answers=clarification_answers,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async def cancel_remote_query(self) -> None:
|
|
233
|
+
"""Ask the daemon to cancel via slash ``/cancel`` (CLI wire path)."""
|
|
234
|
+
await self._client.notify("slash_command", {"cmd": "/cancel"})
|
|
235
|
+
|
|
236
|
+
async def cancel_active_turn(self) -> None:
|
|
237
|
+
"""Cancel the in-flight query on the active loop."""
|
|
238
|
+
await self.cancel_remote_query()
|
|
239
|
+
|
|
240
|
+
async def _drain_stream_events_after_idle(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
expected_loop_id: str | None,
|
|
244
|
+
) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
|
|
245
|
+
"""Yield stream chunks that arrive just after ``idle``."""
|
|
246
|
+
loop = asyncio.get_running_loop()
|
|
247
|
+
deadline = loop.time() + self._drain_deadline
|
|
248
|
+
exp = expected_loop_id
|
|
249
|
+
while loop.time() < deadline:
|
|
250
|
+
try:
|
|
251
|
+
event = await asyncio.wait_for(self._client.read_event(), timeout=0.25)
|
|
252
|
+
except TimeoutError:
|
|
253
|
+
break
|
|
254
|
+
if not event:
|
|
255
|
+
break
|
|
256
|
+
event_type = event.get("type", "")
|
|
257
|
+
if event_type == "next":
|
|
258
|
+
event = unwrap_next(event) or event
|
|
259
|
+
event_type = event.get("type", "")
|
|
260
|
+
event_loop_id = event.get("loop_id")
|
|
261
|
+
if exp and isinstance(event_loop_id, str) and event_loop_id and event_loop_id != exp:
|
|
262
|
+
continue
|
|
263
|
+
if event_type == "error":
|
|
264
|
+
err_obj = event.get("error") or {}
|
|
265
|
+
err_msg = str(err_obj.get("message") or event.get("message") or "daemon error")
|
|
266
|
+
raise RuntimeError(err_msg)
|
|
267
|
+
if event_type == "status":
|
|
268
|
+
loop_ev = event.get("loop_id")
|
|
269
|
+
if isinstance(loop_ev, str) and loop_ev:
|
|
270
|
+
self._loop_id = loop_ev
|
|
271
|
+
exp = loop_ev
|
|
272
|
+
continue
|
|
273
|
+
if event_type != "event":
|
|
274
|
+
continue
|
|
275
|
+
data = event.get("data")
|
|
276
|
+
namespace = tuple(event.get("namespace", []) or [])
|
|
277
|
+
mode = str(event.get("mode", ""))
|
|
278
|
+
if self._should_drop(namespace, mode, data):
|
|
279
|
+
self.turn_event_stats.filtered_early += 1
|
|
280
|
+
continue
|
|
281
|
+
self.turn_event_stats.post_idle_drained += 1
|
|
282
|
+
yield (namespace, mode, data)
|
|
283
|
+
if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
async def list_loops(self, *, limit: int = 20) -> dict[str, Any]:
|
|
287
|
+
"""Return ``loop_list`` via the RPC sidecar."""
|
|
288
|
+
async with self._rpc_lock:
|
|
289
|
+
await self._ensure_rpc_connected()
|
|
290
|
+
return await self._rpc_client.request("loop_list", {"limit": limit}, timeout=15.0)
|
|
291
|
+
|
|
292
|
+
async def iter_turn_chunks(self) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
|
|
293
|
+
"""Yield ``(namespace, mode, data)`` chunks for the active daemon turn."""
|
|
294
|
+
self.turn_event_stats = self._new_turn_stats()
|
|
295
|
+
self.last_turn_end_state = None
|
|
296
|
+
self.last_turn_cancellation_seen = False
|
|
297
|
+
self.last_turn_error_message = None
|
|
298
|
+
inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
|
|
299
|
+
query_started = False
|
|
300
|
+
expected_loop_id = self._loop_id
|
|
301
|
+
stream_payload_seen = False
|
|
302
|
+
self._streaming = True
|
|
303
|
+
turn_read_started = time.monotonic()
|
|
304
|
+
first_event_logged = False
|
|
305
|
+
progress_seen = False
|
|
306
|
+
peel = getattr(self._client, "peel_stale_pending_control_events", None)
|
|
307
|
+
stale_pending = peel() if callable(peel) else []
|
|
308
|
+
if stale_pending:
|
|
309
|
+
logger.debug(
|
|
310
|
+
"Peeled %d stale pending control frame(s) before turn (loop=%s): %s",
|
|
311
|
+
len(stale_pending),
|
|
312
|
+
(expected_loop_id or "?")[:16],
|
|
313
|
+
", ".join(stale_pending[:8]),
|
|
314
|
+
)
|
|
315
|
+
async with self._read_lock:
|
|
316
|
+
try:
|
|
317
|
+
while True:
|
|
318
|
+
if not progress_seen and time.monotonic() - turn_read_started > 30.0:
|
|
319
|
+
logger.warning(
|
|
320
|
+
"No daemon stream progress after %.0fs (loop=%s, "
|
|
321
|
+
"query_started=%s); check daemon sender / WebSocket reader",
|
|
322
|
+
time.monotonic() - turn_read_started,
|
|
323
|
+
(expected_loop_id or "?")[:16],
|
|
324
|
+
query_started,
|
|
325
|
+
)
|
|
326
|
+
turn_read_started = time.monotonic()
|
|
327
|
+
event = await self._client.read_event()
|
|
328
|
+
if event and not first_event_logged:
|
|
329
|
+
first_event_logged = True
|
|
330
|
+
logger.debug(
|
|
331
|
+
"First daemon event on turn: type=%s loop_id=%s",
|
|
332
|
+
event.get("type"),
|
|
333
|
+
event.get("loop_id"),
|
|
334
|
+
)
|
|
335
|
+
if not event:
|
|
336
|
+
if query_started and not self._client.is_connection_alive():
|
|
337
|
+
self.last_turn_end_state = "connection_lost"
|
|
338
|
+
raise ConnectionError("Daemon connection lost")
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
event_type = event.get("type", "")
|
|
342
|
+
if event_type == "next":
|
|
343
|
+
event = unwrap_next(event) or event
|
|
344
|
+
event_type = event.get("type", "")
|
|
345
|
+
|
|
346
|
+
event_loop_id = event.get("loop_id")
|
|
347
|
+
if (
|
|
348
|
+
expected_loop_id
|
|
349
|
+
and isinstance(event_loop_id, str)
|
|
350
|
+
and event_loop_id
|
|
351
|
+
and event_loop_id != expected_loop_id
|
|
352
|
+
):
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
if event_type == "error":
|
|
356
|
+
err_obj = event.get("error") or {}
|
|
357
|
+
err_msg = str(
|
|
358
|
+
err_obj.get("message") or event.get("message") or "daemon error"
|
|
359
|
+
)
|
|
360
|
+
raise RuntimeError(err_msg)
|
|
361
|
+
|
|
362
|
+
if event_type == "status":
|
|
363
|
+
loop_ev = event.get("loop_id")
|
|
364
|
+
if isinstance(loop_ev, str) and loop_ev:
|
|
365
|
+
self._loop_id = loop_ev
|
|
366
|
+
expected_loop_id = loop_ev
|
|
367
|
+
state = event.get("state", "")
|
|
368
|
+
if state == "running":
|
|
369
|
+
query_started = True
|
|
370
|
+
progress_seen = True
|
|
371
|
+
elif query_started and state == "stopped":
|
|
372
|
+
self.last_turn_end_state = state
|
|
373
|
+
async for chunk in self._drain_stream_events_after_idle(
|
|
374
|
+
expected_loop_id=expected_loop_id,
|
|
375
|
+
):
|
|
376
|
+
yield chunk
|
|
377
|
+
break
|
|
378
|
+
elif query_started and state == "idle":
|
|
379
|
+
if not stream_payload_seen and not self.last_turn_cancellation_seen:
|
|
380
|
+
continue
|
|
381
|
+
self.last_turn_end_state = state
|
|
382
|
+
async for chunk in self._drain_stream_events_after_idle(
|
|
383
|
+
expected_loop_id=expected_loop_id,
|
|
384
|
+
):
|
|
385
|
+
yield chunk
|
|
386
|
+
break
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
if event_type == "command_response":
|
|
390
|
+
content = str(event.get("content", ""))
|
|
391
|
+
if "Cancellation requested" in content:
|
|
392
|
+
self.last_turn_cancellation_seen = True
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
if event_type != "event":
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
data = event.get("data")
|
|
399
|
+
namespace = tuple(event.get("namespace", []) or [])
|
|
400
|
+
mode = str(event.get("mode", ""))
|
|
401
|
+
if self._should_drop(namespace, mode, data):
|
|
402
|
+
self.turn_event_stats.filtered_early += 1
|
|
403
|
+
continue
|
|
404
|
+
progress_seen = True
|
|
405
|
+
stream_payload_seen = True
|
|
406
|
+
yield (namespace, mode, data)
|
|
407
|
+
if mode == "custom" and isinstance(data, dict):
|
|
408
|
+
custom_type = str(data.get("type", "")).strip()
|
|
409
|
+
if custom_type in _TURN_END_CUSTOM_TYPES:
|
|
410
|
+
self.last_turn_end_state = "completed"
|
|
411
|
+
async for chunk in self._drain_stream_events_after_idle(
|
|
412
|
+
expected_loop_id=expected_loop_id,
|
|
413
|
+
):
|
|
414
|
+
yield chunk
|
|
415
|
+
break
|
|
416
|
+
if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
|
|
417
|
+
continue
|
|
418
|
+
except Exception as exc:
|
|
419
|
+
self.last_turn_error_message = str(exc)
|
|
420
|
+
raise
|
|
421
|
+
finally:
|
|
422
|
+
self._streaming = False
|
|
423
|
+
self.turn_event_stats.inbound_dropped = max(
|
|
424
|
+
0,
|
|
425
|
+
getattr(self._client, "inbound_dropped", 0) - inbound_dropped_baseline,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
async def list_skills(self) -> list[dict[str, Any]]:
|
|
429
|
+
"""Return skill rows from the daemon catalog."""
|
|
430
|
+
async with self._rpc_lock:
|
|
431
|
+
await self._ensure_rpc_connected()
|
|
432
|
+
response = await self._rpc_client.list_skills(timeout=15.0)
|
|
433
|
+
skills = response.get("skills", [])
|
|
434
|
+
if not isinstance(skills, list):
|
|
435
|
+
return []
|
|
436
|
+
return [s for s in skills if isinstance(s, dict)]
|
|
437
|
+
|
|
438
|
+
async def list_models(self) -> dict[str, Any]:
|
|
439
|
+
"""Return daemon ``models_list`` result."""
|
|
440
|
+
async with self._rpc_lock:
|
|
441
|
+
await self._ensure_rpc_connected()
|
|
442
|
+
return await self._rpc_client.list_models(timeout=15.0)
|
|
443
|
+
|
|
444
|
+
async def get_mcp_status(self) -> dict[str, Any]:
|
|
445
|
+
"""Return daemon ``mcp_status`` result."""
|
|
446
|
+
async with self._rpc_lock:
|
|
447
|
+
await self._ensure_rpc_connected()
|
|
448
|
+
return await self._rpc_client.get_mcp_status(timeout=15.0)
|
|
449
|
+
|
|
450
|
+
async def invoke_skill(
|
|
451
|
+
self,
|
|
452
|
+
skill: str,
|
|
453
|
+
args: str = "",
|
|
454
|
+
*,
|
|
455
|
+
clarification_mode: str | None = None,
|
|
456
|
+
) -> dict[str, Any]:
|
|
457
|
+
"""Invoke a skill on the **stream** socket (required for turn enqueue)."""
|
|
458
|
+
async with self._read_lock:
|
|
459
|
+
return await self._client.invoke_skill(
|
|
460
|
+
skill,
|
|
461
|
+
args,
|
|
462
|
+
timeout=120.0,
|
|
463
|
+
clarification_mode=clarification_mode,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
async def _ensure_rpc_connected(self) -> None:
|
|
467
|
+
if self._rpc_connected:
|
|
468
|
+
return
|
|
469
|
+
await connect_websocket_with_retries(self._rpc_client)
|
|
470
|
+
await self._rpc_client.request_connection_init()
|
|
471
|
+
await self._rpc_client.wait_for_connection_ack(ack_timeout_s=_RPC_HANDSHAKE_TIMEOUT_S)
|
|
472
|
+
self._rpc_connected = True
|
|
473
|
+
|
|
474
|
+
async def fetch_loop_cards(self, loop_id: str) -> SimpleNamespace:
|
|
475
|
+
"""Fetch bound display-card snapshot for a loop."""
|
|
476
|
+
lid = str(loop_id or "").strip()
|
|
477
|
+
if not lid:
|
|
478
|
+
return SimpleNamespace(cards=[], seq=0, success=False)
|
|
479
|
+
|
|
480
|
+
async with self._rpc_lock:
|
|
481
|
+
await self._ensure_rpc_connected()
|
|
482
|
+
try:
|
|
483
|
+
resp = await self._rpc_client.request(
|
|
484
|
+
"loop_cards_fetch",
|
|
485
|
+
{"loop_id": lid},
|
|
486
|
+
timeout=30.0,
|
|
487
|
+
)
|
|
488
|
+
except Exception:
|
|
489
|
+
logger.warning("loop_cards_fetch failed for loop %s", lid[:16], exc_info=True)
|
|
490
|
+
return SimpleNamespace(cards=[], seq=0, success=False)
|
|
491
|
+
|
|
492
|
+
raw_cards = resp.get("cards")
|
|
493
|
+
cards = list(raw_cards) if isinstance(raw_cards, list) else []
|
|
494
|
+
seq = int(resp.get("seq") or 0)
|
|
495
|
+
context_tokens_raw = resp.get("context_tokens")
|
|
496
|
+
context_tokens = (
|
|
497
|
+
context_tokens_raw
|
|
498
|
+
if isinstance(context_tokens_raw, int) and context_tokens_raw >= 0
|
|
499
|
+
else 0
|
|
500
|
+
)
|
|
501
|
+
return SimpleNamespace(
|
|
502
|
+
cards=cards,
|
|
503
|
+
seq=seq,
|
|
504
|
+
context_tokens=context_tokens,
|
|
505
|
+
success=True,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
async def fetch_loop_history(self, loop_id: str) -> SimpleNamespace:
|
|
509
|
+
"""Fetch goal display snapshots plus live card tail."""
|
|
510
|
+
lid = str(loop_id or "").strip()
|
|
511
|
+
if not lid:
|
|
512
|
+
return SimpleNamespace(
|
|
513
|
+
goals=[],
|
|
514
|
+
live_cards=[],
|
|
515
|
+
live_goal_index=None,
|
|
516
|
+
context_tokens=0,
|
|
517
|
+
success=False,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
async with self._rpc_lock:
|
|
521
|
+
await self._ensure_rpc_connected()
|
|
522
|
+
try:
|
|
523
|
+
resp = await self._rpc_client.request(
|
|
524
|
+
"loop_history_fetch",
|
|
525
|
+
{"loop_id": lid},
|
|
526
|
+
timeout=30.0,
|
|
527
|
+
)
|
|
528
|
+
except Exception:
|
|
529
|
+
logger.warning("loop_history_fetch failed for loop %s", lid[:16], exc_info=True)
|
|
530
|
+
return SimpleNamespace(
|
|
531
|
+
goals=[],
|
|
532
|
+
live_cards=[],
|
|
533
|
+
live_goal_index=None,
|
|
534
|
+
context_tokens=0,
|
|
535
|
+
success=False,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
goals_raw = resp.get("goals")
|
|
539
|
+
goals = list(goals_raw) if isinstance(goals_raw, list) else []
|
|
540
|
+
live_raw = resp.get("live_cards")
|
|
541
|
+
live_cards = list(live_raw) if isinstance(live_raw, list) else []
|
|
542
|
+
live_goal_index = resp.get("live_goal_index")
|
|
543
|
+
if live_goal_index is not None and not isinstance(live_goal_index, int):
|
|
544
|
+
live_goal_index = None
|
|
545
|
+
context_tokens_raw = resp.get("context_tokens")
|
|
546
|
+
context_tokens = (
|
|
547
|
+
context_tokens_raw
|
|
548
|
+
if isinstance(context_tokens_raw, int) and context_tokens_raw >= 0
|
|
549
|
+
else 0
|
|
550
|
+
)
|
|
551
|
+
success = bool(resp.get("success", True))
|
|
552
|
+
return SimpleNamespace(
|
|
553
|
+
goals=goals,
|
|
554
|
+
live_cards=live_cards,
|
|
555
|
+
live_goal_index=live_goal_index,
|
|
556
|
+
context_tokens=context_tokens,
|
|
557
|
+
success=success,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
async def aget_loop_state(self, loop_id: str) -> Any:
|
|
561
|
+
"""Load StrangeLoop state channels from the daemon."""
|
|
562
|
+
lid = str(loop_id or "").strip()
|
|
563
|
+
if not lid:
|
|
564
|
+
return SimpleNamespace(values={})
|
|
565
|
+
|
|
566
|
+
async with self._rpc_lock:
|
|
567
|
+
await self._ensure_rpc_connected()
|
|
568
|
+
try:
|
|
569
|
+
resp = await self._rpc_client.request(
|
|
570
|
+
"loop_state_get",
|
|
571
|
+
{"loop_id": lid},
|
|
572
|
+
timeout=30.0,
|
|
573
|
+
)
|
|
574
|
+
except Exception:
|
|
575
|
+
logger.warning("loop_state_get failed for loop %s", lid[:16], exc_info=True)
|
|
576
|
+
return SimpleNamespace(values={})
|
|
577
|
+
|
|
578
|
+
raw = resp.get("values")
|
|
579
|
+
values: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
|
|
580
|
+
return SimpleNamespace(values=values)
|
|
581
|
+
|
|
582
|
+
async def aupdate_loop_state(
|
|
583
|
+
self,
|
|
584
|
+
loop_id: str,
|
|
585
|
+
values: dict[str, Any],
|
|
586
|
+
*,
|
|
587
|
+
timeout: float = 10.0,
|
|
588
|
+
as_node: str | None = None,
|
|
589
|
+
) -> None:
|
|
590
|
+
"""Merge partial state into the loop on the daemon host."""
|
|
591
|
+
lid = str(loop_id or "").strip()
|
|
592
|
+
if not lid:
|
|
593
|
+
return
|
|
594
|
+
|
|
595
|
+
async with self._rpc_lock:
|
|
596
|
+
await self._ensure_rpc_connected()
|
|
597
|
+
from soothe_sdk.wire.protocol import _serialize_for_json
|
|
598
|
+
|
|
599
|
+
payload_values = _serialize_for_json(values)
|
|
600
|
+
if not isinstance(payload_values, dict):
|
|
601
|
+
return
|
|
602
|
+
params: dict[str, Any] = {"loop_id": lid, "values": payload_values}
|
|
603
|
+
if as_node:
|
|
604
|
+
params["as_node"] = as_node
|
|
605
|
+
await self._rpc_client.request(
|
|
606
|
+
"loop_state_update",
|
|
607
|
+
params,
|
|
608
|
+
timeout=timeout,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
async def fetch_conversation_log(
|
|
612
|
+
self,
|
|
613
|
+
loop_id: str,
|
|
614
|
+
*,
|
|
615
|
+
limit: int = 100,
|
|
616
|
+
offset: int = 0,
|
|
617
|
+
include_events: bool = False,
|
|
618
|
+
) -> list[dict[str, Any]]:
|
|
619
|
+
"""Load persisted rows for a loop (conversation + optional events)."""
|
|
620
|
+
lid = str(loop_id or "").strip()
|
|
621
|
+
if not lid:
|
|
622
|
+
return []
|
|
623
|
+
|
|
624
|
+
async with self._rpc_lock:
|
|
625
|
+
await self._ensure_rpc_connected()
|
|
626
|
+
resp = await self._rpc_client.request(
|
|
627
|
+
"loop_messages",
|
|
628
|
+
{
|
|
629
|
+
"loop_id": lid,
|
|
630
|
+
"limit": limit,
|
|
631
|
+
"offset": offset,
|
|
632
|
+
"include_events": include_events,
|
|
633
|
+
},
|
|
634
|
+
timeout=10.0,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
raw = resp.get("messages")
|
|
638
|
+
if not isinstance(raw, list):
|
|
639
|
+
return []
|
|
640
|
+
return [m for m in raw if isinstance(m, dict)]
|
|
641
|
+
|
|
642
|
+
async def fetch_goal_completion_text(self, loop_id: str) -> str | None:
|
|
643
|
+
"""Return the latest persisted ``goal_completion`` body for a loop, if any."""
|
|
644
|
+
rows = await self.fetch_conversation_log(loop_id, limit=200, include_events=False)
|
|
645
|
+
for row in reversed(rows):
|
|
646
|
+
if row.get("phase") != "goal_completion":
|
|
647
|
+
continue
|
|
648
|
+
text = row.get("text") or row.get("content") or ""
|
|
649
|
+
if isinstance(text, str) and text.strip():
|
|
650
|
+
return text.strip()
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
__all__ = [
|
|
655
|
+
"DEFAULT_POST_IDLE_DRAIN_S",
|
|
656
|
+
"DaemonSession",
|
|
657
|
+
]
|