soothe-client-python 1.0.21__tar.gz → 1.0.23__tar.gz
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_python-1.0.21 → soothe_client_python-1.0.23}/PKG-INFO +1 -1
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/__init__.py +0 -3
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/__init__.py +3 -11
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/attachments.py +6 -6
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/broadcaster.py +3 -3
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/card_projection.py +1 -1
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/chunk_filter.py +3 -3
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/classifier.py +7 -8
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/daemon_session.py +38 -57
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/events.py +9 -16
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/loop_session_store.py +2 -2
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/managed_client.py +6 -6
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/observability.py +1 -1
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/pool.py +5 -9
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/query_gate.py +3 -3
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/thinking_step.py +2 -3
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/turn.py +3 -8
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/turn_boundary.py +1 -1
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/turn_runner.py +5 -5
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/command_client.py +27 -45
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/errors.py +1 -1
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/helpers.py +49 -62
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/intent_hints.py +1 -1
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/protocol_params.py +52 -252
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/schemas.py +1 -4
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/session.py +27 -31
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/stream_terminal.py +7 -7
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/turn_boundary.py +17 -17
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/websocket.py +117 -224
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/.gitignore +0 -0
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/LICENSE +0 -0
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/README.md +0 -0
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/pyproject.toml +0 -0
- {soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: soothe-client-python
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.23
|
|
4
4
|
Summary: WebSocket client + appkit for soothe-daemon (Python)
|
|
5
5
|
Project-URL: Homepage, https://github.com/mirasoth/soothe-client-python
|
|
6
6
|
Project-URL: Documentation, https://soothe.readthedocs.io
|
|
@@ -5,9 +5,6 @@ Public surface::
|
|
|
5
5
|
from soothe_client import WebSocketClient, is_daemon_live
|
|
6
6
|
from soothe_client import AsyncCommandClient, CommandClient
|
|
7
7
|
from soothe_client.appkit import DaemonSession
|
|
8
|
-
|
|
9
|
-
Wire request param models: ``soothe_client.protocol_params``.
|
|
10
|
-
Session / multi-user helpers: ``soothe_client.appkit``.
|
|
11
8
|
"""
|
|
12
9
|
|
|
13
10
|
from __future__ import annotations
|
{soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/__init__.py
RENAMED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
"""Application helpers built on WebSocketClient.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
- ``ConnectionPool`` / ``TurnRunner`` — multi-session turn execution
|
|
7
|
-
- ``QueryGate`` — one-in-flight query per session
|
|
8
|
-
- ``EventClassifier`` — stream → deliverable mapping
|
|
9
|
-
- ``SSEBroadcaster`` — drop-on-full fan-out to subscribers
|
|
10
|
-
- ``LoopSessionStore`` — persistence seam (Protocol)
|
|
11
|
-
|
|
12
|
-
Advanced stream/pipeline plumbing lives in submodules
|
|
13
|
-
(``events``, ``chunk_filter``, ``turn``, ``managed_client``).
|
|
3
|
+
Building blocks for agent UIs and backends: `DaemonSession`,
|
|
4
|
+
`ConnectionPool` / `TurnRunner`, `QueryGate`, `EventClassifier`,
|
|
5
|
+
`SSEBroadcaster`, `LoopSessionStore`.
|
|
14
6
|
"""
|
|
15
7
|
|
|
16
8
|
from __future__ import annotations
|
{soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/attachments.py
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Image attachment compaction for appkit.
|
|
2
2
|
|
|
3
|
-
Downscales
|
|
3
|
+
Downscales `image/*` payloads when either dimension exceeds a max size.
|
|
4
4
|
Non-images and decode failures pass through unchanged.
|
|
5
5
|
"""
|
|
6
6
|
|
|
@@ -16,7 +16,7 @@ from PIL import Image
|
|
|
16
16
|
|
|
17
17
|
@dataclass(slots=True)
|
|
18
18
|
class CompactImageOptions:
|
|
19
|
-
"""Controls
|
|
19
|
+
"""Controls `compact_image_attachment`. Zero / unset fields use defaults."""
|
|
20
20
|
|
|
21
21
|
max_dim: int = 768
|
|
22
22
|
jpeg_quality: int = 85
|
|
@@ -38,15 +38,15 @@ def compact_image_attachment(
|
|
|
38
38
|
data_b64: str,
|
|
39
39
|
opts: CompactImageOptions | None = None,
|
|
40
40
|
) -> tuple[str, str]:
|
|
41
|
-
"""Downscale an image attachment when either dimension exceeds
|
|
41
|
+
"""Downscale an image attachment when either dimension exceeds `max_dim`.
|
|
42
42
|
|
|
43
43
|
Args:
|
|
44
|
-
mime_type: Attachment MIME type (e.g.
|
|
44
|
+
mime_type: Attachment MIME type (e.g. `image/png`).
|
|
45
45
|
data_b64: Base64-encoded image bytes.
|
|
46
46
|
opts: Optional size / JPEG quality overrides.
|
|
47
47
|
|
|
48
48
|
Returns:
|
|
49
|
-
|
|
49
|
+
`(out_mime, out_b64)`. Non-images and failures return inputs unchanged.
|
|
50
50
|
"""
|
|
51
51
|
if not data_b64 or not mime_type.startswith("image/"):
|
|
52
52
|
return mime_type, data_b64
|
|
@@ -92,7 +92,7 @@ def compact_attachments(
|
|
|
92
92
|
atts: list[dict[str, Any]] | None,
|
|
93
93
|
opts: CompactImageOptions | None = None,
|
|
94
94
|
) -> list[dict[str, Any]]:
|
|
95
|
-
"""Apply
|
|
95
|
+
"""Apply `compact_image_attachment` to each `mime_type` + `data` map."""
|
|
96
96
|
if not atts:
|
|
97
97
|
return atts or []
|
|
98
98
|
out: list[dict[str, Any]] = []
|
{soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/broadcaster.py
RENAMED
|
@@ -19,7 +19,7 @@ SUBSCRIBER_QUEUE_CAP = 100
|
|
|
19
19
|
|
|
20
20
|
@dataclass(frozen=True, slots=True)
|
|
21
21
|
class SSEEvent:
|
|
22
|
-
"""One Server-Sent Event payload. The
|
|
22
|
+
"""One Server-Sent Event payload. The `type` vocabulary is app-defined."""
|
|
23
23
|
|
|
24
24
|
type: str
|
|
25
25
|
data: Any = None
|
|
@@ -40,9 +40,9 @@ class SSEBroadcaster:
|
|
|
40
40
|
self._subscribers: dict[str, dict[str, _Subscriber]] = {}
|
|
41
41
|
|
|
42
42
|
def subscribe(self, session_id: str) -> tuple[AsyncIterator[SSEEvent], str]:
|
|
43
|
-
"""Register a subscriber and return
|
|
43
|
+
"""Register a subscriber and return `(async iterator, subscription id)`.
|
|
44
44
|
|
|
45
|
-
Unsubscribe via
|
|
45
|
+
Unsubscribe via `unsubscribe` or `close`.
|
|
46
46
|
"""
|
|
47
47
|
sub_id = str(uuid4())
|
|
48
48
|
sub = _Subscriber()
|
{soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/chunk_filter.py
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Early filters for daemon stream chunks (wire / dict-shaped payloads).
|
|
2
2
|
|
|
3
3
|
CLI callers that also see LangChain message objects should pass a richer filter
|
|
4
|
-
callback (e.g.
|
|
4
|
+
callback (e.g. `soothe_cli.runtime.wire.chunk_filter.should_drop_stream_chunk_early`).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -12,7 +12,7 @@ _MSG_PAIR_LEN = 2
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def updates_chunk_is_noop(data: Any) -> bool:
|
|
15
|
-
"""True when an
|
|
15
|
+
"""True when an `updates` chunk carries no LangGraph interrupt."""
|
|
16
16
|
if not isinstance(data, dict):
|
|
17
17
|
return True
|
|
18
18
|
return "__interrupt__" not in data
|
|
@@ -62,7 +62,7 @@ def _plain_text(msg: dict[str, Any]) -> str:
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def message_chunk_is_non_actionable(data: Any) -> bool:
|
|
65
|
-
"""True when a wire
|
|
65
|
+
"""True when a wire `messages` pair has no tool, text, or loop phase payload."""
|
|
66
66
|
if not isinstance(data, (list, tuple)) or len(data) != _MSG_PAIR_LEN:
|
|
67
67
|
return False
|
|
68
68
|
msg = data[0]
|
{soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/classifier.py
RENAMED
|
@@ -2,7 +2,7 @@
|
|
|
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
|
|
5
|
-
|
|
5
|
+
`deliverable_phases` set; appkit stays product-agnostic.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -40,18 +40,17 @@ class ChatEventResult:
|
|
|
40
40
|
|
|
41
41
|
@dataclass(frozen=True, slots=True)
|
|
42
42
|
class ClassifierConfig:
|
|
43
|
-
"""Product-specific decisions an
|
|
43
|
+
"""Product-specific decisions an `EventClassifier` needs.
|
|
44
44
|
|
|
45
45
|
Attributes:
|
|
46
|
-
deliverable_phases: Message
|
|
46
|
+
deliverable_phases: Message `phase` values that may end a query with
|
|
47
47
|
user-facing text. Required.
|
|
48
48
|
min_deliverable_runes: Minimum trimmed rune count for a reply to be
|
|
49
|
-
persisted as final
|
|
49
|
+
persisted as final. Defaults to 8.
|
|
50
50
|
thinking_step_events: Optional override of the default thinking-step
|
|
51
51
|
event allowlist.
|
|
52
|
-
treat_status_idle_as_complete: Standalone
|
|
53
|
-
|
|
54
|
-
contract). Default false.
|
|
52
|
+
treat_status_idle_as_complete: Standalone `classify` only. Prefer
|
|
53
|
+
`TurnRunner` + `TurnBoundary` for turn end. Default false.
|
|
55
54
|
"""
|
|
56
55
|
|
|
57
56
|
deliverable_phases: frozenset[str] | set[str]
|
|
@@ -100,7 +99,7 @@ class EventClassifier:
|
|
|
100
99
|
return "soothe.output" in event_type and "responded" in event_type
|
|
101
100
|
|
|
102
101
|
def is_deliverable_loop_phase(self, phase: str) -> bool:
|
|
103
|
-
"""Return whether
|
|
102
|
+
"""Return whether `phase` is in the configured deliverable set."""
|
|
104
103
|
return phase in self._deliverable_phases
|
|
105
104
|
|
|
106
105
|
def is_substantive_assistant_reply(self, content: str) -> bool:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
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 loop events.
|
|
4
|
+
starve loop events. `iter_turn_chunks` handles idle timeout, post-idle drain,
|
|
5
5
|
loop scoping, and connection-loss detection.
|
|
6
6
|
"""
|
|
7
7
|
|
|
@@ -57,9 +57,6 @@ class DaemonSession:
|
|
|
57
57
|
async for _ns, mode, data in session.iter_turn_chunks():
|
|
58
58
|
...
|
|
59
59
|
await session.close()
|
|
60
|
-
|
|
61
|
-
Advanced: ``early_drop_fn`` / ``stats_factory`` customize stream filtering
|
|
62
|
-
and metrics for product UIs; most apps leave the defaults.
|
|
63
60
|
"""
|
|
64
61
|
|
|
65
62
|
def __init__(
|
|
@@ -103,12 +100,7 @@ class DaemonSession:
|
|
|
103
100
|
self._drain_saw_successor_running: bool = False
|
|
104
101
|
|
|
105
102
|
def _reset_turn_seq_floor(self) -> None:
|
|
106
|
-
"""Clear seq/turn filters when the peer seq namespace may have restarted.
|
|
107
|
-
|
|
108
|
-
Keep-TUI reconnect after a daemon restart (or subscribe to a different
|
|
109
|
-
loop) would otherwise drop all low-seq frames — including
|
|
110
|
-
``status=running`` — leaving ``query_started=False`` and a silent TUI.
|
|
111
|
-
"""
|
|
103
|
+
"""Clear seq/turn filters when the peer seq namespace may have restarted."""
|
|
112
104
|
self._last_turn_end_seq = 0
|
|
113
105
|
self._last_completed_turn_gen = 0
|
|
114
106
|
self._expected_turn_id = None
|
|
@@ -171,7 +163,7 @@ class DaemonSession:
|
|
|
171
163
|
"""Open the stream socket and bootstrap (or resume) a loop session.
|
|
172
164
|
|
|
173
165
|
Returns:
|
|
174
|
-
Status event from the daemon (includes
|
|
166
|
+
Status event from the daemon (includes `loop_id` on success).
|
|
175
167
|
"""
|
|
176
168
|
await connect_websocket_with_retries(self._client)
|
|
177
169
|
return await self._bootstrap_loop(resume_loop_id=resume_loop_id)
|
|
@@ -201,11 +193,7 @@ class DaemonSession:
|
|
|
201
193
|
return await self._bootstrap_loop(resume_loop_id=loop_id)
|
|
202
194
|
|
|
203
195
|
async def ensure_connected(self) -> None:
|
|
204
|
-
"""Reconnect and re-subscribe when the stream WebSocket died.
|
|
205
|
-
|
|
206
|
-
Prefers ``reconnect`` + ``reattach_and_probe`` when a loop id is known;
|
|
207
|
-
falls back to bootstrap on ``StaleLoopError``.
|
|
208
|
-
"""
|
|
196
|
+
"""Reconnect and re-subscribe when the stream WebSocket died."""
|
|
209
197
|
from soothe_client.errors import StaleLoopError
|
|
210
198
|
|
|
211
199
|
is_disconn = getattr(self._client, "is_disconnected", None)
|
|
@@ -266,7 +254,7 @@ class DaemonSession:
|
|
|
266
254
|
self._rpc_connected = False
|
|
267
255
|
|
|
268
256
|
async def detach(self) -> None:
|
|
269
|
-
"""Notify the daemon that this client is leaving (
|
|
257
|
+
"""Notify the daemon that this client is leaving (`disconnect`)."""
|
|
270
258
|
if not self._client.is_connected:
|
|
271
259
|
logger.debug("Skipping detach — connection already closed")
|
|
272
260
|
return
|
|
@@ -289,11 +277,11 @@ class DaemonSession:
|
|
|
289
277
|
clarification_answer: bool = False,
|
|
290
278
|
clarification_answers: list[str] | None = None,
|
|
291
279
|
intent_hint: str | None = None,
|
|
280
|
+
autopilot_rail_id: str | None = None,
|
|
292
281
|
) -> None:
|
|
293
282
|
"""Send a user message on the active loop (start of a turn).
|
|
294
283
|
|
|
295
|
-
Call
|
|
296
|
-
Requires ``connect()`` (or another bootstrap) first.
|
|
284
|
+
Call `iter_turn_chunks` afterward to consume the streamed reply.
|
|
297
285
|
"""
|
|
298
286
|
if not self._loop_id:
|
|
299
287
|
raise RuntimeError("No active loop session")
|
|
@@ -310,10 +298,11 @@ class DaemonSession:
|
|
|
310
298
|
clarification_answer=clarification_answer,
|
|
311
299
|
clarification_answers=clarification_answers,
|
|
312
300
|
intent_hint=intent_hint,
|
|
301
|
+
autopilot_rail_id=autopilot_rail_id,
|
|
313
302
|
)
|
|
314
303
|
|
|
315
304
|
async def cancel_remote_query(self) -> None:
|
|
316
|
-
"""Ask the daemon to cancel via slash
|
|
305
|
+
"""Ask the daemon to cancel via slash `/cancel` (CLI wire path)."""
|
|
317
306
|
await self._client.notify("slash_command", {"cmd": "/cancel"})
|
|
318
307
|
|
|
319
308
|
async def cancel_active_turn(self) -> None:
|
|
@@ -325,10 +314,10 @@ class DaemonSession:
|
|
|
325
314
|
*,
|
|
326
315
|
expected_loop_id: str | None,
|
|
327
316
|
) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
|
|
328
|
-
"""Yield stream chunks that arrive just after
|
|
317
|
+
"""Yield stream chunks that arrive just after `idle`.
|
|
329
318
|
|
|
330
|
-
If a successor
|
|
331
|
-
arrives, re-queue it and set
|
|
319
|
+
If a successor `status=running` (generation above the completed floor)
|
|
320
|
+
arrives, re-queue it and set `_drain_saw_successor_running` so the
|
|
332
321
|
caller can continue the reader instead of ending the turn.
|
|
333
322
|
"""
|
|
334
323
|
self._drain_saw_successor_running = False
|
|
@@ -389,7 +378,7 @@ class DaemonSession:
|
|
|
389
378
|
continue
|
|
390
379
|
|
|
391
380
|
async def list_loops(self, *, limit: int = 20) -> dict[str, Any]:
|
|
392
|
-
"""Return
|
|
381
|
+
"""Return `loop_list` via the RPC sidecar."""
|
|
393
382
|
async with self._rpc_lock:
|
|
394
383
|
await self._ensure_rpc_connected()
|
|
395
384
|
return await self._rpc_client.request("loop_list", {"limit": limit}, timeout=15.0)
|
|
@@ -400,22 +389,15 @@ class DaemonSession:
|
|
|
400
389
|
max_wait_s: float | None = None,
|
|
401
390
|
idle_timeout_s: float | None = None,
|
|
402
391
|
) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
|
|
403
|
-
"""Yield
|
|
392
|
+
"""Yield `(namespace, mode, data)` stream chunks until the turn ends.
|
|
404
393
|
|
|
405
394
|
Handles idle timeout, post-idle drain, and turn boundary filtering.
|
|
406
|
-
Call after
|
|
395
|
+
Call after `send_turn`.
|
|
407
396
|
|
|
408
397
|
Args:
|
|
409
|
-
max_wait_s: Optional absolute deadline for the whole turn.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
idle_timeout_s: Optional hard cap on how long to wait for the *first*
|
|
413
|
-
progress event before giving up. When set and no progress is seen
|
|
414
|
-
within this many seconds, raises ``TimeoutError`` instead of the
|
|
415
|
-
default 30s-log-and-reset behavior. Used by attach-only reads
|
|
416
|
-
(``skip_daemon_send_turn``) so a stale ``live`` probe that turns
|
|
417
|
-
out to have no real follow-on turn does not spin the thinking
|
|
418
|
-
row for minutes.
|
|
398
|
+
max_wait_s: Optional absolute deadline for the whole turn.
|
|
399
|
+
idle_timeout_s: Optional cap on how long to wait for the first
|
|
400
|
+
progress event before raising `TimeoutError`.
|
|
419
401
|
"""
|
|
420
402
|
self._ensure_turn_floor_attrs()
|
|
421
403
|
self.turn_event_stats = self._new_turn_stats()
|
|
@@ -437,7 +419,7 @@ class DaemonSession:
|
|
|
437
419
|
first_event_logged = False
|
|
438
420
|
progress_seen = False
|
|
439
421
|
# Wall-clock of the last *real* progress (a yielded chunk). A stale
|
|
440
|
-
#
|
|
422
|
+
# `status=running` frame sets `progress_seen` but yields nothing;
|
|
441
423
|
# for the attach-only idle timeout we need a signal that advances when
|
|
442
424
|
# the daemon actually streams content, not when it merely re-announces
|
|
443
425
|
# a (possibly stale) running status.
|
|
@@ -474,7 +456,7 @@ class DaemonSession:
|
|
|
474
456
|
)
|
|
475
457
|
turn_read_started = time.monotonic()
|
|
476
458
|
# Attach-only idle timeout: even after a stale
|
|
477
|
-
#
|
|
459
|
+
# `status=running` sets `progress_seen`, no real content
|
|
478
460
|
# may ever arrive (the runner already exited). Bound the wait
|
|
479
461
|
# for the first yielded chunk so the TUI falls back instead
|
|
480
462
|
# of spinning the thinking row for minutes.
|
|
@@ -735,13 +717,13 @@ class DaemonSession:
|
|
|
735
717
|
return [s for s in skills if isinstance(s, dict)]
|
|
736
718
|
|
|
737
719
|
async def list_models(self) -> dict[str, Any]:
|
|
738
|
-
"""Return daemon
|
|
720
|
+
"""Return daemon `models_list` result."""
|
|
739
721
|
async with self._rpc_lock:
|
|
740
722
|
await self._ensure_rpc_connected()
|
|
741
723
|
return await self._rpc_client.list_models(timeout=15.0)
|
|
742
724
|
|
|
743
725
|
async def get_mcp_status(self) -> dict[str, Any]:
|
|
744
|
-
"""Return daemon
|
|
726
|
+
"""Return daemon `mcp_status` result."""
|
|
745
727
|
async with self._rpc_lock:
|
|
746
728
|
await self._ensure_rpc_connected()
|
|
747
729
|
return await self._rpc_client.get_mcp_status(timeout=15.0)
|
|
@@ -764,20 +746,26 @@ class DaemonSession:
|
|
|
764
746
|
interaction_mode=interaction_mode,
|
|
765
747
|
)
|
|
766
748
|
|
|
767
|
-
async def set_clarification_mode(
|
|
768
|
-
|
|
749
|
+
async def set_clarification_mode(
|
|
750
|
+
self,
|
|
751
|
+
mode: str,
|
|
752
|
+
*,
|
|
753
|
+
interaction_mode: str | None = None,
|
|
754
|
+
) -> bool:
|
|
755
|
+
"""Hot-swap the agent mode on the running goal.
|
|
769
756
|
|
|
770
|
-
|
|
771
|
-
``True`` when the swap landed on a live goal; ``False`` when no goal
|
|
772
|
-
is currently running (retry on the next turn).
|
|
757
|
+
Returns `True` on a live goal, `False` when none is running.
|
|
773
758
|
"""
|
|
774
759
|
if not self._loop_id:
|
|
775
760
|
return False
|
|
761
|
+
params: dict[str, Any] = {"loop_id": self._loop_id, "mode": mode}
|
|
762
|
+
if interaction_mode is not None:
|
|
763
|
+
params["interaction_mode"] = interaction_mode
|
|
776
764
|
async with self._rpc_lock:
|
|
777
765
|
await self._ensure_rpc_connected()
|
|
778
766
|
result = await self._rpc_client.request(
|
|
779
767
|
"loop_set_clarification_mode",
|
|
780
|
-
|
|
768
|
+
params,
|
|
781
769
|
timeout=5.0,
|
|
782
770
|
)
|
|
783
771
|
return bool(result.get("applied", False))
|
|
@@ -872,10 +860,7 @@ class DaemonSession:
|
|
|
872
860
|
timeout: float = 10.0,
|
|
873
861
|
as_node: str | None = None,
|
|
874
862
|
) -> dict[str, Any]:
|
|
875
|
-
"""Merge partial StrangeLoop state via daemon
|
|
876
|
-
|
|
877
|
-
Used by the TUI for interrupt cleanup and token-total persistence.
|
|
878
|
-
"""
|
|
863
|
+
"""Merge partial StrangeLoop state via daemon `loop_state_update`."""
|
|
879
864
|
lid = str(loop_id or "").strip()
|
|
880
865
|
if not lid or not isinstance(values, dict):
|
|
881
866
|
return {}
|
|
@@ -896,11 +881,7 @@ class DaemonSession:
|
|
|
896
881
|
async def fetch_execution_state(self, loop_id: str) -> SimpleNamespace:
|
|
897
882
|
"""Fetch execution-progress snapshot (plan, step_index, iteration, status).
|
|
898
883
|
|
|
899
|
-
|
|
900
|
-
``status``, and ``active_runner`` for the loop's bound checkpoint
|
|
901
|
-
thread. On RPC failure the fields degrade gracefully (``step_index=0``,
|
|
902
|
-
``iteration=0``, ``status=None``, ``plan=None``, ``active_runner=False``)
|
|
903
|
-
so a resume gate can still proceed.
|
|
884
|
+
Fields degrade gracefully on RPC failure so a resume gate can still proceed.
|
|
904
885
|
"""
|
|
905
886
|
lid = str(loop_id or "").strip()
|
|
906
887
|
empty = SimpleNamespace(
|
|
@@ -936,7 +917,7 @@ class DaemonSession:
|
|
|
936
917
|
iteration = iteration_raw if isinstance(iteration_raw, int) else 0
|
|
937
918
|
status_raw = resp.get("status")
|
|
938
919
|
status = status_raw if isinstance(status_raw, str) else None
|
|
939
|
-
#
|
|
920
|
+
# `active_runner` is absent on daemons older than this field; treat
|
|
940
921
|
# absence as unknown (None) so callers can fall back to status only.
|
|
941
922
|
active_runner_raw = resp.get("active_runner")
|
|
942
923
|
active_runner: bool | None
|
|
@@ -987,7 +968,7 @@ class DaemonSession:
|
|
|
987
968
|
return [m for m in raw if isinstance(m, dict)]
|
|
988
969
|
|
|
989
970
|
async def fetch_goal_completion_text(self, loop_id: str) -> str | None:
|
|
990
|
-
"""Return the latest persisted
|
|
971
|
+
"""Return the latest persisted `goal_completion` body for a loop, if any."""
|
|
991
972
|
rows = await self.fetch_conversation_log(loop_id, limit=200, include_events=False)
|
|
992
973
|
for row in reversed(rows):
|
|
993
974
|
if row.get("phase") != "goal_completion":
|
{soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/events.py
RENAMED
|
@@ -6,23 +6,17 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def unwrap_next(event: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
9
|
-
"""Unwrap a protocol-1
|
|
9
|
+
"""Unwrap a protocol-1 `next` envelope to its inner streaming frame.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
``{proto, type:"next", payload:{namespace, mode, data}}`` envelope. This
|
|
14
|
-
helper returns the inner ``data`` dict (the legacy frame) so turn loops
|
|
15
|
-
can branch on the same fields as before the migration.
|
|
16
|
-
|
|
17
|
-
``status`` / ``error`` / ``response`` / ``complete`` are sent raw and pass
|
|
18
|
-
through unchanged.
|
|
11
|
+
Returns the inner `payload.data` dict for `next` envelopes; other
|
|
12
|
+
types pass through unchanged.
|
|
19
13
|
|
|
20
14
|
Args:
|
|
21
|
-
event: A raw wire frame
|
|
15
|
+
event: A raw wire frame from `client.read_event()`.
|
|
22
16
|
|
|
23
17
|
Returns:
|
|
24
|
-
The inner
|
|
25
|
-
|
|
18
|
+
The inner `payload.data` dict for `next`, the original frame
|
|
19
|
+
otherwise, or `None` if `event` is `None`.
|
|
26
20
|
"""
|
|
27
21
|
if not isinstance(event, dict):
|
|
28
22
|
return event
|
|
@@ -38,16 +32,15 @@ def unwrap_next(event: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
|
38
32
|
def is_loop_scoped_event(event: dict[str, Any], *, active_loop_id: str) -> bool:
|
|
39
33
|
"""Return whether a daemon frame belongs to the active StrangeLoop session.
|
|
40
34
|
|
|
41
|
-
Unwraps
|
|
42
|
-
|
|
43
|
-
``complete``, etc.) are always considered in-scope.
|
|
35
|
+
Unwraps `next` envelopes first, then checks `loop_id`. Non-scoped types
|
|
36
|
+
(`response`, `error`, `complete`) are always in-scope.
|
|
44
37
|
|
|
45
38
|
Args:
|
|
46
39
|
event: Raw or already-unwrapped wire frame.
|
|
47
40
|
active_loop_id: Loop id for the in-flight session.
|
|
48
41
|
|
|
49
42
|
Returns:
|
|
50
|
-
True when the frame should be processed for
|
|
43
|
+
True when the frame should be processed for `active_loop_id`.
|
|
51
44
|
"""
|
|
52
45
|
event_type = event.get("type", "")
|
|
53
46
|
if event_type == "next":
|
|
@@ -67,7 +67,7 @@ class LoopSessionStore(Protocol):
|
|
|
67
67
|
"""Persistence seam between appkit and the application's storage backend."""
|
|
68
68
|
|
|
69
69
|
async def get_session(self, session_id: str) -> LoopSessionEntry | None:
|
|
70
|
-
"""Return the persisted entry for
|
|
70
|
+
"""Return the persisted entry for `session_id`, or None."""
|
|
71
71
|
...
|
|
72
72
|
|
|
73
73
|
async def create_session(
|
|
@@ -89,7 +89,7 @@ class LoopSessionStore(Protocol):
|
|
|
89
89
|
...
|
|
90
90
|
|
|
91
91
|
async def get_loop_id_for_session(self, session_id: str) -> tuple[str, bool]:
|
|
92
|
-
"""Return
|
|
92
|
+
"""Return `(loop_id, ok)`; `ok is False` triggers fresh `loop_new`."""
|
|
93
93
|
...
|
|
94
94
|
|
|
95
95
|
async def append_message(self, session_id: str, message: SessionMessage) -> None:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""ManagedClient adapter used by ConnectionPool and TurnRunner.
|
|
2
2
|
|
|
3
|
-
Wraps
|
|
4
|
-
|
|
3
|
+
Wraps `WebSocketClient` and upgrades flat appkit payloads to protocol-1
|
|
4
|
+
envelopes before send.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -18,7 +18,7 @@ BootstrapFunc = Callable[
|
|
|
18
18
|
Awaitable[str],
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
-
# Flat appkit payloads still use legacy
|
|
21
|
+
# Flat appkit payloads still use legacy `{"type": "<op>", ...}` maps (parity
|
|
22
22
|
# with Go InputMessageForLoop). The live daemon accepts protocol-1 envelopes
|
|
23
23
|
# only — coerce at the ManagedClient boundary before send.
|
|
24
24
|
_ALREADY_ENVELOPE_TYPES = frozenset(
|
|
@@ -133,7 +133,7 @@ class ManagedClient(Protocol):
|
|
|
133
133
|
|
|
134
134
|
|
|
135
135
|
class WebSocketManagedClient:
|
|
136
|
-
"""Adapter wrapping
|
|
136
|
+
"""Adapter wrapping `WebSocketClient` as a `ManagedClient`."""
|
|
137
137
|
|
|
138
138
|
def __init__(self, client: WebSocketClient) -> None:
|
|
139
139
|
self._client = client
|
|
@@ -189,7 +189,7 @@ class WebSocketManagedClient:
|
|
|
189
189
|
|
|
190
190
|
|
|
191
191
|
def default_client_factory() -> ClientFactory:
|
|
192
|
-
"""Return a factory that builds
|
|
192
|
+
"""Return a factory that builds `WebSocketManagedClient` instances."""
|
|
193
193
|
|
|
194
194
|
def _factory(url: str) -> ManagedClient:
|
|
195
195
|
return WebSocketManagedClient(WebSocketClient(url=url))
|
|
@@ -198,7 +198,7 @@ def default_client_factory() -> ClientFactory:
|
|
|
198
198
|
|
|
199
199
|
|
|
200
200
|
def default_bootstrap_func() -> BootstrapFunc:
|
|
201
|
-
"""Default bootstrap:
|
|
201
|
+
"""Default bootstrap: `loop_new` + `subscribe(loop_events)`."""
|
|
202
202
|
|
|
203
203
|
async def _bootstrap(client: ManagedClient, workspace_id: str, user_id: str) -> str:
|
|
204
204
|
from soothe_client.session import bootstrap_loop_session
|
|
@@ -9,7 +9,7 @@ from dataclasses import dataclass
|
|
|
9
9
|
class TurnEventStats:
|
|
10
10
|
"""Event counts accumulated over a single daemon turn.
|
|
11
11
|
|
|
12
|
-
Apps may subclass or replace this via
|
|
12
|
+
Apps may subclass or replace this via `DaemonSession`'s stats factory.
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
total: int = 0
|
{soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/pool.py
RENAMED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Manages a pool of daemon connections, one active per session. Reuses an
|
|
4
4
|
active connection when still live, otherwise bootstraps a fresh loop or
|
|
5
5
|
reattaches an existing one. Persistence of session↔loop mappings is
|
|
6
|
-
abstracted behind
|
|
6
|
+
abstracted behind `LoopSessionStore`.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
@@ -35,11 +35,7 @@ class ErrPoolExhausted(Exception): # noqa: N818 — match Go/TS name
|
|
|
35
35
|
|
|
36
36
|
@dataclass(slots=True)
|
|
37
37
|
class PoolConfig:
|
|
38
|
-
"""Configures a
|
|
39
|
-
|
|
40
|
-
``max_idle_time_s`` is enforced on ``acquire`` (idle sessions are released).
|
|
41
|
-
``health_check_interval_s`` is reserved for future background sweeps.
|
|
42
|
-
"""
|
|
38
|
+
"""Configures a `ConnectionPool`. Zero / negative sizes use defaults."""
|
|
43
39
|
|
|
44
40
|
pool_size: int = 1000
|
|
45
41
|
query_timeout_s: float = 30 * 60
|
|
@@ -145,9 +141,9 @@ class ConnectionPool:
|
|
|
145
141
|
workspace_id: str,
|
|
146
142
|
user_id: str,
|
|
147
143
|
) -> PooledConn:
|
|
148
|
-
"""Return a live connection for
|
|
144
|
+
"""Return a live connection for `session_id`.
|
|
149
145
|
|
|
150
|
-
Caller must
|
|
146
|
+
Caller must `release` when done (turn completes or session reset).
|
|
151
147
|
"""
|
|
152
148
|
async with self._lock:
|
|
153
149
|
existing = self._active.get(session_id)
|
|
@@ -209,7 +205,7 @@ class ConnectionPool:
|
|
|
209
205
|
return conn
|
|
210
206
|
|
|
211
207
|
async def release(self, session_id: str) -> None:
|
|
212
|
-
"""Tear down the connection for
|
|
208
|
+
"""Tear down the connection for `session_id` and return a fresh slot."""
|
|
213
209
|
async with self._lock:
|
|
214
210
|
await self._release_unlocked(session_id)
|
|
215
211
|
|
{soothe_client_python-1.0.21 → soothe_client_python-1.0.23}/src/soothe_client/appkit/query_gate.py
RENAMED
|
@@ -35,7 +35,7 @@ class QueryGate:
|
|
|
35
35
|
cancel: Callable[[], None],
|
|
36
36
|
send_cancel: SendCancelFn | None = None,
|
|
37
37
|
) -> None:
|
|
38
|
-
"""Reserve
|
|
38
|
+
"""Reserve `session_id` for one agent turn.
|
|
39
39
|
|
|
40
40
|
Args:
|
|
41
41
|
session_id: Application session key (often the loop id).
|
|
@@ -43,7 +43,7 @@ class QueryGate:
|
|
|
43
43
|
send_cancel: Optional async daemon-cancel sender.
|
|
44
44
|
|
|
45
45
|
Raises:
|
|
46
|
-
ErrQueryBusy: A query is already in flight for
|
|
46
|
+
ErrQueryBusy: A query is already in flight for `session_id`.
|
|
47
47
|
"""
|
|
48
48
|
async with self._lock:
|
|
49
49
|
if session_id in self._cancels:
|
|
@@ -80,7 +80,7 @@ class QueryGate:
|
|
|
80
80
|
self._send_cancels.pop(session_id, None)
|
|
81
81
|
|
|
82
82
|
async def is_active(self, session_id: str) -> bool:
|
|
83
|
-
"""Return whether a query is in flight for
|
|
83
|
+
"""Return whether a query is in flight for `session_id`."""
|
|
84
84
|
async with self._lock:
|
|
85
85
|
return session_id in self._cancels
|
|
86
86
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"""Thinking-step extraction for appkit.
|
|
2
2
|
|
|
3
|
-
Maps an allowlisted progress event to one structured UI line.
|
|
4
|
-
streams (tokens, reports, reasoning) are excluded. Ported from Go/TS appkit.
|
|
3
|
+
Maps an allowlisted progress event to one structured UI line.
|
|
5
4
|
"""
|
|
6
5
|
|
|
7
6
|
from __future__ import annotations
|
|
@@ -40,7 +39,7 @@ def extract_thinking_step(
|
|
|
40
39
|
allow: Optional override of the default thinking-step allowlist.
|
|
41
40
|
|
|
42
41
|
Returns:
|
|
43
|
-
|
|
42
|
+
`(line, True)` for a recognized event; `("", False)` otherwise.
|
|
44
43
|
"""
|
|
45
44
|
if not event_type or data is None:
|
|
46
45
|
return "", False
|