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,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
+ ]