soothe-client-python 1.0.0__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.0 → soothe_client_python-1.0.2}/PKG-INFO +1 -1
  2. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/__init__.py +14 -0
  3. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/classifier.py +9 -9
  4. soothe_client_python-1.0.2/src/soothe_client/appkit/turn_boundary.py +127 -0
  5. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/turn_runner.py +24 -5
  6. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/intent_hints.py +6 -3
  7. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/protocol_params.py +2 -2
  8. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/websocket.py +3 -2
  9. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/.gitignore +0 -0
  10. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/LICENSE +0 -0
  11. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/README.md +0 -0
  12. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/pyproject.toml +0 -0
  13. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/__init__.py +0 -0
  14. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/attachments.py +0 -0
  15. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/broadcaster.py +0 -0
  16. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/chunk_filter.py +0 -0
  17. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/daemon_session.py +0 -0
  18. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/events.py +0 -0
  19. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/managed_client.py +0 -0
  20. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/observability.py +0 -0
  21. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/pool.py +0 -0
  22. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/query_gate.py +0 -0
  23. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/session_store.py +0 -0
  24. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/thinking_step.py +0 -0
  25. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/appkit/turn.py +0 -0
  26. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/command_client.py +0 -0
  27. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/errors.py +0 -0
  28. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/helpers.py +0 -0
  29. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/py.typed +0 -0
  30. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/schemas.py +0 -0
  31. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/session.py +0 -0
  32. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/stream_terminal.py +0 -0
  33. {soothe_client_python-1.0.0 → soothe_client_python-1.0.2}/src/soothe_client/turn_boundary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-client-python
3
- Version: 1.0.0
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 direct-model 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",
@@ -314,12 +316,10 @@ class EventClassifier:
314
316
  )
315
317
  return self._continue_result(content)
316
318
 
317
- direct_content, direct_ok = self._messages_mode_assistant_content(data)
318
- if direct_ok and self.is_substantive_assistant_reply(direct_content):
319
- return self._deliverable_result(
320
- direct_content,
321
- "soothe.protocol.message.direct_model",
322
- )
319
+ # Unphased terminal AI text is streamable narration only.
320
+ unphased_content, unphased_ok = self._messages_mode_assistant_content(data)
321
+ if unphased_ok:
322
+ return self._continue_result(unphased_content)
323
323
 
324
324
  if has_payload and raw_content:
325
325
  if _is_terminal_message_type(msg_type) or msg_type == "":
@@ -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):
@@ -9,14 +9,14 @@ IMAGE_TO_TEXT: Final = "image_to_text"
9
9
  OCR: Final = "ocr"
10
10
  EMBED: Final = "embed"
11
11
 
12
- REMOVED_INTENT_HINTS: frozenset[str] = frozenset({"direct_llm", "quiz"})
12
+ REMOVED_INTENT_HINTS: frozenset[str] = frozenset({"direct_llm", "quiz", "direct_model"})
13
13
 
14
14
  # Default deliverable phases for turn-ending replies (excludes plan_direct narration).
15
15
  DEFAULT_DELIVERABLE_PHASES: frozenset[str] = frozenset(
16
16
  {
17
17
  "quiz",
18
18
  "goal_completion",
19
- "direct_model",
19
+ "chitchat",
20
20
  "text_completion",
21
21
  "image_to_text",
22
22
  "ocr",
@@ -30,13 +30,16 @@ _REMOVED_INTENT_HINT_MESSAGES: dict[str, str] = {
30
30
  "use text_completion (text-only) or image_to_text (with attachments)"
31
31
  ),
32
32
  "quiz": "intent_hint quiz is removed; omit intent_hint and let intake classify the turn",
33
+ "direct_model": (
34
+ "intent_hint direct_model is removed; use text_completion, image_to_text, ocr, or embed"
35
+ ),
33
36
  }
34
37
 
35
38
 
36
39
  def validate_loop_input_intent_hint(hint: str) -> str | None:
37
40
  """Return an error message when ``hint`` is a removed legacy value.
38
41
 
39
- Direct model hints and agent-path pass-through values (e.g.
42
+ Intent-hint values and agent-path pass-through values (e.g.
40
43
  ``resume_clarification``, ``skill:foo``) are allowed.
41
44
  """
42
45
  key = hint.strip().lower()
@@ -200,7 +200,7 @@ class LoopInputParams(ParamsBase):
200
200
  model_params: Additional model parameters.
201
201
  router_profile: Named ``router_profiles`` overlay for chat roles this turn.
202
202
  attachments: Image attachments.
203
- intent_hint: Daemon direct-model hint (text_completion, image_to_text, ocr, embed).
203
+ intent_hint: Daemon intent_hint (text_completion, image_to_text, ocr, embed).
204
204
  response_schema: Structured output schema.
205
205
  response_schema_name: Schema name for logging.
206
206
  response_schema_strict: Enable strict schema validation.
@@ -415,7 +415,7 @@ class JobCreateParams(ParamsBase):
415
415
  autonomous: Enable autonomous mode.
416
416
  max_iterations: Max iterations.
417
417
  guidance: Initial guidance.
418
- intent_hint: Daemon direct-model hint (text_completion, image_to_text, ocr, embed).
418
+ intent_hint: Daemon intent_hint (text_completion, image_to_text, ocr, embed).
419
419
  """
420
420
 
421
421
  goal: str = Field(..., min_length=1, description="Root goal text")
@@ -966,7 +966,7 @@ class WebSocketClient:
966
966
  model_params: Additional model parameters.
967
967
  router_profile: Named router profile for chat-role overlay this turn.
968
968
  attachments: Image attachments (mime_type + base64 data).
969
- intent_hint: Daemon-only direct model hint. Supported values:
969
+ intent_hint: Daemon-only intent_hint. Supported values:
970
970
  ``text_completion`` (``default`` role, text-only),
971
971
  ``image_to_text`` (``image`` role, attachments required),
972
972
  ``ocr`` (``ocr`` role, attachments required),
@@ -974,7 +974,8 @@ class WebSocketClient:
974
974
  ``response_schema`` is supported for ``text_completion`` and
975
975
  ``image_to_text``. Agent-path pass-through hints (e.g.
976
976
  ``resume_clarification``, ``skill:foo``) are forwarded unchanged.
977
- Legacy ``direct_llm`` and ``quiz`` are rejected before send.
977
+ Legacy ``direct_llm``, ``quiz``, and ``direct_model`` are rejected
978
+ before send.
978
979
 
979
980
  Raises:
980
981
  ValueError: When ``intent_hint`` is a removed legacy value.