soothe-client-python 1.0.7__tar.gz → 1.0.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-1.0.7 → soothe_client_python-1.0.9}/PKG-INFO +1 -1
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/daemon_session.py +108 -22
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/turn_boundary.py +58 -10
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/stream_terminal.py +17 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/turn_boundary.py +57 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/.gitignore +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/LICENSE +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/README.md +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/pyproject.toml +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/__init__.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/__init__.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/attachments.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/broadcaster.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/card_projection.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/chunk_filter.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/classifier.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/events.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/loop_session_store.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/managed_client.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/observability.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/pool.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/query_gate.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/thinking_step.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/turn.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/turn_runner.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/command_client.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/errors.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/helpers.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/intent_hints.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/protocol_params.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/py.typed +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/schemas.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/session.py +0 -0
- {soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/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.
|
|
3
|
+
Version: 1.0.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-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/daemon_session.py
RENAMED
|
@@ -20,10 +20,18 @@ from soothe_client.appkit.observability import TurnEventStats
|
|
|
20
20
|
from soothe_client.session import bootstrap_loop_session, connect_websocket_with_retries
|
|
21
21
|
from soothe_client.stream_terminal import (
|
|
22
22
|
STREAM_END,
|
|
23
|
+
is_stream_end_cancel_reason,
|
|
23
24
|
is_turn_end_custom_data,
|
|
24
25
|
is_turn_progress_chunk,
|
|
25
26
|
)
|
|
26
|
-
from soothe_client.turn_boundary import
|
|
27
|
+
from soothe_client.turn_boundary import (
|
|
28
|
+
frame_seq,
|
|
29
|
+
frame_turn_id,
|
|
30
|
+
is_idle_terminal_allowed,
|
|
31
|
+
is_turn_terminal_allowed,
|
|
32
|
+
parse_turn_generation,
|
|
33
|
+
turn_ids_match,
|
|
34
|
+
)
|
|
27
35
|
from soothe_client.websocket import WebSocketClient
|
|
28
36
|
|
|
29
37
|
logger = logging.getLogger(__name__)
|
|
@@ -81,6 +89,7 @@ class DaemonSession:
|
|
|
81
89
|
self.last_turn_end_state: str | None = None
|
|
82
90
|
self.last_turn_cancellation_seen: bool = False
|
|
83
91
|
self.last_turn_error_message: str | None = None
|
|
92
|
+
self.last_turn_stream_end_reason: str | None = None
|
|
84
93
|
# Last completed turn's seq floor (drop stale prior-turn frames).
|
|
85
94
|
# Scoped to the current peer seq namespace: reset on reconnect / loop
|
|
86
95
|
# change — daemon seq is per-process and restarts after daemon restart.
|
|
@@ -359,13 +368,13 @@ class DaemonSession:
|
|
|
359
368
|
self.last_turn_end_state = None
|
|
360
369
|
self.last_turn_cancellation_seen = False
|
|
361
370
|
self.last_turn_error_message = None
|
|
371
|
+
self.last_turn_stream_end_reason = None
|
|
362
372
|
inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
|
|
363
373
|
query_started = False
|
|
364
374
|
expected_loop_id = self._loop_id
|
|
365
375
|
# Bind turn_id only after this turn's status=running (do not reuse prior).
|
|
366
376
|
self._expected_turn_id = None
|
|
367
377
|
expected_turn_id: str | None = None
|
|
368
|
-
stream_payload_seen = False
|
|
369
378
|
turn_progress_seen = False
|
|
370
379
|
last_end_seq = int(getattr(self, "_last_turn_end_seq", 0) or 0)
|
|
371
380
|
self._last_turn_end_seq = last_end_seq
|
|
@@ -436,23 +445,40 @@ class DaemonSession:
|
|
|
436
445
|
if ev_seq is not None and last_end_seq > 0 and ev_seq <= last_end_seq:
|
|
437
446
|
continue
|
|
438
447
|
|
|
439
|
-
#
|
|
440
|
-
# which may rebind to a newer
|
|
441
|
-
# running can
|
|
448
|
+
# Once bound, drop absent/mismatched turn_id on event/status
|
|
449
|
+
# (except status=running, which may rebind to a newer gen;
|
|
450
|
+
# pre-admit early running can omit turn_id). Idle/stopped
|
|
451
|
+
# fall through to their gates (cancel idle may omit turn_id).
|
|
442
452
|
ev_turn_id = frame_turn_id(event)
|
|
443
|
-
|
|
444
|
-
|
|
453
|
+
status_state = (
|
|
454
|
+
str(event.get("state") or "") if event_type == "status" else ""
|
|
445
455
|
)
|
|
456
|
+
is_running_status = status_state == "running"
|
|
457
|
+
is_terminal_status = status_state in {"idle", "stopped"}
|
|
446
458
|
if (
|
|
447
459
|
expected_turn_id
|
|
448
|
-
and ev_turn_id
|
|
449
|
-
and ev_turn_id != expected_turn_id
|
|
450
460
|
and event_type in {"event", "status"}
|
|
451
461
|
and not is_running_status
|
|
462
|
+
and not is_terminal_status
|
|
463
|
+
and not turn_ids_match(expected_turn_id, ev_turn_id)
|
|
452
464
|
):
|
|
453
465
|
logger.debug(
|
|
454
|
-
"Ignoring mismatched turn_id frame type=%s
|
|
466
|
+
"Ignoring unbound/mismatched turn_id frame type=%s "
|
|
467
|
+
"got=%s expected=%s",
|
|
455
468
|
event_type,
|
|
469
|
+
(ev_turn_id or "<absent>")[-24:],
|
|
470
|
+
expected_turn_id[-24:],
|
|
471
|
+
)
|
|
472
|
+
continue
|
|
473
|
+
if (
|
|
474
|
+
expected_turn_id
|
|
475
|
+
and is_terminal_status
|
|
476
|
+
and ev_turn_id
|
|
477
|
+
and not turn_ids_match(expected_turn_id, ev_turn_id)
|
|
478
|
+
):
|
|
479
|
+
logger.debug(
|
|
480
|
+
"Ignoring mismatched terminal status=%s got=%s expected=%s",
|
|
481
|
+
status_state,
|
|
456
482
|
ev_turn_id[-24:],
|
|
457
483
|
expected_turn_id[-24:],
|
|
458
484
|
)
|
|
@@ -475,6 +501,9 @@ class DaemonSession:
|
|
|
475
501
|
query_started = True
|
|
476
502
|
progress_seen = True
|
|
477
503
|
status_turn = frame_turn_id(event)
|
|
504
|
+
# Bind only from running frames that carry turn_id.
|
|
505
|
+
# Pre-admit early running omits turn_id and must not
|
|
506
|
+
# unlock terminals or poison the expected id.
|
|
478
507
|
if status_turn:
|
|
479
508
|
new_gen = parse_turn_generation(status_turn)
|
|
480
509
|
old_gen = parse_turn_generation(expected_turn_id)
|
|
@@ -484,11 +513,15 @@ class DaemonSession:
|
|
|
484
513
|
if expected_turn_id and status_turn != expected_turn_id:
|
|
485
514
|
# Successor turn admitted while this reader
|
|
486
515
|
# still held a stale/early binding.
|
|
487
|
-
stream_payload_seen = False
|
|
488
516
|
turn_progress_seen = False
|
|
489
517
|
expected_turn_id = status_turn
|
|
490
518
|
self._expected_turn_id = status_turn
|
|
491
519
|
elif query_started and state == "stopped":
|
|
520
|
+
stop_turn = frame_turn_id(event)
|
|
521
|
+
if expected_turn_id and not turn_ids_match(
|
|
522
|
+
expected_turn_id, stop_turn
|
|
523
|
+
):
|
|
524
|
+
continue
|
|
492
525
|
self.last_turn_end_state = state
|
|
493
526
|
if ev_seq is not None:
|
|
494
527
|
self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
|
|
@@ -498,7 +531,22 @@ class DaemonSession:
|
|
|
498
531
|
yield chunk
|
|
499
532
|
break
|
|
500
533
|
elif query_started and state == "idle":
|
|
501
|
-
|
|
534
|
+
idle_turn = frame_turn_id(event)
|
|
535
|
+
if not is_idle_terminal_allowed(
|
|
536
|
+
expected_turn_id=expected_turn_id,
|
|
537
|
+
frame_turn_id=idle_turn,
|
|
538
|
+
query_started=query_started,
|
|
539
|
+
turn_progress_seen=turn_progress_seen,
|
|
540
|
+
cancellation_seen=self.last_turn_cancellation_seen,
|
|
541
|
+
):
|
|
542
|
+
logger.debug(
|
|
543
|
+
"Ignoring premature idle "
|
|
544
|
+
"(loop=%s bound=%s idle_turn=%s progress=%s)",
|
|
545
|
+
(expected_loop_id or "?")[:16],
|
|
546
|
+
bool(expected_turn_id),
|
|
547
|
+
(idle_turn or "<absent>")[-24:],
|
|
548
|
+
turn_progress_seen,
|
|
549
|
+
)
|
|
502
550
|
continue
|
|
503
551
|
self.last_turn_end_state = state
|
|
504
552
|
if ev_seq is not None:
|
|
@@ -526,38 +574,47 @@ class DaemonSession:
|
|
|
526
574
|
self.turn_event_stats.filtered_early += 1
|
|
527
575
|
continue
|
|
528
576
|
|
|
529
|
-
#
|
|
577
|
+
# Strict turn_id match: absent ids never unlock turn-end.
|
|
530
578
|
if mode == "custom" and is_turn_end_custom_data(data):
|
|
531
579
|
data_turn = (
|
|
532
580
|
frame_turn_id(data if isinstance(data, dict) else None) or ev_turn_id
|
|
533
581
|
)
|
|
534
|
-
turn_ok = (
|
|
535
|
-
|
|
582
|
+
turn_ok = is_turn_terminal_allowed(
|
|
583
|
+
expected_turn_id=expected_turn_id,
|
|
584
|
+
frame_turn_id=data_turn,
|
|
585
|
+
query_started=query_started,
|
|
586
|
+
turn_progress_seen=turn_progress_seen,
|
|
536
587
|
)
|
|
537
|
-
if not turn_ok
|
|
588
|
+
if not turn_ok:
|
|
538
589
|
logger.debug(
|
|
539
590
|
"Ignoring premature turn-end frame %s "
|
|
540
|
-
"(loop=%s query_started=%s progress=%s
|
|
591
|
+
"(loop=%s query_started=%s progress=%s "
|
|
592
|
+
"bound=%s data_turn=%s)",
|
|
541
593
|
str(data.get("type", "")).strip()
|
|
542
594
|
if isinstance(data, dict)
|
|
543
595
|
else "?",
|
|
544
596
|
(expected_loop_id or "?")[:16],
|
|
545
597
|
query_started,
|
|
546
598
|
turn_progress_seen,
|
|
547
|
-
|
|
599
|
+
bool(expected_turn_id),
|
|
600
|
+
(data_turn or "<absent>")[-24:],
|
|
548
601
|
)
|
|
549
602
|
continue
|
|
550
603
|
|
|
551
604
|
progress_seen = True
|
|
552
|
-
stream_payload_seen = True
|
|
553
605
|
if is_turn_progress_chunk(mode, data):
|
|
554
606
|
turn_progress_seen = True
|
|
555
607
|
yield (namespace, mode, data)
|
|
556
608
|
if mode == "custom" and is_turn_end_custom_data(data):
|
|
557
609
|
custom_type = str(data.get("type", "")).strip()
|
|
558
|
-
|
|
559
|
-
"
|
|
560
|
-
|
|
610
|
+
if custom_type == STREAM_END:
|
|
611
|
+
reason = str(data.get("reason") or "").strip().lower()
|
|
612
|
+
self.last_turn_stream_end_reason = reason or None
|
|
613
|
+
if is_stream_end_cancel_reason(reason):
|
|
614
|
+
self.last_turn_cancellation_seen = True
|
|
615
|
+
self.last_turn_end_state = "stream_end"
|
|
616
|
+
else:
|
|
617
|
+
self.last_turn_end_state = "completed"
|
|
561
618
|
if ev_seq is not None:
|
|
562
619
|
self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
|
|
563
620
|
async for chunk in self._drain_stream_events_after_idle(
|
|
@@ -697,6 +754,35 @@ class DaemonSession:
|
|
|
697
754
|
values: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
|
|
698
755
|
return SimpleNamespace(values=values)
|
|
699
756
|
|
|
757
|
+
async def aupdate_loop_state(
|
|
758
|
+
self,
|
|
759
|
+
loop_id: str,
|
|
760
|
+
values: dict[str, Any],
|
|
761
|
+
*,
|
|
762
|
+
timeout: float = 10.0,
|
|
763
|
+
as_node: str | None = None,
|
|
764
|
+
) -> dict[str, Any]:
|
|
765
|
+
"""Merge partial StrangeLoop state via daemon ``loop_state_update``.
|
|
766
|
+
|
|
767
|
+
Used by the TUI for interrupt cleanup and token-total persistence.
|
|
768
|
+
"""
|
|
769
|
+
lid = str(loop_id or "").strip()
|
|
770
|
+
if not lid or not isinstance(values, dict):
|
|
771
|
+
return {}
|
|
772
|
+
|
|
773
|
+
async with self._rpc_lock:
|
|
774
|
+
await self._ensure_rpc_connected()
|
|
775
|
+
try:
|
|
776
|
+
return await self._rpc_client.loop_state_update(
|
|
777
|
+
lid,
|
|
778
|
+
values,
|
|
779
|
+
as_node=as_node,
|
|
780
|
+
timeout=timeout,
|
|
781
|
+
)
|
|
782
|
+
except Exception:
|
|
783
|
+
logger.warning("loop_state_update failed for loop %s", lid[:16], exc_info=True)
|
|
784
|
+
return {}
|
|
785
|
+
|
|
700
786
|
async def fetch_execution_state(self, loop_id: str) -> SimpleNamespace:
|
|
701
787
|
"""Fetch execution-progress snapshot (plan, step_index, iteration, status).
|
|
702
788
|
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/turn_boundary.py
RENAMED
|
@@ -14,6 +14,13 @@ from soothe_client.stream_terminal import (
|
|
|
14
14
|
is_turn_end_custom_data,
|
|
15
15
|
is_turn_progress_chunk,
|
|
16
16
|
)
|
|
17
|
+
from soothe_client.turn_boundary import (
|
|
18
|
+
frame_turn_id,
|
|
19
|
+
is_idle_terminal_allowed,
|
|
20
|
+
is_turn_terminal_allowed,
|
|
21
|
+
parse_turn_generation,
|
|
22
|
+
turn_ids_match,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
TURN_END_STREAM_END = STREAM_END
|
|
19
26
|
TURN_END_IDLE = "status.idle"
|
|
@@ -25,8 +32,9 @@ class TurnLifecycleGate:
|
|
|
25
32
|
"""Per-turn progress flags (DaemonSession parity; not shared across chats)."""
|
|
26
33
|
|
|
27
34
|
saw_running: bool = False
|
|
28
|
-
saw_stream_payload: bool = False
|
|
29
35
|
saw_turn_progress: bool = False
|
|
36
|
+
expected_turn_id: str | None = None
|
|
37
|
+
cancellation_seen: bool = False
|
|
30
38
|
|
|
31
39
|
def observe(self, msg: Any) -> None:
|
|
32
40
|
frame = _normalize_frame(msg)
|
|
@@ -36,18 +44,38 @@ class TurnLifecycleGate:
|
|
|
36
44
|
if typ == "status":
|
|
37
45
|
if str(frame.get("state") or "").strip().lower() == "running":
|
|
38
46
|
self.saw_running = True
|
|
47
|
+
status_turn = frame_turn_id(frame)
|
|
48
|
+
if status_turn:
|
|
49
|
+
new_gen = parse_turn_generation(status_turn)
|
|
50
|
+
old_gen = parse_turn_generation(self.expected_turn_id)
|
|
51
|
+
if self.expected_turn_id is None or (
|
|
52
|
+
new_gen is not None and (old_gen is None or new_gen >= old_gen)
|
|
53
|
+
):
|
|
54
|
+
if self.expected_turn_id and status_turn != self.expected_turn_id:
|
|
55
|
+
self.saw_turn_progress = False
|
|
56
|
+
self.expected_turn_id = status_turn
|
|
39
57
|
return
|
|
40
58
|
if typ == "event":
|
|
41
|
-
self.saw_stream_payload = True
|
|
42
59
|
mode = str(frame.get("mode") or "")
|
|
43
60
|
if is_turn_progress_chunk(mode, frame.get("data")):
|
|
44
61
|
self.saw_turn_progress = True
|
|
45
62
|
|
|
46
|
-
def allow_stream_end(self) -> bool:
|
|
47
|
-
return
|
|
63
|
+
def allow_stream_end(self, frame_turn: str | None) -> bool:
|
|
64
|
+
return is_turn_terminal_allowed(
|
|
65
|
+
expected_turn_id=self.expected_turn_id,
|
|
66
|
+
frame_turn_id=frame_turn,
|
|
67
|
+
query_started=self.saw_running,
|
|
68
|
+
turn_progress_seen=self.saw_turn_progress,
|
|
69
|
+
)
|
|
48
70
|
|
|
49
|
-
def allow_idle_complete(self) -> bool:
|
|
50
|
-
return
|
|
71
|
+
def allow_idle_complete(self, frame_turn: str | None) -> bool:
|
|
72
|
+
return is_idle_terminal_allowed(
|
|
73
|
+
expected_turn_id=self.expected_turn_id,
|
|
74
|
+
frame_turn_id=frame_turn,
|
|
75
|
+
query_started=self.saw_running,
|
|
76
|
+
turn_progress_seen=self.saw_turn_progress,
|
|
77
|
+
cancellation_seen=self.cancellation_seen,
|
|
78
|
+
)
|
|
51
79
|
|
|
52
80
|
|
|
53
81
|
@dataclass
|
|
@@ -69,16 +97,28 @@ class TurnBoundary:
|
|
|
69
97
|
typ = str(frame.get("type") or "")
|
|
70
98
|
if typ == "status":
|
|
71
99
|
state = str(frame.get("state") or "").strip().lower()
|
|
100
|
+
frame_turn = frame_turn_id(frame)
|
|
72
101
|
if state == "stopped" and self.gate.saw_running:
|
|
102
|
+
if self.gate.expected_turn_id and not turn_ids_match(
|
|
103
|
+
self.gate.expected_turn_id, frame_turn
|
|
104
|
+
):
|
|
105
|
+
return False, ""
|
|
73
106
|
return self._mark(TURN_END_STOPPED)
|
|
74
|
-
if state == "idle" and self.gate.allow_idle_complete():
|
|
107
|
+
if state == "idle" and self.gate.allow_idle_complete(frame_turn):
|
|
75
108
|
return self._mark(TURN_END_IDLE)
|
|
76
109
|
return False, ""
|
|
77
110
|
|
|
78
111
|
if typ == "event":
|
|
79
112
|
mode = str(frame.get("mode") or "")
|
|
80
113
|
data = frame.get("data")
|
|
81
|
-
|
|
114
|
+
data_turn = frame_turn_id(data if isinstance(data, dict) else None) or frame_turn_id(
|
|
115
|
+
frame
|
|
116
|
+
)
|
|
117
|
+
if (
|
|
118
|
+
mode == "custom"
|
|
119
|
+
and is_turn_end_custom_data(data)
|
|
120
|
+
and self.gate.allow_stream_end(data_turn)
|
|
121
|
+
):
|
|
82
122
|
return self._mark(TURN_END_STREAM_END)
|
|
83
123
|
return False, ""
|
|
84
124
|
|
|
@@ -109,19 +149,27 @@ def _normalize_frame(msg: Any) -> dict[str, Any] | None:
|
|
|
109
149
|
if isinstance(inner, dict) and inner.get("type") == "status":
|
|
110
150
|
return inner
|
|
111
151
|
if isinstance(inner, dict) and inner.get("mode"):
|
|
112
|
-
|
|
152
|
+
out = {
|
|
113
153
|
"type": "event",
|
|
114
154
|
"mode": inner.get("mode"),
|
|
115
155
|
"data": inner.get("data"),
|
|
116
156
|
"namespace": inner.get("namespace") or payload.get("namespace"),
|
|
117
157
|
}
|
|
158
|
+
tid = inner.get("turn_id") or payload.get("turn_id") or msg.get("turn_id")
|
|
159
|
+
if tid:
|
|
160
|
+
out["turn_id"] = tid
|
|
161
|
+
return out
|
|
118
162
|
mode = payload.get("mode")
|
|
119
163
|
if mode:
|
|
120
|
-
|
|
164
|
+
out = {
|
|
121
165
|
"type": "event",
|
|
122
166
|
"mode": mode,
|
|
123
167
|
"data": payload.get("data"),
|
|
124
168
|
"namespace": payload.get("namespace"),
|
|
125
169
|
}
|
|
170
|
+
tid = payload.get("turn_id") or msg.get("turn_id")
|
|
171
|
+
if tid:
|
|
172
|
+
out["turn_id"] = tid
|
|
173
|
+
return out
|
|
126
174
|
return None
|
|
127
175
|
return msg
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/stream_terminal.py
RENAMED
|
@@ -24,6 +24,16 @@ from soothe_sdk.core.events import (
|
|
|
24
24
|
|
|
25
25
|
TURN_END_CUSTOM_TYPES = frozenset({STREAM_END, STRANGE_LOOP_COMPLETED})
|
|
26
26
|
|
|
27
|
+
# ``soothe.stream.end`` reasons that mean the turn was cancelled (not a clean end).
|
|
28
|
+
STREAM_END_CANCEL_REASONS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"cancelled",
|
|
31
|
+
"canceled",
|
|
32
|
+
"user_cancelled",
|
|
33
|
+
"client_disconnect",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
27
37
|
# Customs that prove this turn has real work (not mere intake plan.phase).
|
|
28
38
|
# Intake-only phases must not unlock turn-end — prior-goal stream.end can still
|
|
29
39
|
# arrive after status=running (loop 3e43).
|
|
@@ -64,6 +74,11 @@ def is_turn_end_custom_data(data: Any) -> TypeGuard[dict[str, Any]]:
|
|
|
64
74
|
return True
|
|
65
75
|
|
|
66
76
|
|
|
77
|
+
def is_stream_end_cancel_reason(reason: str | None) -> bool:
|
|
78
|
+
"""True when a ``stream.end`` reason is a cancel/disconnect (not clean end)."""
|
|
79
|
+
return str(reason or "").strip().lower() in STREAM_END_CANCEL_REASONS
|
|
80
|
+
|
|
81
|
+
|
|
67
82
|
def is_turn_progress_chunk(mode: str, data: Any) -> bool:
|
|
68
83
|
"""True when a chunk proves the active turn has non-intake progress.
|
|
69
84
|
|
|
@@ -113,8 +128,10 @@ def stale_pending_frame_label(event: dict[str, Any]) -> str | None:
|
|
|
113
128
|
__all__ = [
|
|
114
129
|
"STALE_TURN_PENDING_TYPES",
|
|
115
130
|
"STREAM_END",
|
|
131
|
+
"STREAM_END_CANCEL_REASONS",
|
|
116
132
|
"STRANGE_LOOP_COMPLETED",
|
|
117
133
|
"TURN_END_CUSTOM_TYPES",
|
|
134
|
+
"is_stream_end_cancel_reason",
|
|
118
135
|
"is_turn_end_custom_data",
|
|
119
136
|
"is_turn_progress_chunk",
|
|
120
137
|
"stale_pending_frame_label",
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/turn_boundary.py
RENAMED
|
@@ -61,9 +61,66 @@ def frame_seq(frame: dict[str, Any] | None) -> int | None:
|
|
|
61
61
|
return None
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def turn_ids_match(expected: str | None, candidate: str | None) -> bool:
|
|
65
|
+
"""True when both ids are non-empty and equal.
|
|
66
|
+
|
|
67
|
+
Absent ids never match — callers must not treat missing ``turn_id`` as
|
|
68
|
+
compatible with a bound turn (prior-goal terminals / idle can omit or
|
|
69
|
+
carry a stale generation).
|
|
70
|
+
"""
|
|
71
|
+
exp = str(expected or "").strip()
|
|
72
|
+
cand = str(candidate or "").strip()
|
|
73
|
+
return bool(exp) and bool(cand) and exp == cand
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_turn_terminal_allowed(
|
|
77
|
+
*,
|
|
78
|
+
expected_turn_id: str | None,
|
|
79
|
+
frame_turn_id: str | None,
|
|
80
|
+
query_started: bool,
|
|
81
|
+
turn_progress_seen: bool,
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""Gate for turn-scoped ``stream.end`` / ``strange_loop.completed``.
|
|
84
|
+
|
|
85
|
+
Requires an active query, real turn progress, a bound ``expected_turn_id``,
|
|
86
|
+
and an exact ``frame_turn_id`` match. Absent ids are rejected.
|
|
87
|
+
"""
|
|
88
|
+
if not query_started or not turn_progress_seen:
|
|
89
|
+
return False
|
|
90
|
+
return turn_ids_match(expected_turn_id, frame_turn_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def is_idle_terminal_allowed(
|
|
94
|
+
*,
|
|
95
|
+
expected_turn_id: str | None,
|
|
96
|
+
frame_turn_id: str | None,
|
|
97
|
+
query_started: bool,
|
|
98
|
+
turn_progress_seen: bool,
|
|
99
|
+
cancellation_seen: bool = False,
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""Gate for ``status=idle`` soft-complete.
|
|
102
|
+
|
|
103
|
+
Requires a bound turn. Matching ``turn_id`` needs progress or cancel.
|
|
104
|
+
Absent idle ``turn_id`` is allowed only when cancellation was already seen
|
|
105
|
+
(legacy cancel finalize); mismatched ids are always rejected.
|
|
106
|
+
"""
|
|
107
|
+
if not query_started or not str(expected_turn_id or "").strip():
|
|
108
|
+
return False
|
|
109
|
+
cand = str(frame_turn_id or "").strip()
|
|
110
|
+
if cand:
|
|
111
|
+
if not turn_ids_match(expected_turn_id, cand):
|
|
112
|
+
return False
|
|
113
|
+
return bool(turn_progress_seen or cancellation_seen)
|
|
114
|
+
# Absent idle turn_id: never end a healthy turn; cancel path only.
|
|
115
|
+
return bool(cancellation_seen)
|
|
116
|
+
|
|
117
|
+
|
|
64
118
|
__all__ = [
|
|
65
119
|
"format_turn_id",
|
|
66
120
|
"frame_seq",
|
|
67
121
|
"frame_turn_id",
|
|
122
|
+
"is_idle_terminal_allowed",
|
|
123
|
+
"is_turn_terminal_allowed",
|
|
68
124
|
"parse_turn_generation",
|
|
125
|
+
"turn_ids_match",
|
|
69
126
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/__init__.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/attachments.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/broadcaster.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/chunk_filter.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/classifier.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/events.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/managed_client.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/observability.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/query_gate.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/thinking_step.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/appkit/turn_runner.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/command_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.7 → soothe_client_python-1.0.9}/src/soothe_client/protocol_params.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|