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.
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/PKG-INFO +1 -1
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/__init__.py +0 -2
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/daemon_session.py +153 -49
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/turn_boundary.py +33 -20
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/turn_runner.py +3 -4
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/command_client.py +0 -10
- soothe_client_python-1.0.14/src/soothe_client/intent_hints.py +23 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/protocol_params.py +2 -8
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/session.py +0 -5
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/stream_terminal.py +12 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/turn_boundary.py +54 -4
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/websocket.py +17 -19
- soothe_client_python-1.0.11/src/soothe_client/intent_hints.py +0 -44
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/.gitignore +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/LICENSE +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/README.md +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/pyproject.toml +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/__init__.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/attachments.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/broadcaster.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/card_projection.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/chunk_filter.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/classifier.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/events.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/loop_session_store.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/managed_client.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/observability.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/pool.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/query_gate.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/thinking_step.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/turn.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/errors.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/helpers.py +0 -0
- {soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/py.typed +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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=
|
|
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
|
|
539
|
-
"(loop=%s bound=%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
|
-
(
|
|
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
|
|
786
|
-
fields degrade gracefully (``step_index=0``,
|
|
787
|
-
``status=None``, ``plan=None``)
|
|
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
|
-
|
|
19
|
+
is_status_terminal_allowed,
|
|
20
20
|
is_turn_terminal_allowed,
|
|
21
21
|
parse_turn_generation,
|
|
22
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
72
|
-
return
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/turn_runner.py
RENAMED
|
@@ -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():
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/command_client.py
RENAMED
|
@@ -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
|
+
)
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/protocol_params.py
RENAMED
|
@@ -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
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/stream_terminal.py
RENAMED
|
@@ -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":
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/turn_boundary.py
RENAMED
|
@@ -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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/__init__.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/attachments.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/broadcaster.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/chunk_filter.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/classifier.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/events.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/pool.py
RENAMED
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/query_gate.py
RENAMED
|
File without changes
|
|
File without changes
|
{soothe_client_python-1.0.11 → soothe_client_python-1.0.14}/src/soothe_client/appkit/turn.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|