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.
Files changed (32) hide show
  1. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/PKG-INFO +1 -1
  2. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/daemon_session.py +86 -10
  3. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/stream_terminal.py +41 -1
  4. soothe_client_python-0.9.9/src/soothe_client/turn_boundary.py +68 -0
  5. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/.gitignore +0 -0
  6. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/LICENSE +0 -0
  7. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/README.md +0 -0
  8. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/pyproject.toml +0 -0
  9. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/__init__.py +0 -0
  10. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/__init__.py +0 -0
  11. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/attachments.py +0 -0
  12. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/broadcaster.py +0 -0
  13. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/chunk_filter.py +0 -0
  14. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/classifier.py +0 -0
  15. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/events.py +0 -0
  16. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/managed_client.py +0 -0
  17. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/observability.py +0 -0
  18. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/pool.py +0 -0
  19. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/query_gate.py +0 -0
  20. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/session_store.py +0 -0
  21. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/thinking_step.py +0 -0
  22. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/turn.py +0 -0
  23. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/appkit/turn_runner.py +0 -0
  24. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/errors.py +0 -0
  25. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/helpers.py +0 -0
  26. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/intent_hints.py +0 -0
  27. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/protocol_params.py +0 -0
  28. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/py.typed +0 -0
  29. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/schemas.py +0 -0
  30. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/session.py +0 -0
  31. {soothe_client_python-0.9.7 → soothe_client_python-0.9.9}/src/soothe_client/websocket.py +0 -0
  32. {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.7
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
@@ -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, 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
- # 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],
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
- continue
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
  ):
@@ -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,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
+ ]