soothe-client-python 1.0.11__tar.gz → 1.0.14__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 (35) hide show
  1. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/PKG-INFO +1 -1
  2. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/__init__.py +0 -2
  3. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/daemon_session.py +153 -49
  4. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/turn_boundary.py +33 -20
  5. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/turn_runner.py +3 -4
  6. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/command_client.py +0 -10
  7. soothe_client_python-1.0.14/src/soothe_client/intent_hints.py +23 -0
  8. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/protocol_params.py +2 -8
  9. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/session.py +0 -5
  10. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/stream_terminal.py +12 -0
  11. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/turn_boundary.py +54 -4
  12. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/websocket.py +17 -19
  13. soothe_client_python-1.0.11/src/soothe_client/intent_hints.py +0 -44
  14. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/.gitignore +0 -0
  15. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/LICENSE +0 -0
  16. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/README.md +0 -0
  17. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/pyproject.toml +0 -0
  18. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/__init__.py +0 -0
  19. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/attachments.py +0 -0
  20. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/broadcaster.py +0 -0
  21. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/card_projection.py +0 -0
  22. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/chunk_filter.py +0 -0
  23. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/classifier.py +0 -0
  24. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/events.py +0 -0
  25. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/loop_session_store.py +0 -0
  26. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/managed_client.py +0 -0
  27. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/observability.py +0 -0
  28. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/pool.py +0 -0
  29. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/query_gate.py +0 -0
  30. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/thinking_step.py +0 -0
  31. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/turn.py +0 -0
  32. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/errors.py +0 -0
  33. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/helpers.py +0 -0
  34. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/py.typed +0 -0
  35. {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/schemas.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-client-python
3
- Version: 1.0.11
3
+ Version: 1.0.14
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
@@ -49,7 +49,6 @@ from soothe_client.intent_hints import (
49
49
  IMAGE_TO_TEXT,
50
50
  OCR,
51
51
  TEXT_COMPLETION,
52
- validate_loop_input_intent_hint,
53
52
  )
54
53
  from soothe_client.session import (
55
54
  bootstrap_loop_session,
@@ -100,5 +99,4 @@ __all__ = [
100
99
  "OCR",
101
100
  "EMBED",
102
101
  "DEFAULT_DELIVERABLE_PHASES",
103
- "validate_loop_input_intent_hint",
104
102
  ]
@@ -27,9 +27,10 @@ from soothe_client.stream_terminal import (
27
27
  from soothe_client.turn_boundary import (
28
28
  frame_seq,
29
29
  frame_turn_id,
30
- is_idle_terminal_allowed,
30
+ is_status_terminal_allowed,
31
31
  is_turn_terminal_allowed,
32
32
  parse_turn_generation,
33
+ should_bind_running_turn,
33
34
  turn_ids_match,
34
35
  )
35
36
  from soothe_client.websocket import WebSocketClient
@@ -94,7 +95,12 @@ class DaemonSession:
94
95
  # Scoped to the current peer seq namespace: reset on reconnect / loop
95
96
  # change — daemon seq is per-process and restarts after daemon restart.
96
97
  self._last_turn_end_seq: int = 0
98
+ # Last accepted terminal's turn generation (refuse leftover prior-gen
99
+ # status=running binds when expected_turn_id is still unset).
100
+ self._last_completed_turn_gen: int = 0
97
101
  self._expected_turn_id: str | None = None
102
+ # Set by post-idle drain when a successor status=running is re-queued.
103
+ self._drain_saw_successor_running: bool = False
98
104
 
99
105
  def _reset_turn_seq_floor(self) -> None:
100
106
  """Clear seq/turn filters when the peer seq namespace may have restarted.
@@ -104,8 +110,27 @@ class DaemonSession:
104
110
  ``status=running`` — leaving ``query_started=False`` and a silent TUI.
105
111
  """
106
112
  self._last_turn_end_seq = 0
113
+ self._last_completed_turn_gen = 0
107
114
  self._expected_turn_id = None
108
115
 
116
+ def _note_completed_turn_gen(self, turn_id: str | None) -> None:
117
+ """Raise the completed-generation floor after an accepted terminal."""
118
+ floor = int(getattr(self, "_last_completed_turn_gen", 0) or 0)
119
+ gen = parse_turn_generation(turn_id)
120
+ if gen is not None and gen > floor:
121
+ self._last_completed_turn_gen = gen
122
+
123
+ def _ensure_turn_floor_attrs(self) -> None:
124
+ """Initialize turn-floor attrs for partially constructed sessions (tests)."""
125
+ if not hasattr(self, "_last_completed_turn_gen"):
126
+ self._last_completed_turn_gen = 0
127
+ if not hasattr(self, "_last_turn_end_seq"):
128
+ self._last_turn_end_seq = 0
129
+ if not hasattr(self, "_drain_saw_successor_running"):
130
+ self._drain_saw_successor_running = False
131
+ if not hasattr(self, "_expected_turn_id"):
132
+ self._expected_turn_id = None
133
+
109
134
  @property
110
135
  def client(self) -> WebSocketClient:
111
136
  """Subscribed stream WebSocket (loop events)."""
@@ -254,8 +279,6 @@ class DaemonSession:
254
279
  self,
255
280
  text: str,
256
281
  *,
257
- autonomous: bool = False,
258
- max_iterations: int | None = None,
259
282
  preferred_subagent: str | None = None,
260
283
  model: str | None = None,
261
284
  model_params: dict[str, Any] | None = None,
@@ -276,8 +299,6 @@ class DaemonSession:
276
299
  await self._client.send_input(
277
300
  self._loop_id,
278
301
  text,
279
- autonomous=autonomous,
280
- max_iterations=max_iterations,
281
302
  preferred_subagent=preferred_subagent,
282
303
  model=model,
283
304
  model_params=model_params,
@@ -302,7 +323,13 @@ class DaemonSession:
302
323
  *,
303
324
  expected_loop_id: str | None,
304
325
  ) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
305
- """Yield stream chunks that arrive just after ``idle``."""
326
+ """Yield stream chunks that arrive just after ``idle``.
327
+
328
+ If a successor ``status=running`` (generation above the completed floor)
329
+ arrives, re-queue it and set ``_drain_saw_successor_running`` so the
330
+ caller can continue the reader instead of ending the turn.
331
+ """
332
+ self._drain_saw_successor_running = False
306
333
  loop = asyncio.get_running_loop()
307
334
  deadline = loop.time() + self._drain_deadline
308
335
  exp = expected_loop_id
@@ -313,6 +340,7 @@ class DaemonSession:
313
340
  break
314
341
  if not event:
315
342
  break
343
+ raw_event = event
316
344
  event_type = event.get("type", "")
317
345
  if event_type == "next":
318
346
  event = unwrap_next(event) or event
@@ -329,6 +357,21 @@ class DaemonSession:
329
357
  if isinstance(loop_ev, str) and loop_ev:
330
358
  self._loop_id = loop_ev
331
359
  exp = loop_ev
360
+ state = str(event.get("state") or "").strip().lower()
361
+ if state == "running":
362
+ status_turn = frame_turn_id(event)
363
+ new_gen = parse_turn_generation(status_turn)
364
+ if new_gen is not None and new_gen > int(
365
+ getattr(self, "_last_completed_turn_gen", 0) or 0
366
+ ):
367
+ push = getattr(self._client, "push_pending_event_front", None)
368
+ if callable(push):
369
+ push(raw_event)
370
+ else:
371
+ # Fallback: put unwrapped event if client lacks helper.
372
+ self._client._pending_events.appendleft(event) # noqa: SLF001
373
+ self._drain_saw_successor_running = True
374
+ return
332
375
  continue
333
376
  if event_type != "event":
334
377
  continue
@@ -353,6 +396,7 @@ class DaemonSession:
353
396
  self,
354
397
  *,
355
398
  max_wait_s: float | None = None,
399
+ idle_timeout_s: float | None = None,
356
400
  ) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
357
401
  """Yield ``(namespace, mode, data)`` stream chunks until the turn ends.
358
402
 
@@ -363,7 +407,15 @@ class DaemonSession:
363
407
  max_wait_s: Optional absolute deadline for the whole turn. When set,
364
408
  raises ``TimeoutError`` if the daemon never emits a turn-end
365
409
  signal in time.
410
+ idle_timeout_s: Optional hard cap on how long to wait for the *first*
411
+ progress event before giving up. When set and no progress is seen
412
+ within this many seconds, raises ``TimeoutError`` instead of the
413
+ default 30s-log-and-reset behavior. Used by attach-only reads
414
+ (``skip_daemon_send_turn``) so a stale ``live`` probe that turns
415
+ out to have no real follow-on turn does not spin the thinking
416
+ row for minutes.
366
417
  """
418
+ self._ensure_turn_floor_attrs()
367
419
  self.turn_event_stats = self._new_turn_stats()
368
420
  self.last_turn_end_state = None
369
421
  self.last_turn_cancellation_seen = False
@@ -382,6 +434,12 @@ class DaemonSession:
382
434
  turn_read_started = time.monotonic()
383
435
  first_event_logged = False
384
436
  progress_seen = False
437
+ # Wall-clock of the last *real* progress (a yielded chunk). A stale
438
+ # ``status=running`` frame sets ``progress_seen`` but yields nothing;
439
+ # for the attach-only idle timeout we need a signal that advances when
440
+ # the daemon actually streams content, not when it merely re-announces
441
+ # a (possibly stale) running status.
442
+ last_real_progress_at = turn_read_started
385
443
  absolute_deadline = (
386
444
  time.monotonic() + max_wait_s if max_wait_s is not None and max_wait_s > 0 else None
387
445
  )
@@ -402,15 +460,31 @@ class DaemonSession:
402
460
  f"Turn timed out after {max_wait_s:.0f}s "
403
461
  f"(loop={expected_loop_id or '?'})"
404
462
  )
405
- if not progress_seen and time.monotonic() - turn_read_started > 30.0:
406
- logger.warning(
407
- "No daemon stream progress after %.0fs (loop=%s, "
408
- "query_started=%s); check daemon sender / WebSocket reader",
409
- time.monotonic() - turn_read_started,
410
- (expected_loop_id or "?")[:16],
411
- query_started,
463
+ if not progress_seen:
464
+ elapsed = time.monotonic() - turn_read_started
465
+ if elapsed > 30.0:
466
+ logger.warning(
467
+ "No daemon stream progress after %.0fs (loop=%s, "
468
+ "query_started=%s); check daemon sender / WebSocket reader",
469
+ elapsed,
470
+ (expected_loop_id or "?")[:16],
471
+ query_started,
472
+ )
473
+ turn_read_started = time.monotonic()
474
+ # Attach-only idle timeout: even after a stale
475
+ # ``status=running`` sets ``progress_seen``, no real content
476
+ # may ever arrive (the runner already exited). Bound the wait
477
+ # for the first yielded chunk so the TUI falls back instead
478
+ # of spinning the thinking row for minutes.
479
+ if (
480
+ idle_timeout_s is not None
481
+ and idle_timeout_s > 0
482
+ and time.monotonic() - last_real_progress_at > idle_timeout_s
483
+ ):
484
+ raise TimeoutError(
485
+ f"No daemon stream progress within {idle_timeout_s:.0f}s "
486
+ f"attach window (loop={expected_loop_id or '?'})"
412
487
  )
413
- turn_read_started = time.monotonic()
414
488
  event = await self._client.read_event()
415
489
  if event and not first_event_logged:
416
490
  first_event_logged = True
@@ -495,61 +569,73 @@ class DaemonSession:
495
569
  expected_loop_id = loop_ev
496
570
  state = event.get("state", "")
497
571
  if state == "running":
498
- query_started = True
499
- progress_seen = True
500
572
  status_turn = frame_turn_id(event)
501
573
  # Bind only from running frames that carry turn_id.
502
574
  # Pre-admit early running omits turn_id and must not
503
575
  # unlock terminals or poison the expected id.
504
576
  if status_turn:
505
- new_gen = parse_turn_generation(status_turn)
506
- old_gen = parse_turn_generation(expected_turn_id)
507
- if expected_turn_id is None or (
508
- new_gen is not None and (old_gen is None or new_gen >= old_gen)
577
+ completed_gen = int(
578
+ getattr(self, "_last_completed_turn_gen", 0) or 0
579
+ )
580
+ if not should_bind_running_turn(
581
+ status_turn=status_turn,
582
+ expected_turn_id=expected_turn_id,
583
+ last_completed_turn_gen=completed_gen,
509
584
  ):
510
- if expected_turn_id and status_turn != expected_turn_id:
511
- # Successor turn admitted while this reader
512
- # still held a stale/early binding.
513
- turn_progress_seen = False
514
- expected_turn_id = status_turn
515
- self._expected_turn_id = status_turn
516
- elif query_started and state == "stopped":
517
- stop_turn = frame_turn_id(event)
518
- if expected_turn_id and not turn_ids_match(expected_turn_id, stop_turn):
519
- continue
520
- self.last_turn_end_state = state
521
- if ev_seq is not None:
522
- self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
523
- async for chunk in self._drain_stream_events_after_idle(
524
- expected_loop_id=expected_loop_id,
525
- ):
526
- yield chunk
527
- break
528
- elif query_started and state == "idle":
529
- idle_turn = frame_turn_id(event)
530
- if not is_idle_terminal_allowed(
585
+ logger.debug(
586
+ "Ignoring stale/older status=running "
587
+ "(loop=%s turn=%s expected=%s completed_gen=%s)",
588
+ (expected_loop_id or "?")[:16],
589
+ status_turn[-24:],
590
+ (expected_turn_id or "<none>")[-24:],
591
+ completed_gen,
592
+ )
593
+ continue
594
+ if expected_turn_id and status_turn != expected_turn_id:
595
+ # Successor turn admitted while this reader
596
+ # still held a stale/early binding.
597
+ turn_progress_seen = False
598
+ expected_turn_id = status_turn
599
+ self._expected_turn_id = status_turn
600
+ query_started = True
601
+ progress_seen = True
602
+ else:
603
+ # Pre-admit early running: query start, no bind.
604
+ query_started = True
605
+ progress_seen = True
606
+ elif query_started and state in {"stopped", "idle"}:
607
+ status_turn = frame_turn_id(event)
608
+ if not is_status_terminal_allowed(
531
609
  expected_turn_id=expected_turn_id,
532
- frame_turn_id=idle_turn,
610
+ frame_turn_id=status_turn,
533
611
  query_started=query_started,
534
612
  turn_progress_seen=turn_progress_seen,
535
613
  cancellation_seen=self.last_turn_cancellation_seen,
536
614
  ):
537
615
  logger.debug(
538
- "Ignoring premature idle "
539
- "(loop=%s bound=%s idle_turn=%s progress=%s)",
616
+ "Ignoring premature %s "
617
+ "(loop=%s bound=%s status_turn=%s progress=%s)",
618
+ state,
540
619
  (expected_loop_id or "?")[:16],
541
620
  bool(expected_turn_id),
542
- (idle_turn or "<absent>")[-24:],
621
+ (status_turn or "<absent>")[-24:],
543
622
  turn_progress_seen,
544
623
  )
545
624
  continue
546
625
  self.last_turn_end_state = state
547
626
  if ev_seq is not None:
548
627
  self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
628
+ self._note_completed_turn_gen(expected_turn_id)
549
629
  async for chunk in self._drain_stream_events_after_idle(
550
630
  expected_loop_id=expected_loop_id,
551
631
  ):
552
632
  yield chunk
633
+ if self._drain_saw_successor_running:
634
+ # Successor turn already re-queued; continue reading.
635
+ turn_progress_seen = False
636
+ expected_turn_id = None
637
+ self._expected_turn_id = None
638
+ continue
553
639
  break
554
640
  continue
555
641
 
@@ -597,6 +683,7 @@ class DaemonSession:
597
683
  continue
598
684
 
599
685
  progress_seen = True
686
+ last_real_progress_at = time.monotonic()
600
687
  if is_turn_progress_chunk(mode, data):
601
688
  turn_progress_seen = True
602
689
  yield (namespace, mode, data)
@@ -612,10 +699,16 @@ class DaemonSession:
612
699
  self.last_turn_end_state = "completed"
613
700
  if ev_seq is not None:
614
701
  self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
702
+ self._note_completed_turn_gen(expected_turn_id)
615
703
  async for chunk in self._drain_stream_events_after_idle(
616
704
  expected_loop_id=expected_loop_id,
617
705
  ):
618
706
  yield chunk
707
+ if self._drain_saw_successor_running:
708
+ turn_progress_seen = False
709
+ expected_turn_id = None
710
+ self._expected_turn_id = None
711
+ continue
619
712
  break
620
713
  if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
621
714
  continue
@@ -633,7 +726,7 @@ class DaemonSession:
633
726
  """Return skill rows from the daemon catalog."""
634
727
  async with self._rpc_lock:
635
728
  await self._ensure_rpc_connected()
636
- response = await self._rpc_client.list_skills(timeout=15.0)
729
+ response = await self._rpc_client.list_skills(workspace=self._workspace, timeout=15.0)
637
730
  skills = response.get("skills", [])
638
731
  if not isinstance(skills, list):
639
732
  return []
@@ -782,9 +875,10 @@ class DaemonSession:
782
875
  """Fetch execution-progress snapshot (plan, step_index, iteration, status).
783
876
 
784
877
  Returns a namespace with ``plan``, ``step_index``, ``iteration``,
785
- ``status`` for the loop's bound checkpoint thread. On RPC failure the
786
- fields degrade gracefully (``step_index=0``, ``iteration=0``,
787
- ``status=None``, ``plan=None``) so a resume gate can still proceed.
878
+ ``status``, and ``active_runner`` for the loop's bound checkpoint
879
+ thread. On RPC failure the fields degrade gracefully (``step_index=0``,
880
+ ``iteration=0``, ``status=None``, ``plan=None``, ``active_runner=False``)
881
+ so a resume gate can still proceed.
788
882
  """
789
883
  lid = str(loop_id or "").strip()
790
884
  empty = SimpleNamespace(
@@ -792,6 +886,7 @@ class DaemonSession:
792
886
  step_index=0,
793
887
  iteration=0,
794
888
  status=None,
889
+ active_runner=False,
795
890
  loop_id=lid or None,
796
891
  success=False,
797
892
  )
@@ -819,12 +914,21 @@ class DaemonSession:
819
914
  iteration = iteration_raw if isinstance(iteration_raw, int) else 0
820
915
  status_raw = resp.get("status")
821
916
  status = status_raw if isinstance(status_raw, str) else None
917
+ # ``active_runner`` is absent on daemons older than this field; treat
918
+ # absence as unknown (None) so callers can fall back to status only.
919
+ active_runner_raw = resp.get("active_runner")
920
+ active_runner: bool | None
921
+ if isinstance(active_runner_raw, bool):
922
+ active_runner = active_runner_raw
923
+ else:
924
+ active_runner = None
822
925
  success = bool(resp.get("success", True))
823
926
  return SimpleNamespace(
824
927
  plan=plan,
825
928
  step_index=step_index,
826
929
  iteration=iteration,
827
930
  status=status,
931
+ active_runner=active_runner,
828
932
  loop_id=resp.get("loop_id", lid),
829
933
  success=success,
830
934
  )
@@ -16,10 +16,10 @@ from soothe_client.stream_terminal import (
16
16
  )
17
17
  from soothe_client.turn_boundary import (
18
18
  frame_turn_id,
19
- is_idle_terminal_allowed,
19
+ is_status_terminal_allowed,
20
20
  is_turn_terminal_allowed,
21
21
  parse_turn_generation,
22
- turn_ids_match,
22
+ should_bind_running_turn,
23
23
  )
24
24
 
25
25
  TURN_END_STREAM_END = STREAM_END
@@ -35,6 +35,7 @@ class TurnLifecycleGate:
35
35
  saw_turn_progress: bool = False
36
36
  expected_turn_id: str | None = None
37
37
  cancellation_seen: bool = False
38
+ last_completed_turn_gen: int = 0
38
39
 
39
40
  def observe(self, msg: Any) -> None:
40
41
  frame = _normalize_frame(msg)
@@ -43,17 +44,24 @@ class TurnLifecycleGate:
43
44
  typ = str(frame.get("type") or "")
44
45
  if typ == "status":
45
46
  if str(frame.get("state") or "").strip().lower() == "running":
46
- self.saw_running = True
47
47
  status_turn = frame_turn_id(frame)
48
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)
49
+ if not should_bind_running_turn(
50
+ status_turn=status_turn,
51
+ expected_turn_id=self.expected_turn_id,
52
+ last_completed_turn_gen=self.last_completed_turn_gen,
53
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
54
+ return
55
+ if self.expected_turn_id and status_turn != self.expected_turn_id:
56
+ # Successor turn admitted while this reader
57
+ # still held a stale/early binding.
58
+ self.saw_turn_progress = False
59
+ self.expected_turn_id = status_turn
60
+ self.saw_running = True
61
+ else:
62
+ # Pre-admit early running omits turn_id — mark query start
63
+ # without binding (DaemonSession parity).
64
+ self.saw_running = True
57
65
  return
58
66
  if typ == "event":
59
67
  mode = str(frame.get("mode") or "")
@@ -68,8 +76,8 @@ class TurnLifecycleGate:
68
76
  turn_progress_seen=self.saw_turn_progress,
69
77
  )
70
78
 
71
- def allow_idle_complete(self, frame_turn: str | None) -> bool:
72
- return is_idle_terminal_allowed(
79
+ def allow_status_complete(self, frame_turn: str | None) -> bool:
80
+ return is_status_terminal_allowed(
73
81
  expected_turn_id=self.expected_turn_id,
74
82
  frame_turn_id=frame_turn,
75
83
  query_started=self.saw_running,
@@ -77,6 +85,15 @@ class TurnLifecycleGate:
77
85
  cancellation_seen=self.cancellation_seen,
78
86
  )
79
87
 
88
+ def allow_idle_complete(self, frame_turn: str | None) -> bool:
89
+ return self.allow_status_complete(frame_turn)
90
+
91
+ def note_completed_turn(self, turn_id: str | None) -> None:
92
+ """Raise the completed-generation floor after an accepted terminal."""
93
+ gen = parse_turn_generation(turn_id)
94
+ if gen is not None and gen > self.last_completed_turn_gen:
95
+ self.last_completed_turn_gen = gen
96
+
80
97
 
81
98
  @dataclass
82
99
  class TurnBoundary:
@@ -98,14 +115,9 @@ class TurnBoundary:
98
115
  if typ == "status":
99
116
  state = str(frame.get("state") or "").strip().lower()
100
117
  frame_turn = frame_turn_id(frame)
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, ""
106
- return self._mark(TURN_END_STOPPED)
107
- if state == "idle" and self.gate.allow_idle_complete(frame_turn):
108
- return self._mark(TURN_END_IDLE)
118
+ if state in {"stopped", "idle"} and self.gate.allow_status_complete(frame_turn):
119
+ self.gate.note_completed_turn(self.gate.expected_turn_id)
120
+ return self._mark(TURN_END_STOPPED if state == "stopped" else TURN_END_IDLE)
109
121
  return False, ""
110
122
 
111
123
  if typ == "event":
@@ -119,6 +131,7 @@ class TurnBoundary:
119
131
  and is_turn_end_custom_data(data)
120
132
  and self.gate.allow_stream_end(data_turn)
121
133
  ):
134
+ self.gate.note_completed_turn(self.gate.expected_turn_id)
122
135
  return self._mark(TURN_END_STREAM_END)
123
136
  return False, ""
124
137
 
@@ -25,7 +25,6 @@ from soothe_client.appkit.loop_session_store import LoopSessionStore, SessionMes
25
25
  from soothe_client.appkit.pool import ConnectionPool, PooledConn
26
26
  from soothe_client.appkit.query_gate import QueryGate
27
27
  from soothe_client.appkit.turn_boundary import TurnBoundary, is_daemon_turn_end_event
28
- from soothe_client.intent_hints import validate_loop_input_intent_hint
29
28
 
30
29
  Attachment = dict[str, Any]
31
30
  OnComplete = Callable[[str, str, str, str, float], None]
@@ -82,6 +81,7 @@ class InputOpts:
82
81
 
83
82
  intent_hint: str | None = None
84
83
  preferred_subagent: str | None = None
84
+ intake_scope: str | None = None
85
85
  response_schema: dict[str, Any] | None = None
86
86
  response_schema_name: str | None = None
87
87
  response_schema_strict: bool | None = None
@@ -101,12 +101,11 @@ def input_message_for_loop(
101
101
  msg["attachments"] = attachments
102
102
  if opts is not None:
103
103
  if opts.intent_hint and opts.intent_hint.strip():
104
- hint_err = validate_loop_input_intent_hint(opts.intent_hint)
105
- if hint_err:
106
- raise ValueError(hint_err)
107
104
  msg["intent_hint"] = opts.intent_hint.strip()
108
105
  if opts.preferred_subagent and opts.preferred_subagent.strip():
109
106
  msg["preferred_subagent"] = opts.preferred_subagent.strip()
107
+ if opts.intake_scope and opts.intake_scope.strip():
108
+ msg["intake_scope"] = opts.intake_scope.strip()
110
109
  if opts.response_schema:
111
110
  msg["response_schema"] = opts.response_schema
112
111
  if opts.response_schema_name and opts.response_schema_name.strip():
@@ -331,18 +331,12 @@ class AsyncCommandClient:
331
331
  goal: str,
332
332
  *,
333
333
  workspace: str | None = None,
334
- autonomous: bool = False,
335
- max_iterations: int | None = None,
336
334
  guidance: str | None = None,
337
335
  ) -> dict[str, Any]:
338
336
  """Create a background job (root goal). Returns ``job_id`` + status."""
339
337
  payload: dict[str, Any] = {"goal": goal}
340
338
  if workspace:
341
339
  payload["workspace"] = workspace
342
- if autonomous:
343
- payload["autonomous"] = autonomous
344
- if max_iterations:
345
- payload["max_iterations"] = max_iterations
346
340
  if guidance:
347
341
  payload["guidance"] = guidance
348
342
  return await self._send_command("job_create", payload)
@@ -518,8 +512,6 @@ class CommandClient:
518
512
  goal: str,
519
513
  *,
520
514
  workspace: str | None = None,
521
- autonomous: bool = False,
522
- max_iterations: int | None = None,
523
515
  guidance: str | None = None,
524
516
  ) -> dict[str, Any]:
525
517
  """Create a background job (root goal)."""
@@ -529,8 +521,6 @@ class CommandClient:
529
521
  self._client.job_create(
530
522
  goal,
531
523
  workspace=workspace,
532
- autonomous=autonomous,
533
- max_iterations=max_iterations,
534
524
  guidance=guidance,
535
525
  )
536
526
  ),
@@ -0,0 +1,23 @@
1
+ """``loop_input.intent_hint`` constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ TEXT_COMPLETION: Final = "text_completion"
8
+ IMAGE_TO_TEXT: Final = "image_to_text"
9
+ OCR: Final = "ocr"
10
+ EMBED: Final = "embed"
11
+
12
+ # Default deliverable phases for turn-ending replies (excludes plan_direct narration).
13
+ DEFAULT_DELIVERABLE_PHASES: frozenset[str] = frozenset(
14
+ {
15
+ "quiz",
16
+ "goal_completion",
17
+ "chitchat",
18
+ "text_completion",
19
+ "image_to_text",
20
+ "ocr",
21
+ "embed",
22
+ }
23
+ )
@@ -194,9 +194,8 @@ class LoopInputParams(ParamsBase):
194
194
  Attributes:
195
195
  loop_id: Loop identifier (required).
196
196
  content: User input text or structured content (required).
197
- autonomous: Enable autonomous mode.
198
- max_iterations: Max iterations for autonomous mode.
199
197
  preferred_subagent: Routing hint.
198
+ intake_scope: Forced StrangeLoop scope (``trivial``|``simple``|``complex``).
200
199
  model: Provider:model override.
201
200
  model_params: Additional model parameters.
202
201
  router_profile: Named ``router_profiles`` overlay for chat roles this turn.
@@ -212,9 +211,8 @@ class LoopInputParams(ParamsBase):
212
211
 
213
212
  loop_id: str = Field(..., min_length=1)
214
213
  content: str | dict[str, Any] = Field(..., description="User input text or structured content")
215
- autonomous: bool = False
216
- max_iterations: int | None = Field(default=None, gt=0)
217
214
  preferred_subagent: str | None = None
215
+ intake_scope: str | None = None
218
216
  model: str | None = None
219
217
  model_params: dict[str, Any] | None = None
220
218
  router_profile: str | None = None
@@ -424,8 +422,6 @@ class JobCreateParams(ParamsBase):
424
422
  goal: Root goal description (required).
425
423
  workspace: Optional workspace path.
426
424
  user_id: Optional user identifier.
427
- autonomous: Enable autonomous mode.
428
- max_iterations: Max iterations.
429
425
  guidance: Initial guidance.
430
426
  intent_hint: Daemon intent_hint (text_completion, image_to_text, ocr, embed).
431
427
  """
@@ -433,8 +429,6 @@ class JobCreateParams(ParamsBase):
433
429
  goal: str = Field(..., min_length=1, description="Root goal text")
434
430
  workspace: str | None = None
435
431
  user_id: str | None = None
436
- autonomous: bool = False
437
- max_iterations: int | None = Field(default=None, gt=0)
438
432
  guidance: str | None = None
439
433
  intent_hint: str | None = None
440
434
 
@@ -21,9 +21,6 @@ _CONNECT_TIMEOUT_S = 5.0
21
21
  _DAEMON_READY_TIMEOUT_S = 20.0
22
22
  _SESSION_BOOTSTRAP_TIMEOUT_S = 30.0
23
23
 
24
- # Wire-compat: interactive loops are always solo; daemon jobs use AutopilotService.
25
- _LEGACY_LOOP_AUTOPILOT_MODE = "solo"
26
-
27
24
 
28
25
  async def connect_websocket_with_retries(client: Any) -> None:
29
26
  """Connect a ``WebSocketClient`` with retries (daemon cold-start safe)."""
@@ -181,8 +178,6 @@ async def bootstrap_loop_session(
181
178
  "type": "session_ready",
182
179
  "loop_id": loop_id,
183
180
  "success": True,
184
- # Deprecated wire field: interactive loops are always solo.
185
- "autopilot_mode": _LEGACY_LOOP_AUTOPILOT_MODE,
186
181
  }
187
182
  if mapping_data and mapping_data.get("host_root"):
188
183
  result["workspace_mapping"] = mapping_data
@@ -105,6 +105,14 @@ def stale_pending_frame_label(event: dict[str, Any]) -> str | None:
105
105
  if event_type in STALE_TURN_PENDING_TYPES:
106
106
  return event_type
107
107
 
108
+ # Leftover terminal status from the prior turn must not bind/kill the next
109
+ # reader. Keep ``status=running`` — attach / live-query paths need it.
110
+ if event_type == "status":
111
+ state = str(event.get("state") or "").strip().lower()
112
+ if state in {"idle", "stopped"}:
113
+ return f"status.{state}"
114
+ return None
115
+
108
116
  if event_type == "next":
109
117
  payload = event.get("payload")
110
118
  if not isinstance(payload, dict):
@@ -115,6 +123,10 @@ def stale_pending_frame_label(event: dict[str, Any]) -> str | None:
115
123
  inner = payload.get("data")
116
124
  if isinstance(inner, dict):
117
125
  return stale_pending_frame_label(inner)
126
+ # Bare status nested under next.payload
127
+ nested_type = str(payload.get("type") or "")
128
+ if nested_type == "status":
129
+ return stale_pending_frame_label(payload)
118
130
  return None
119
131
 
120
132
  if event_type == "event":
@@ -90,7 +90,7 @@ def is_turn_terminal_allowed(
90
90
  return turn_ids_match(expected_turn_id, frame_turn_id)
91
91
 
92
92
 
93
- def is_idle_terminal_allowed(
93
+ def is_status_terminal_allowed(
94
94
  *,
95
95
  expected_turn_id: str | None,
96
96
  frame_turn_id: str | None,
@@ -98,11 +98,14 @@ def is_idle_terminal_allowed(
98
98
  turn_progress_seen: bool,
99
99
  cancellation_seen: bool = False,
100
100
  ) -> bool:
101
- """Gate for ``status=idle`` soft-complete.
101
+ """Gate for ``status=idle`` / ``status=stopped`` soft-complete.
102
102
 
103
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
104
+ Absent status ``turn_id`` is allowed only when cancellation was already seen
105
105
  (legacy cancel finalize); mismatched ids are always rejected.
106
+
107
+ ``stopped`` must use the same progress gate as ``idle`` so leftover prior-turn
108
+ ``stopped`` cannot end a reader that only bound a stale ``status=running``.
106
109
  """
107
110
  if not query_started or not str(expected_turn_id or "").strip():
108
111
  return False
@@ -111,16 +114,63 @@ def is_idle_terminal_allowed(
111
114
  if not turn_ids_match(expected_turn_id, cand):
112
115
  return False
113
116
  return bool(turn_progress_seen or cancellation_seen)
114
- # Absent idle turn_id: never end a healthy turn; cancel path only.
117
+ # Absent status turn_id: never end a healthy turn; cancel path only.
115
118
  return bool(cancellation_seen)
116
119
 
117
120
 
121
+ def is_idle_terminal_allowed(
122
+ *,
123
+ expected_turn_id: str | None,
124
+ frame_turn_id: str | None,
125
+ query_started: bool,
126
+ turn_progress_seen: bool,
127
+ cancellation_seen: bool = False,
128
+ ) -> bool:
129
+ """Gate for ``status=idle`` soft-complete (alias of status terminal rules)."""
130
+ return is_status_terminal_allowed(
131
+ expected_turn_id=expected_turn_id,
132
+ frame_turn_id=frame_turn_id,
133
+ query_started=query_started,
134
+ turn_progress_seen=turn_progress_seen,
135
+ cancellation_seen=cancellation_seen,
136
+ )
137
+
138
+
139
+ def should_bind_running_turn(
140
+ *,
141
+ status_turn: str | None,
142
+ expected_turn_id: str | None,
143
+ last_completed_turn_gen: int = 0,
144
+ ) -> bool:
145
+ """True when a stamped ``status=running`` may bind/rebind ``expected_turn_id``.
146
+
147
+ Refuses generations at or below the last completed floor (leftover prior-turn
148
+ ``running`` when ``expected`` is still unset) and older gens than the current
149
+ bind.
150
+ """
151
+ if not status_turn:
152
+ return False
153
+ new_gen = parse_turn_generation(status_turn)
154
+ if new_gen is not None and last_completed_turn_gen > 0 and new_gen <= last_completed_turn_gen:
155
+ return False
156
+ if expected_turn_id is None:
157
+ return True
158
+ old_gen = parse_turn_generation(expected_turn_id)
159
+ if new_gen is None:
160
+ return old_gen is None
161
+ if old_gen is None:
162
+ return True
163
+ return new_gen >= old_gen
164
+
165
+
118
166
  __all__ = [
119
167
  "format_turn_id",
120
168
  "frame_seq",
121
169
  "frame_turn_id",
122
170
  "is_idle_terminal_allowed",
171
+ "is_status_terminal_allowed",
123
172
  "is_turn_terminal_allowed",
124
173
  "parse_turn_generation",
174
+ "should_bind_running_turn",
125
175
  "turn_ids_match",
126
176
  ]
@@ -17,7 +17,6 @@ from soothe_sdk.ux.loop_stream import is_stream_terminal_wire_dict
17
17
  from soothe_sdk.wire.protocol import decode_websocket_text, encode_websocket_text
18
18
 
19
19
  from soothe_client.errors import DisconnectCause, ReconnectError, StaleLoopError
20
- from soothe_client.intent_hints import validate_loop_input_intent_hint
21
20
  from soothe_client.stream_terminal import TURN_END_CUSTOM_TYPES, stale_pending_frame_label
22
21
 
23
22
  logger = logging.getLogger(__name__)
@@ -939,9 +938,8 @@ class WebSocketClient:
939
938
  loop_id: str,
940
939
  text: str,
941
940
  *,
942
- autonomous: bool = False,
943
- max_iterations: int | None = None,
944
941
  preferred_subagent: str | None = None,
942
+ intake_scope: str | None = None,
945
943
  model: str | None = None,
946
944
  model_params: dict[str, Any] | None = None,
947
945
  router_profile: str | None = None,
@@ -959,9 +957,10 @@ class WebSocketClient:
959
957
  Args:
960
958
  loop_id: Loop identifier for the subscribed loop.
961
959
  text: User input text.
962
- autonomous: Enable autonomous iteration mode.
963
- max_iterations: Maximum iterations for autonomous mode.
964
960
  preferred_subagent: Preferred subagent hint for routing.
961
+ intake_scope: Forced StrangeLoop intake scope
962
+ (``trivial``|``simple``|``complex``). When set, the daemon skips
963
+ Pass 1 and Pass 2 LLM intake.
965
964
  model: Provider:model override string.
966
965
  model_params: Additional model parameters.
967
966
  router_profile: Named router profile for chat-role overlay this turn.
@@ -974,11 +973,6 @@ class WebSocketClient:
974
973
  ``response_schema`` is supported for ``text_completion`` and
975
974
  ``image_to_text``. Agent-path pass-through hints (e.g.
976
975
  ``resume_clarification``, ``skill:foo``) are forwarded unchanged.
977
- Legacy ``direct_llm``, ``quiz``, and ``direct_model`` are rejected
978
- before send.
979
-
980
- Raises:
981
- ValueError: When ``intent_hint`` is a removed legacy value.
982
976
  clarification_mode: clarification relay mode for this turn
983
977
  (``"auto"`` / ``"manual"``). ``None`` lets the daemon fall back
984
978
  to its configured default.
@@ -998,12 +992,10 @@ class WebSocketClient:
998
992
  "loop_id": loop_id,
999
993
  "content": text,
1000
994
  }
1001
- if autonomous:
1002
- params["autonomous"] = True
1003
- if max_iterations is not None:
1004
- params["max_iterations"] = max_iterations
1005
995
  if preferred_subagent is not None:
1006
996
  params["preferred_subagent"] = preferred_subagent
997
+ if intake_scope:
998
+ params["intake_scope"] = intake_scope
1007
999
  if model:
1008
1000
  params["model"] = model
1009
1001
  if model_params:
@@ -1013,9 +1005,6 @@ class WebSocketClient:
1013
1005
  if attachments:
1014
1006
  params["attachments"] = attachments
1015
1007
  if intent_hint:
1016
- hint_error = validate_loop_input_intent_hint(intent_hint)
1017
- if hint_error is not None:
1018
- raise ValueError(hint_error)
1019
1008
  params["intent_hint"] = intent_hint
1020
1009
  if response_schema:
1021
1010
  params["response_schema"] = response_schema
@@ -1243,9 +1232,14 @@ class WebSocketClient:
1243
1232
  except Exception as exc:
1244
1233
  raise StaleLoopError(lid, exc) from exc
1245
1234
 
1246
- async def list_skills(self, *, timeout: float = 15.0) -> dict[str, Any]:
1235
+ async def list_skills(
1236
+ self, *, workspace: str | None = None, timeout: float = 15.0
1237
+ ) -> dict[str, Any]:
1247
1238
  """Request wire-safe skill metadata from the daemon."""
1248
- return await self.request("skills_list", {}, timeout=timeout)
1239
+ params: dict[str, Any] = {}
1240
+ if workspace:
1241
+ params["workspace"] = str(workspace).strip()
1242
+ return await self.request("skills_list", params, timeout=timeout)
1249
1243
 
1250
1244
  async def list_models(self, *, timeout: float = 15.0) -> dict[str, Any]:
1251
1245
  """Request model catalog rows from the daemon host ``SootheConfig``."""
@@ -1550,6 +1544,10 @@ class WebSocketClient:
1550
1544
  """
1551
1545
  self._pending_events.clear()
1552
1546
 
1547
+ def push_pending_event_front(self, event: dict[str, Any]) -> None:
1548
+ """Re-queue ``event`` at the front of ``_pending_events`` (successor bind)."""
1549
+ self._pending_events.appendleft(event)
1550
+
1553
1551
  def peel_stale_pending_control_events(self) -> list[str]:
1554
1552
  """Remove stale handshake/terminal frames left in ``_pending_events`` before a turn.
1555
1553
 
@@ -1,44 +0,0 @@
1
- """``loop_input.intent_hint`` constants and client-side validation."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Final
6
-
7
- TEXT_COMPLETION: Final = "text_completion"
8
- IMAGE_TO_TEXT: Final = "image_to_text"
9
- OCR: Final = "ocr"
10
- EMBED: Final = "embed"
11
-
12
- # Default deliverable phases for turn-ending replies (excludes plan_direct narration).
13
- DEFAULT_DELIVERABLE_PHASES: frozenset[str] = frozenset(
14
- {
15
- "quiz",
16
- "goal_completion",
17
- "chitchat",
18
- "text_completion",
19
- "image_to_text",
20
- "ocr",
21
- "embed",
22
- }
23
- )
24
-
25
- _REMOVED_INTENT_HINT_MESSAGES: dict[str, str] = {
26
- "direct_llm": (
27
- "intent_hint direct_llm is removed; "
28
- "use text_completion (text-only) or image_to_text (with attachments)"
29
- ),
30
- "quiz": "intent_hint quiz is removed; omit intent_hint and let intake classify the turn",
31
- "direct_model": (
32
- "intent_hint direct_model is removed; use text_completion, image_to_text, ocr, or embed"
33
- ),
34
- }
35
-
36
-
37
- def validate_loop_input_intent_hint(hint: str) -> str | None:
38
- """Return an error message when ``hint`` is a removed legacy value.
39
-
40
- Intent-hint values and agent-path pass-through values (e.g.
41
- ``resume_clarification``, ``skill:foo``) are allowed.
42
- """
43
- key = hint.strip().lower()
44
- return _REMOVED_INTENT_HINT_MESSAGES.get(key)