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.
Files changed (32) hide show
  1. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/PKG-INFO +1 -1
  2. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/daemon_session.py +70 -10
  3. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/stream_terminal.py +41 -1
  4. soothe_client_python-0.9.8/src/soothe_client/turn_boundary.py +54 -0
  5. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/.gitignore +0 -0
  6. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/LICENSE +0 -0
  7. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/README.md +0 -0
  8. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/pyproject.toml +0 -0
  9. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/__init__.py +0 -0
  10. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/__init__.py +0 -0
  11. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/attachments.py +0 -0
  12. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/broadcaster.py +0 -0
  13. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/chunk_filter.py +0 -0
  14. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/classifier.py +0 -0
  15. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/events.py +0 -0
  16. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/managed_client.py +0 -0
  17. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/observability.py +0 -0
  18. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/pool.py +0 -0
  19. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/query_gate.py +0 -0
  20. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/session_store.py +0 -0
  21. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/thinking_step.py +0 -0
  22. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/turn.py +0 -0
  23. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/appkit/turn_runner.py +0 -0
  24. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/errors.py +0 -0
  25. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/helpers.py +0 -0
  26. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/intent_hints.py +0 -0
  27. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/protocol_params.py +0 -0
  28. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/py.typed +0 -0
  29. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/schemas.py +0 -0
  30. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/session.py +0 -0
  31. {soothe_client_python-0.9.7 → soothe_client_python-0.9.8}/src/soothe_client/websocket.py +0 -0
  32. {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.7
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
@@ -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 STREAM_END, is_turn_end_custom_data
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
- # Prior-goal terminal frames can arrive before status=running
426
- # for the new turn; ignoring them prevents a blank second query.
427
- if mode == "custom" and is_turn_end_custom_data(data) and not query_started:
428
- logger.debug(
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
- continue
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
  ):
@@ -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 STRANGE_LOOP_COMPLETED, STREAM_END
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
+ ]