soothe-client-python 1.0.10__tar.gz → 1.0.13__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.10 → soothe_client_python-1.0.13}/PKG-INFO +1 -1
  2. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/__init__.py +0 -2
  3. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/daemon_session.py +99 -37
  4. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/turn_boundary.py +33 -20
  5. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/turn_runner.py +3 -4
  6. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/command_client.py +18 -17
  7. soothe_client_python-1.0.13/src/soothe_client/intent_hints.py +23 -0
  8. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/protocol_params.py +2 -8
  9. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/session.py +0 -5
  10. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/stream_terminal.py +12 -0
  11. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/turn_boundary.py +54 -4
  12. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/websocket.py +10 -17
  13. soothe_client_python-1.0.10/src/soothe_client/intent_hints.py +0 -44
  14. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/.gitignore +0 -0
  15. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/LICENSE +0 -0
  16. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/README.md +0 -0
  17. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/pyproject.toml +0 -0
  18. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/__init__.py +0 -0
  19. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/attachments.py +0 -0
  20. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/broadcaster.py +0 -0
  21. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/card_projection.py +0 -0
  22. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/chunk_filter.py +0 -0
  23. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/classifier.py +0 -0
  24. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/events.py +0 -0
  25. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/loop_session_store.py +0 -0
  26. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/managed_client.py +0 -0
  27. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/observability.py +0 -0
  28. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/pool.py +0 -0
  29. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/query_gate.py +0 -0
  30. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/thinking_step.py +0 -0
  31. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/appkit/turn.py +0 -0
  32. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/errors.py +0 -0
  33. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/helpers.py +0 -0
  34. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/src/soothe_client/py.typed +0 -0
  35. {soothe_client_python-1.0.10 → soothe_client_python-1.0.13}/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.10
3
+ Version: 1.0.13
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
@@ -364,6 +407,7 @@ class DaemonSession:
364
407
  raises ``TimeoutError`` if the daemon never emits a turn-end
365
408
  signal in time.
366
409
  """
410
+ self._ensure_turn_floor_attrs()
367
411
  self.turn_event_stats = self._new_turn_stats()
368
412
  self.last_turn_end_state = None
369
413
  self.last_turn_cancellation_seen = False
@@ -495,61 +539,73 @@ class DaemonSession:
495
539
  expected_loop_id = loop_ev
496
540
  state = event.get("state", "")
497
541
  if state == "running":
498
- query_started = True
499
- progress_seen = True
500
542
  status_turn = frame_turn_id(event)
501
543
  # Bind only from running frames that carry turn_id.
502
544
  # Pre-admit early running omits turn_id and must not
503
545
  # unlock terminals or poison the expected id.
504
546
  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)
547
+ completed_gen = int(
548
+ getattr(self, "_last_completed_turn_gen", 0) or 0
549
+ )
550
+ if not should_bind_running_turn(
551
+ status_turn=status_turn,
552
+ expected_turn_id=expected_turn_id,
553
+ last_completed_turn_gen=completed_gen,
509
554
  ):
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(
555
+ logger.debug(
556
+ "Ignoring stale/older status=running "
557
+ "(loop=%s turn=%s expected=%s completed_gen=%s)",
558
+ (expected_loop_id or "?")[:16],
559
+ status_turn[-24:],
560
+ (expected_turn_id or "<none>")[-24:],
561
+ completed_gen,
562
+ )
563
+ continue
564
+ if expected_turn_id and status_turn != expected_turn_id:
565
+ # Successor turn admitted while this reader
566
+ # still held a stale/early binding.
567
+ turn_progress_seen = False
568
+ expected_turn_id = status_turn
569
+ self._expected_turn_id = status_turn
570
+ query_started = True
571
+ progress_seen = True
572
+ else:
573
+ # Pre-admit early running: query start, no bind.
574
+ query_started = True
575
+ progress_seen = True
576
+ elif query_started and state in {"stopped", "idle"}:
577
+ status_turn = frame_turn_id(event)
578
+ if not is_status_terminal_allowed(
531
579
  expected_turn_id=expected_turn_id,
532
- frame_turn_id=idle_turn,
580
+ frame_turn_id=status_turn,
533
581
  query_started=query_started,
534
582
  turn_progress_seen=turn_progress_seen,
535
583
  cancellation_seen=self.last_turn_cancellation_seen,
536
584
  ):
537
585
  logger.debug(
538
- "Ignoring premature idle "
539
- "(loop=%s bound=%s idle_turn=%s progress=%s)",
586
+ "Ignoring premature %s "
587
+ "(loop=%s bound=%s status_turn=%s progress=%s)",
588
+ state,
540
589
  (expected_loop_id or "?")[:16],
541
590
  bool(expected_turn_id),
542
- (idle_turn or "<absent>")[-24:],
591
+ (status_turn or "<absent>")[-24:],
543
592
  turn_progress_seen,
544
593
  )
545
594
  continue
546
595
  self.last_turn_end_state = state
547
596
  if ev_seq is not None:
548
597
  self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
598
+ self._note_completed_turn_gen(expected_turn_id)
549
599
  async for chunk in self._drain_stream_events_after_idle(
550
600
  expected_loop_id=expected_loop_id,
551
601
  ):
552
602
  yield chunk
603
+ if self._drain_saw_successor_running:
604
+ # Successor turn already re-queued; continue reading.
605
+ turn_progress_seen = False
606
+ expected_turn_id = None
607
+ self._expected_turn_id = None
608
+ continue
553
609
  break
554
610
  continue
555
611
 
@@ -612,10 +668,16 @@ class DaemonSession:
612
668
  self.last_turn_end_state = "completed"
613
669
  if ev_seq is not None:
614
670
  self._last_turn_end_seq = max(self._last_turn_end_seq, ev_seq)
671
+ self._note_completed_turn_gen(expected_turn_id)
615
672
  async for chunk in self._drain_stream_events_after_idle(
616
673
  expected_loop_id=expected_loop_id,
617
674
  ):
618
675
  yield chunk
676
+ if self._drain_saw_successor_running:
677
+ turn_progress_seen = False
678
+ expected_turn_id = None
679
+ self._expected_turn_id = None
680
+ continue
619
681
  break
620
682
  if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
621
683
  continue
@@ -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():
@@ -158,7 +158,11 @@ class AsyncCommandClient:
158
158
  self._timeout = timeout
159
159
 
160
160
  async def _send_command(
161
- self, command_type: str, payload: dict[str, Any] | None = None
161
+ self,
162
+ command_type: str,
163
+ payload: dict[str, Any] | None = None,
164
+ *,
165
+ timeout: float | None = None,
162
166
  ) -> dict[str, Any]:
163
167
  """Send a protocol-1 request envelope and wait for the response.
164
168
 
@@ -171,6 +175,8 @@ class AsyncCommandClient:
171
175
  Args:
172
176
  command_type: RPC method name (e.g. ``"autopilot_status"``).
173
177
  payload: Structured parameters object for the method.
178
+ timeout: Optional per-call override (seconds). Defaults to the
179
+ client constructor timeout.
174
180
 
175
181
  Returns:
176
182
  The ``result`` dict from the matching ``response`` envelope.
@@ -181,6 +187,7 @@ class AsyncCommandClient:
181
187
  """
182
188
  import websockets
183
189
 
190
+ cmd_timeout = self._timeout if timeout is None else timeout
184
191
  req_id = str(uuid4())
185
192
  envelope = WireEnvelope(
186
193
  type=MessageType.REQUEST.value,
@@ -190,15 +197,15 @@ class AsyncCommandClient:
190
197
  )
191
198
 
192
199
  try:
193
- async with websockets.connect(self._ws_url, open_timeout=self._timeout) as ws:
194
- await _perform_handshake(ws, timeout=self._timeout)
200
+ async with websockets.connect(self._ws_url, open_timeout=cmd_timeout) as ws:
201
+ await _perform_handshake(ws, timeout=cmd_timeout)
195
202
 
196
203
  # Send the protocol-1 request envelope.
197
204
  await ws.send(encode_envelope(envelope))
198
205
 
199
206
  # Wait for the matching response/error envelope.
200
207
  while True:
201
- response_str = await asyncio.wait_for(ws.recv(), timeout=self._timeout)
208
+ response_str = await asyncio.wait_for(ws.recv(), timeout=cmd_timeout)
202
209
  response = decode_envelope(response_str)
203
210
  if not isinstance(response, dict):
204
211
  raise RuntimeError(f"Unexpected response: {response_str!r}")
@@ -227,7 +234,7 @@ class AsyncCommandClient:
227
234
  continue
228
235
 
229
236
  except TimeoutError:
230
- raise RuntimeError(f"Command timeout after {self._timeout}s") from None
237
+ raise RuntimeError(f"Command timeout after {cmd_timeout}s") from None
231
238
  except websockets.exceptions.ConnectionClosedError as exc:
232
239
  raise RuntimeError(f"WebSocket connection closed: {exc}") from exc
233
240
  except RuntimeError:
@@ -249,13 +256,17 @@ class AsyncCommandClient:
249
256
  workspace: str | None = None,
250
257
  rail_id: str | None = None,
251
258
  ) -> dict[str, Any]:
252
- """Submit a new autopilot goal (returns ``goal_id``)."""
259
+ """Submit a new autopilot goal (returns ``goal_id``).
260
+
261
+ Uses a 120s RPC timeout so placement analysis can finish before the
262
+ client gives up (intake may call the verifier LLM).
263
+ """
253
264
  payload: dict[str, Any] = {"description": description, "priority": priority}
254
265
  if workspace:
255
266
  payload["workspace"] = workspace
256
267
  if rail_id:
257
268
  payload["rail_id"] = rail_id
258
- return await self._send_command("autopilot_submit", payload)
269
+ return await self._send_command("autopilot_submit", payload, timeout=120.0)
259
270
 
260
271
  async def autopilot_list_goals(self) -> dict[str, Any]:
261
272
  """List all goals (including non-root children)."""
@@ -320,18 +331,12 @@ class AsyncCommandClient:
320
331
  goal: str,
321
332
  *,
322
333
  workspace: str | None = None,
323
- autonomous: bool = False,
324
- max_iterations: int | None = None,
325
334
  guidance: str | None = None,
326
335
  ) -> dict[str, Any]:
327
336
  """Create a background job (root goal). Returns ``job_id`` + status."""
328
337
  payload: dict[str, Any] = {"goal": goal}
329
338
  if workspace:
330
339
  payload["workspace"] = workspace
331
- if autonomous:
332
- payload["autonomous"] = autonomous
333
- if max_iterations:
334
- payload["max_iterations"] = max_iterations
335
340
  if guidance:
336
341
  payload["guidance"] = guidance
337
342
  return await self._send_command("job_create", payload)
@@ -507,8 +512,6 @@ class CommandClient:
507
512
  goal: str,
508
513
  *,
509
514
  workspace: str | None = None,
510
- autonomous: bool = False,
511
- max_iterations: int | None = None,
512
515
  guidance: str | None = None,
513
516
  ) -> dict[str, Any]:
514
517
  """Create a background job (root goal)."""
@@ -518,8 +521,6 @@ class CommandClient:
518
521
  self._client.job_create(
519
522
  goal,
520
523
  workspace=workspace,
521
- autonomous=autonomous,
522
- max_iterations=max_iterations,
523
524
  guidance=guidance,
524
525
  )
525
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
@@ -1550,6 +1539,10 @@ class WebSocketClient:
1550
1539
  """
1551
1540
  self._pending_events.clear()
1552
1541
 
1542
+ def push_pending_event_front(self, event: dict[str, Any]) -> None:
1543
+ """Re-queue ``event`` at the front of ``_pending_events`` (successor bind)."""
1544
+ self._pending_events.appendleft(event)
1545
+
1553
1546
  def peel_stale_pending_control_events(self) -> list[str]:
1554
1547
  """Remove stale handshake/terminal frames left in ``_pending_events`` before a turn.
1555
1548
 
@@ -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)