soothe-client-python 1.0.1__tar.gz → 1.0.2__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 (33) hide show
  1. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/PKG-INFO +1 -1
  2. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/__init__.py +14 -0
  3. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/classifier.py +5 -3
  4. soothe_client_python-1.0.2/src/soothe_client/appkit/turn_boundary.py +127 -0
  5. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/turn_runner.py +24 -5
  6. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/.gitignore +0 -0
  7. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/LICENSE +0 -0
  8. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/README.md +0 -0
  9. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/pyproject.toml +0 -0
  10. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/__init__.py +0 -0
  11. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/attachments.py +0 -0
  12. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/broadcaster.py +0 -0
  13. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/chunk_filter.py +0 -0
  14. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/daemon_session.py +0 -0
  15. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/events.py +0 -0
  16. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/managed_client.py +0 -0
  17. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/observability.py +0 -0
  18. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/pool.py +0 -0
  19. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/query_gate.py +0 -0
  20. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/session_store.py +0 -0
  21. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/thinking_step.py +0 -0
  22. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/appkit/turn.py +0 -0
  23. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/command_client.py +0 -0
  24. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/errors.py +0 -0
  25. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/helpers.py +0 -0
  26. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/intent_hints.py +0 -0
  27. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/protocol_params.py +0 -0
  28. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/py.typed +0 -0
  29. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/schemas.py +0 -0
  30. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/session.py +0 -0
  31. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/stream_terminal.py +0 -0
  32. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/turn_boundary.py +0 -0
  33. {soothe_client_python-1.0.1 → soothe_client_python-1.0.2}/src/soothe_client/websocket.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-client-python
3
- Version: 1.0.1
3
+ Version: 1.0.2
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
@@ -42,6 +42,14 @@ from soothe_client.appkit.thinking_step import (
42
42
  DEFAULT_THINKING_STEP_EVENTS,
43
43
  extract_thinking_step,
44
44
  )
45
+ from soothe_client.appkit.turn_boundary import (
46
+ TURN_END_IDLE,
47
+ TURN_END_STOPPED,
48
+ TURN_END_STREAM_END,
49
+ TurnBoundary,
50
+ TurnLifecycleGate,
51
+ is_daemon_turn_end_event,
52
+ )
45
53
  from soothe_client.appkit.turn_runner import (
46
54
  STREAM_CLOSE_FAIL,
47
55
  STREAM_CLOSE_SOFT_COMPLETE,
@@ -88,10 +96,16 @@ __all__ = [
88
96
  "SessionMessage",
89
97
  "SessionStore",
90
98
  "StreamClosePolicy",
99
+ "TURN_END_IDLE",
100
+ "TURN_END_STOPPED",
101
+ "TURN_END_STREAM_END",
91
102
  "TimeoutPolicy",
103
+ "TurnBoundary",
92
104
  "TurnConfig",
105
+ "TurnLifecycleGate",
93
106
  "TurnRunner",
94
107
  "compact_attachments",
108
+ "is_daemon_turn_end_event",
95
109
  "compact_image_attachment",
96
110
  "default_pool_config",
97
111
  "extract_thinking_step",
@@ -49,9 +49,9 @@ class ClassifierConfig:
49
49
  persisted as final (avoids finishing on stub ACKs). 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: When true, a status frame with
53
- ``state==idle`` and non-empty accumulated assistant text completes
54
- the turn (typical for intent-hint turns). Default false.
52
+ treat_status_idle_as_complete: Standalone ``classify`` only. Prefer
53
+ ``TurnRunner`` + ``TurnBoundary`` for turn end (DaemonSession
54
+ contract). Default false.
55
55
  """
56
56
 
57
57
  deliverable_phases: frozenset[str] | set[str]
@@ -85,6 +85,8 @@ class EventClassifier:
85
85
  return False
86
86
  if event_type in (
87
87
  "status.idle",
88
+ "status.stopped",
89
+ "soothe.stream.end",
88
90
  "idle_timeout",
89
91
  "query_timeout",
90
92
  "stream_closed",
@@ -0,0 +1,127 @@
1
+ """DaemonSession turn-end contract for the pool TurnRunner path.
2
+
3
+ TurnRunner owns one ``TurnBoundary`` per Execute. EventClassifier may
4
+ early-complete on deliverable phases for UX; it is not the sole terminator.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from soothe_client.stream_terminal import (
13
+ STREAM_END,
14
+ is_turn_end_custom_data,
15
+ is_turn_progress_chunk,
16
+ )
17
+
18
+ TURN_END_STREAM_END = STREAM_END
19
+ TURN_END_IDLE = "status.idle"
20
+ TURN_END_STOPPED = "status.stopped"
21
+
22
+
23
+ @dataclass
24
+ class TurnLifecycleGate:
25
+ """Per-turn progress flags (DaemonSession parity; not shared across chats)."""
26
+
27
+ saw_running: bool = False
28
+ saw_stream_payload: bool = False
29
+ saw_turn_progress: bool = False
30
+
31
+ def observe(self, msg: Any) -> None:
32
+ frame = _normalize_frame(msg)
33
+ if frame is None:
34
+ return
35
+ typ = str(frame.get("type") or "")
36
+ if typ == "status":
37
+ if str(frame.get("state") or "").strip().lower() == "running":
38
+ self.saw_running = True
39
+ return
40
+ if typ == "event":
41
+ self.saw_stream_payload = True
42
+ mode = str(frame.get("mode") or "")
43
+ if is_turn_progress_chunk(mode, frame.get("data")):
44
+ self.saw_turn_progress = True
45
+
46
+ def allow_stream_end(self) -> bool:
47
+ return self.saw_running and self.saw_turn_progress
48
+
49
+ def allow_idle_complete(self) -> bool:
50
+ return self.saw_running and self.saw_stream_payload
51
+
52
+
53
+ @dataclass
54
+ class TurnBoundary:
55
+ """Applies DaemonSession end rules to pool decoded frames."""
56
+
57
+ gate: TurnLifecycleGate = field(default_factory=TurnLifecycleGate)
58
+ ended: bool = False
59
+ reason: str = ""
60
+
61
+ def feed(self, msg: Any) -> tuple[bool, str]:
62
+ if self.ended:
63
+ return True, self.reason
64
+ self.gate.observe(msg)
65
+ frame = _normalize_frame(msg)
66
+ if frame is None:
67
+ return False, ""
68
+
69
+ typ = str(frame.get("type") or "")
70
+ if typ == "status":
71
+ state = str(frame.get("state") or "").strip().lower()
72
+ if state == "stopped" and self.gate.saw_running:
73
+ return self._mark(TURN_END_STOPPED)
74
+ if state == "idle" and self.gate.allow_idle_complete():
75
+ return self._mark(TURN_END_IDLE)
76
+ return False, ""
77
+
78
+ if typ == "event":
79
+ mode = str(frame.get("mode") or "")
80
+ data = frame.get("data")
81
+ if mode == "custom" and is_turn_end_custom_data(data) and self.gate.allow_stream_end():
82
+ return self._mark(TURN_END_STREAM_END)
83
+ return False, ""
84
+
85
+ def _mark(self, reason: str) -> tuple[bool, str]:
86
+ self.ended = True
87
+ self.reason = reason
88
+ return True, reason
89
+
90
+
91
+ def is_daemon_turn_end_event(completion_event: str) -> bool:
92
+ """True for TurnBoundary completion_event values (not phase deliverables)."""
93
+ return (completion_event or "").strip() in {
94
+ TURN_END_STREAM_END,
95
+ TURN_END_IDLE,
96
+ TURN_END_STOPPED,
97
+ }
98
+
99
+
100
+ def _normalize_frame(msg: Any) -> dict[str, Any] | None:
101
+ if not isinstance(msg, dict):
102
+ return None
103
+ typ = msg.get("type")
104
+ if typ == "next":
105
+ payload = msg.get("payload")
106
+ if not isinstance(payload, dict):
107
+ return None
108
+ inner = payload.get("data")
109
+ if isinstance(inner, dict) and inner.get("type") == "status":
110
+ return inner
111
+ if isinstance(inner, dict) and inner.get("mode"):
112
+ return {
113
+ "type": "event",
114
+ "mode": inner.get("mode"),
115
+ "data": inner.get("data"),
116
+ "namespace": inner.get("namespace") or payload.get("namespace"),
117
+ }
118
+ mode = payload.get("mode")
119
+ if mode:
120
+ return {
121
+ "type": "event",
122
+ "mode": mode,
123
+ "data": payload.get("data"),
124
+ "namespace": payload.get("namespace"),
125
+ }
126
+ return None
127
+ return msg
@@ -1,11 +1,11 @@
1
1
  """Turn runner for appkit.
2
2
 
3
3
  Executes one query turn end-to-end: acquire a pooled connection, enforce
4
- single-flight, send loop_input, consume the event stream, classify events,
5
- resolve the deliverable, persist the reply, and broadcast completion.
4
+ single-flight, send loop_input, consume the event stream, persist/broadcast.
6
5
 
7
- Supports absolute query timeout, optional idle silence watchdog, soft-complete
8
- policies, and optional attachment compaction before send.
6
+ Turn end is owned by ``TurnBoundary`` (DaemonSession.iter_turn_chunks contract:
7
+ gated ``stream.end`` / idle / stopped). ``EventClassifier`` selects user-visible
8
+ text and may early-complete on deliverable phases for UX.
9
9
  """
10
10
 
11
11
  from __future__ import annotations
@@ -24,6 +24,7 @@ from soothe_client.appkit.classifier import ChatEventTerminal, EventClassifier
24
24
  from soothe_client.appkit.pool import ConnectionPool, PooledConn
25
25
  from soothe_client.appkit.query_gate import QueryGate
26
26
  from soothe_client.appkit.session_store import SessionMessage, SessionStore
27
+ from soothe_client.appkit.turn_boundary import TurnBoundary, is_daemon_turn_end_event
27
28
  from soothe_client.intent_hints import validate_loop_input_intent_hint
28
29
 
29
30
  Attachment = dict[str, Any]
@@ -261,6 +262,7 @@ class TurnRunner:
261
262
 
262
263
  assistant_content = ""
263
264
  started_at = time.time()
265
+ boundary = TurnBoundary()
264
266
  agen = event_stream.__aiter__()
265
267
  arm_idle()
266
268
 
@@ -378,6 +380,7 @@ class TurnRunner:
378
380
 
379
381
  arm_idle()
380
382
 
383
+ ended, end_reason = boundary.feed(msg)
381
384
  event_result = self._classifier.classify(msg, assistant_content)
382
385
  if (
383
386
  event_result.err is not None
@@ -404,7 +407,9 @@ class TurnRunner:
404
407
  event_result,
405
408
  assistant_content,
406
409
  )
407
- if deliverable:
410
+ if deliverable and not is_daemon_turn_end_event(
411
+ event_result.completion_event or ""
412
+ ):
408
413
  await self._complete_turn(
409
414
  session_id,
410
415
  loop_id,
@@ -413,6 +418,20 @@ class TurnRunner:
413
418
  event_result.completion_event or "",
414
419
  )
415
420
  return
421
+
422
+ if ended:
423
+ if self._classifier.is_substantive_assistant_reply(assistant_content):
424
+ await self._complete_turn(
425
+ session_id,
426
+ loop_id,
427
+ assistant_content.strip(),
428
+ started_at,
429
+ end_reason,
430
+ )
431
+ return
432
+ empty = RuntimeError(f"turn ended ({end_reason}) with no assistant content")
433
+ await self._fail_turn(session_id, loop_id, empty)
434
+ raise empty
416
435
  finally:
417
436
  query_timer.cancel()
418
437
  with contextlib.suppress(asyncio.CancelledError):