soothe-client-python 0.9.7__tar.gz → 0.9.9__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.9}/PKG-INFO +1 -1
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/daemon_session.py +86 -10
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/stream_terminal.py +41 -1
- soothe_client_python-0.9.9/src/soothe_client/turn_boundary.py +68 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/.gitignore +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/LICENSE +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/README.md +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/pyproject.toml +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/__init__.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/__init__.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/attachments.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/broadcaster.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/chunk_filter.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/classifier.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/events.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/managed_client.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/observability.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/pool.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/query_gate.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/session_store.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/thinking_step.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/turn.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/turn_runner.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/errors.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/helpers.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/intent_hints.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/protocol_params.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/py.typed +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/schemas.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/session.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/websocket.py +0 -0
- {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/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.9
|
|
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.9}/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, parse_turn_generation
|
|
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,33 @@ 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
|
+
# Drop mismatched turn_id once bound — except status=running,
|
|
396
|
+
# which may rebind to a newer generation (pre-admit early
|
|
397
|
+
# running can briefly carry a stale or empty turn_id).
|
|
398
|
+
ev_turn_id = frame_turn_id(event)
|
|
399
|
+
is_running_status = (
|
|
400
|
+
event_type == "status" and str(event.get("state") or "") == "running"
|
|
401
|
+
)
|
|
402
|
+
if (
|
|
403
|
+
expected_turn_id
|
|
404
|
+
and ev_turn_id
|
|
405
|
+
and ev_turn_id != expected_turn_id
|
|
406
|
+
and event_type in {"event", "status"}
|
|
407
|
+
and not is_running_status
|
|
408
|
+
):
|
|
409
|
+
logger.debug(
|
|
410
|
+
"Ignoring mismatched turn_id frame type=%s got=%s expected=%s",
|
|
411
|
+
event_type,
|
|
412
|
+
ev_turn_id[-24:],
|
|
413
|
+
expected_turn_id[-24:],
|
|
414
|
+
)
|
|
415
|
+
continue
|
|
416
|
+
|
|
375
417
|
if event_type == "error":
|
|
376
418
|
err_obj = event.get("error") or {}
|
|
377
419
|
err_msg = str(
|
|
@@ -388,8 +430,24 @@ class DaemonSession:
|
|
|
388
430
|
if state == "running":
|
|
389
431
|
query_started = True
|
|
390
432
|
progress_seen = True
|
|
433
|
+
status_turn = frame_turn_id(event)
|
|
434
|
+
if status_turn:
|
|
435
|
+
new_gen = parse_turn_generation(status_turn)
|
|
436
|
+
old_gen = parse_turn_generation(expected_turn_id)
|
|
437
|
+
if expected_turn_id is None or (
|
|
438
|
+
new_gen is not None and (old_gen is None or new_gen >= old_gen)
|
|
439
|
+
):
|
|
440
|
+
if expected_turn_id and status_turn != expected_turn_id:
|
|
441
|
+
# Successor turn admitted while this reader
|
|
442
|
+
# still held a stale/early binding.
|
|
443
|
+
stream_payload_seen = False
|
|
444
|
+
turn_progress_seen = False
|
|
445
|
+
expected_turn_id = status_turn
|
|
446
|
+
self._expected_turn_id = status_turn
|
|
391
447
|
elif query_started and state == "stopped":
|
|
392
448
|
self.last_turn_end_state = state
|
|
449
|
+
if ev_seq is not None:
|
|
450
|
+
self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
|
|
393
451
|
async for chunk in self._drain_stream_events_after_idle(
|
|
394
452
|
expected_loop_id=expected_loop_id,
|
|
395
453
|
):
|
|
@@ -399,6 +457,8 @@ class DaemonSession:
|
|
|
399
457
|
if not stream_payload_seen and not self.last_turn_cancellation_seen:
|
|
400
458
|
continue
|
|
401
459
|
self.last_turn_end_state = state
|
|
460
|
+
if ev_seq is not None:
|
|
461
|
+
self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
|
|
402
462
|
async for chunk in self._drain_stream_events_after_idle(
|
|
403
463
|
expected_loop_id=expected_loop_id,
|
|
404
464
|
):
|
|
@@ -422,24 +482,40 @@ class DaemonSession:
|
|
|
422
482
|
self.turn_event_stats.filtered_early += 1
|
|
423
483
|
continue
|
|
424
484
|
|
|
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],
|
|
485
|
+
# Prefer turn_id match when present; else IG-658 progress gate.
|
|
486
|
+
if mode == "custom" and is_turn_end_custom_data(data):
|
|
487
|
+
data_turn = (
|
|
488
|
+
frame_turn_id(data if isinstance(data, dict) else None) or ev_turn_id
|
|
432
489
|
)
|
|
433
|
-
|
|
490
|
+
turn_ok = (
|
|
491
|
+
not expected_turn_id or not data_turn or data_turn == expected_turn_id
|
|
492
|
+
)
|
|
493
|
+
if not turn_ok or not query_started or not turn_progress_seen:
|
|
494
|
+
logger.debug(
|
|
495
|
+
"Ignoring premature turn-end frame %s "
|
|
496
|
+
"(loop=%s query_started=%s progress=%s turn_ok=%s)",
|
|
497
|
+
str(data.get("type", "")).strip()
|
|
498
|
+
if isinstance(data, dict)
|
|
499
|
+
else "?",
|
|
500
|
+
(expected_loop_id or "?")[:16],
|
|
501
|
+
query_started,
|
|
502
|
+
turn_progress_seen,
|
|
503
|
+
turn_ok,
|
|
504
|
+
)
|
|
505
|
+
continue
|
|
434
506
|
|
|
435
507
|
progress_seen = True
|
|
436
508
|
stream_payload_seen = True
|
|
509
|
+
if is_turn_progress_chunk(mode, data):
|
|
510
|
+
turn_progress_seen = True
|
|
437
511
|
yield (namespace, mode, data)
|
|
438
512
|
if mode == "custom" and is_turn_end_custom_data(data):
|
|
439
513
|
custom_type = str(data.get("type", "")).strip()
|
|
440
514
|
self.last_turn_end_state = (
|
|
441
515
|
"stream_end" if custom_type == STREAM_END else "completed"
|
|
442
516
|
)
|
|
517
|
+
if ev_seq is not None:
|
|
518
|
+
self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
|
|
443
519
|
async for chunk in self._drain_stream_events_after_idle(
|
|
444
520
|
expected_loop_id=expected_loop_id,
|
|
445
521
|
):
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/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,68 @@
|
|
|
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 parse_turn_generation(turn_id: str | None) -> int | None:
|
|
22
|
+
"""Extract generation int from ``turn_id``, or None if malformed."""
|
|
23
|
+
raw = str(turn_id or "").strip()
|
|
24
|
+
if not raw or ":" not in raw:
|
|
25
|
+
return None
|
|
26
|
+
suffix = raw.rsplit(":", 1)[-1]
|
|
27
|
+
try:
|
|
28
|
+
gen = int(suffix)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return None
|
|
31
|
+
return gen if gen > 0 else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def frame_turn_id(frame: dict[str, Any] | None) -> str | None:
|
|
35
|
+
"""Return ``turn_id`` from a status/event frame or nested custom data."""
|
|
36
|
+
if not isinstance(frame, dict):
|
|
37
|
+
return None
|
|
38
|
+
tid = frame.get("turn_id")
|
|
39
|
+
if isinstance(tid, str) and tid.strip():
|
|
40
|
+
return tid.strip()
|
|
41
|
+
data = frame.get("data")
|
|
42
|
+
if isinstance(data, dict):
|
|
43
|
+
inner = data.get("turn_id")
|
|
44
|
+
if isinstance(inner, str) and inner.strip():
|
|
45
|
+
return inner.strip()
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def frame_seq(frame: dict[str, Any] | None) -> int | None:
|
|
50
|
+
"""Return non-negative ``seq`` from a wire frame, or None."""
|
|
51
|
+
if not isinstance(frame, dict):
|
|
52
|
+
return None
|
|
53
|
+
raw = frame.get("seq")
|
|
54
|
+
if isinstance(raw, bool):
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(raw, int) and raw >= 0:
|
|
57
|
+
return raw
|
|
58
|
+
if isinstance(raw, float) and raw >= 0 and raw == int(raw):
|
|
59
|
+
return int(raw)
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"format_turn_id",
|
|
65
|
+
"frame_seq",
|
|
66
|
+
"frame_turn_id",
|
|
67
|
+
"parse_turn_generation",
|
|
68
|
+
]
|
|
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.9}/src/soothe_client/appkit/__init__.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/attachments.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/broadcaster.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/chunk_filter.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/classifier.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/events.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/managed_client.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/observability.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/query_gate.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/session_store.py
RENAMED
|
File without changes
|
{soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/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.9}/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.9}/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.9}/src/soothe_client/ws_command_client.py
RENAMED
|
File without changes
|