soothe-client-python 0.9.7__tar.gz → 0.9.8__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-0.9.7 → soothe_client_python-0.9.8}/PKG-INFO +1 -1
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/daemon_session.py +70 -10
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/stream_terminal.py +41 -1
- soothe_client_python-0.9.8/src/soothe_client/turn_boundary.py +54 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/.gitignore +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/LICENSE +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/README.md +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/pyproject.toml +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/__init__.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/__init__.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/attachments.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/broadcaster.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/chunk_filter.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/classifier.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/events.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/managed_client.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/observability.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/pool.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/query_gate.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/session_store.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/thinking_step.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/turn.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/turn_runner.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/errors.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/helpers.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/intent_hints.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/protocol_params.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/py.typed +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/schemas.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/session.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/websocket.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/ws_command_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: soothe-client-python
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.8
|
|
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
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/daemon_session.py
RENAMED
|
@@ -18,7 +18,12 @@ from soothe_client.appkit.chunk_filter import should_drop_stream_chunk_early
|
|
|
18
18
|
from soothe_client.appkit.events import unwrap_next
|
|
19
19
|
from soothe_client.appkit.observability import TurnEventStats
|
|
20
20
|
from soothe_client.session import bootstrap_loop_session, connect_websocket_with_retries
|
|
21
|
-
from soothe_client.stream_terminal import
|
|
21
|
+
from soothe_client.stream_terminal import (
|
|
22
|
+
STREAM_END,
|
|
23
|
+
is_turn_end_custom_data,
|
|
24
|
+
is_turn_progress_chunk,
|
|
25
|
+
)
|
|
26
|
+
from soothe_client.turn_boundary import frame_seq, frame_turn_id
|
|
22
27
|
from soothe_client.websocket import WebSocketClient
|
|
23
28
|
|
|
24
29
|
logger = logging.getLogger(__name__)
|
|
@@ -62,6 +67,9 @@ class DaemonSession:
|
|
|
62
67
|
self.last_turn_end_state: str | None = None
|
|
63
68
|
self.last_turn_cancellation_seen: bool = False
|
|
64
69
|
self.last_turn_error_message: str | None = None
|
|
70
|
+
# IG-659: last completed turn's seq floor (drop stale prior-turn frames).
|
|
71
|
+
self._last_turn_end_seq: int = 0
|
|
72
|
+
self._expected_turn_id: str | None = None
|
|
65
73
|
|
|
66
74
|
@property
|
|
67
75
|
def client(self) -> WebSocketClient:
|
|
@@ -310,7 +318,13 @@ class DaemonSession:
|
|
|
310
318
|
inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
|
|
311
319
|
query_started = False
|
|
312
320
|
expected_loop_id = self._loop_id
|
|
321
|
+
# Bind turn_id only after this turn's status=running (do not reuse prior).
|
|
322
|
+
self._expected_turn_id = None
|
|
323
|
+
expected_turn_id: str | None = None
|
|
313
324
|
stream_payload_seen = False
|
|
325
|
+
turn_progress_seen = False
|
|
326
|
+
last_end_seq = int(getattr(self, "_last_turn_end_seq", 0) or 0)
|
|
327
|
+
self._last_turn_end_seq = last_end_seq
|
|
314
328
|
self._streaming = True
|
|
315
329
|
turn_read_started = time.monotonic()
|
|
316
330
|
first_event_logged = False
|
|
@@ -348,9 +362,10 @@ class DaemonSession:
|
|
|
348
362
|
if event and not first_event_logged:
|
|
349
363
|
first_event_logged = True
|
|
350
364
|
logger.debug(
|
|
351
|
-
"First daemon event on turn: type=%s loop_id=%s",
|
|
365
|
+
"First daemon event on turn: type=%s loop_id=%s turn_id=%s",
|
|
352
366
|
event.get("type"),
|
|
353
367
|
event.get("loop_id"),
|
|
368
|
+
frame_turn_id(event),
|
|
354
369
|
)
|
|
355
370
|
if not event:
|
|
356
371
|
if query_started and not self._client.is_connection_alive():
|
|
@@ -372,6 +387,27 @@ class DaemonSession:
|
|
|
372
387
|
):
|
|
373
388
|
continue
|
|
374
389
|
|
|
390
|
+
# IG-659 phase 5: drop frames at or before prior turn end seq.
|
|
391
|
+
ev_seq = frame_seq(event)
|
|
392
|
+
if ev_seq is not None and last_end_seq > 0 and ev_seq <= last_end_seq:
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
# IG-659 phases 1–2: drop mismatched turn_id once bound.
|
|
396
|
+
ev_turn_id = frame_turn_id(event)
|
|
397
|
+
if (
|
|
398
|
+
expected_turn_id
|
|
399
|
+
and ev_turn_id
|
|
400
|
+
and ev_turn_id != expected_turn_id
|
|
401
|
+
and event_type in {"event", "status"}
|
|
402
|
+
):
|
|
403
|
+
logger.debug(
|
|
404
|
+
"Ignoring mismatched turn_id frame type=%s got=%s expected=%s",
|
|
405
|
+
event_type,
|
|
406
|
+
ev_turn_id[-24:],
|
|
407
|
+
expected_turn_id[-24:],
|
|
408
|
+
)
|
|
409
|
+
continue
|
|
410
|
+
|
|
375
411
|
if event_type == "error":
|
|
376
412
|
err_obj = event.get("error") or {}
|
|
377
413
|
err_msg = str(
|
|
@@ -388,8 +424,14 @@ class DaemonSession:
|
|
|
388
424
|
if state == "running":
|
|
389
425
|
query_started = True
|
|
390
426
|
progress_seen = True
|
|
427
|
+
status_turn = frame_turn_id(event)
|
|
428
|
+
if status_turn:
|
|
429
|
+
expected_turn_id = status_turn
|
|
430
|
+
self._expected_turn_id = status_turn
|
|
391
431
|
elif query_started and state == "stopped":
|
|
392
432
|
self.last_turn_end_state = state
|
|
433
|
+
if ev_seq is not None:
|
|
434
|
+
self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
|
|
393
435
|
async for chunk in self._drain_stream_events_after_idle(
|
|
394
436
|
expected_loop_id=expected_loop_id,
|
|
395
437
|
):
|
|
@@ -399,6 +441,8 @@ class DaemonSession:
|
|
|
399
441
|
if not stream_payload_seen and not self.last_turn_cancellation_seen:
|
|
400
442
|
continue
|
|
401
443
|
self.last_turn_end_state = state
|
|
444
|
+
if ev_seq is not None:
|
|
445
|
+
self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
|
|
402
446
|
async for chunk in self._drain_stream_events_after_idle(
|
|
403
447
|
expected_loop_id=expected_loop_id,
|
|
404
448
|
):
|
|
@@ -422,24 +466,40 @@ class DaemonSession:
|
|
|
422
466
|
self.turn_event_stats.filtered_early += 1
|
|
423
467
|
continue
|
|
424
468
|
|
|
425
|
-
#
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
"Ignoring pre-start turn-end frame %s (loop=%s)",
|
|
430
|
-
str(data.get("type", "")).strip(),
|
|
431
|
-
(expected_loop_id or "?")[:16],
|
|
469
|
+
# Prefer turn_id match when present; else IG-658 progress gate.
|
|
470
|
+
if mode == "custom" and is_turn_end_custom_data(data):
|
|
471
|
+
data_turn = (
|
|
472
|
+
frame_turn_id(data if isinstance(data, dict) else None) or ev_turn_id
|
|
432
473
|
)
|
|
433
|
-
|
|
474
|
+
turn_ok = (
|
|
475
|
+
not expected_turn_id or not data_turn or data_turn == expected_turn_id
|
|
476
|
+
)
|
|
477
|
+
if not turn_ok or not query_started or not turn_progress_seen:
|
|
478
|
+
logger.debug(
|
|
479
|
+
"Ignoring premature turn-end frame %s "
|
|
480
|
+
"(loop=%s query_started=%s progress=%s turn_ok=%s)",
|
|
481
|
+
str(data.get("type", "")).strip()
|
|
482
|
+
if isinstance(data, dict)
|
|
483
|
+
else "?",
|
|
484
|
+
(expected_loop_id or "?")[:16],
|
|
485
|
+
query_started,
|
|
486
|
+
turn_progress_seen,
|
|
487
|
+
turn_ok,
|
|
488
|
+
)
|
|
489
|
+
continue
|
|
434
490
|
|
|
435
491
|
progress_seen = True
|
|
436
492
|
stream_payload_seen = True
|
|
493
|
+
if is_turn_progress_chunk(mode, data):
|
|
494
|
+
turn_progress_seen = True
|
|
437
495
|
yield (namespace, mode, data)
|
|
438
496
|
if mode == "custom" and is_turn_end_custom_data(data):
|
|
439
497
|
custom_type = str(data.get("type", "")).strip()
|
|
440
498
|
self.last_turn_end_state = (
|
|
441
499
|
"stream_end" if custom_type == STREAM_END else "completed"
|
|
442
500
|
)
|
|
501
|
+
if ev_seq is not None:
|
|
502
|
+
self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
|
|
443
503
|
async for chunk in self._drain_stream_events_after_idle(
|
|
444
504
|
expected_loop_id=expected_loop_id,
|
|
445
505
|
):
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/stream_terminal.py
RENAMED
|
@@ -8,10 +8,29 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from typing import Any, TypeGuard
|
|
10
10
|
|
|
11
|
-
from soothe_sdk.core.events import
|
|
11
|
+
from soothe_sdk.core.events import (
|
|
12
|
+
PLAN_CREATED,
|
|
13
|
+
STRANGE_LOOP_COMPLETED,
|
|
14
|
+
STRANGE_LOOP_STEP_COMPLETED,
|
|
15
|
+
STRANGE_LOOP_STEP_QUEUED,
|
|
16
|
+
STRANGE_LOOP_STEP_STARTED,
|
|
17
|
+
STREAM_END,
|
|
18
|
+
)
|
|
12
19
|
|
|
13
20
|
TURN_END_CUSTOM_TYPES = frozenset({STREAM_END, STRANGE_LOOP_COMPLETED})
|
|
14
21
|
|
|
22
|
+
# Customs that prove this turn has real work (not mere intake plan.phase).
|
|
23
|
+
# Intake-only phases must not unlock turn-end — prior-goal stream.end can still
|
|
24
|
+
# arrive after status=running (loop 3e43).
|
|
25
|
+
_TURN_PROGRESS_CUSTOM_TYPES = frozenset(
|
|
26
|
+
{
|
|
27
|
+
PLAN_CREATED,
|
|
28
|
+
STRANGE_LOOP_STEP_STARTED,
|
|
29
|
+
STRANGE_LOOP_STEP_QUEUED,
|
|
30
|
+
STRANGE_LOOP_STEP_COMPLETED,
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
15
34
|
# Handshake / card-replay / subscription leftovers safe to drop at turn start.
|
|
16
35
|
STALE_TURN_PENDING_TYPES = frozenset(
|
|
17
36
|
{
|
|
@@ -37,6 +56,26 @@ def is_turn_end_custom_data(data: Any) -> TypeGuard[dict[str, Any]]:
|
|
|
37
56
|
return True
|
|
38
57
|
|
|
39
58
|
|
|
59
|
+
def is_turn_progress_chunk(mode: str, data: Any) -> bool:
|
|
60
|
+
"""True when a chunk proves the active turn has non-intake progress.
|
|
61
|
+
|
|
62
|
+
Used so late prior-goal ``stream.end`` cannot close a turn that has only
|
|
63
|
+
seen intake lifecycle (e.g. plan.phase "Interpreting goal").
|
|
64
|
+
"""
|
|
65
|
+
if mode in {"messages", "updates"}:
|
|
66
|
+
return True
|
|
67
|
+
if mode != "custom" or not isinstance(data, dict):
|
|
68
|
+
return False
|
|
69
|
+
if is_turn_end_custom_data(data):
|
|
70
|
+
return False
|
|
71
|
+
custom_type = str(data.get("type", "")).strip()
|
|
72
|
+
if custom_type in _TURN_PROGRESS_CUSTOM_TYPES:
|
|
73
|
+
return True
|
|
74
|
+
if custom_type.startswith("soothe.cognition.strange_loop.step"):
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
40
79
|
def stale_pending_frame_label(event: dict[str, Any]) -> str | None:
|
|
41
80
|
"""Return a peel label when ``event`` is safe to drop at turn start."""
|
|
42
81
|
event_type = str(event.get("type") or "")
|
|
@@ -69,5 +108,6 @@ __all__ = [
|
|
|
69
108
|
"STRANGE_LOOP_COMPLETED",
|
|
70
109
|
"TURN_END_CUSTOM_TYPES",
|
|
71
110
|
"is_turn_end_custom_data",
|
|
111
|
+
"is_turn_progress_chunk",
|
|
72
112
|
"stale_pending_frame_label",
|
|
73
113
|
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Turn / stream boundary helpers (IG-659).
|
|
2
|
+
|
|
3
|
+
``turn_id`` correlates a user turn across status, events, and terminals.
|
|
4
|
+
``seq`` is a daemon-assigned monotonic counter per loop for drop-stale filtering.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_turn_id(loop_id: str, generation: int) -> str:
|
|
13
|
+
"""Return wire ``turn_id`` for ``loop_id`` + admit generation."""
|
|
14
|
+
lid = str(loop_id or "").strip()
|
|
15
|
+
gen = int(generation)
|
|
16
|
+
if not lid or gen <= 0:
|
|
17
|
+
return ""
|
|
18
|
+
return f"{lid}:{gen}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def frame_turn_id(frame: dict[str, Any] | None) -> str | None:
|
|
22
|
+
"""Return ``turn_id`` from a status/event frame or nested custom data."""
|
|
23
|
+
if not isinstance(frame, dict):
|
|
24
|
+
return None
|
|
25
|
+
tid = frame.get("turn_id")
|
|
26
|
+
if isinstance(tid, str) and tid.strip():
|
|
27
|
+
return tid.strip()
|
|
28
|
+
data = frame.get("data")
|
|
29
|
+
if isinstance(data, dict):
|
|
30
|
+
inner = data.get("turn_id")
|
|
31
|
+
if isinstance(inner, str) and inner.strip():
|
|
32
|
+
return inner.strip()
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def frame_seq(frame: dict[str, Any] | None) -> int | None:
|
|
37
|
+
"""Return non-negative ``seq`` from a wire frame, or None."""
|
|
38
|
+
if not isinstance(frame, dict):
|
|
39
|
+
return None
|
|
40
|
+
raw = frame.get("seq")
|
|
41
|
+
if isinstance(raw, bool):
|
|
42
|
+
return None
|
|
43
|
+
if isinstance(raw, int) and raw >= 0:
|
|
44
|
+
return raw
|
|
45
|
+
if isinstance(raw, float) and raw >= 0 and raw == int(raw):
|
|
46
|
+
return int(raw)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"format_turn_id",
|
|
52
|
+
"frame_seq",
|
|
53
|
+
"frame_turn_id",
|
|
54
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/__init__.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/attachments.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/broadcaster.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/chunk_filter.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/classifier.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/events.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/managed_client.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/observability.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/query_gate.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/session_store.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/thinking_step.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/turn_runner.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/protocol_params.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/ws_command_client.py
RENAMED
|
File without changes
|