soothe-client-python 0.9.4__py3-none-any.whl

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.
@@ -0,0 +1,565 @@
1
+ """Turn runner for appkit (RFC-629 Layer 1).
2
+
3
+ Executes one query turn end-to-end: acquire a pooled connection, enforce
4
+ single-flight, send loop_input, consume the event stream, classify events,
5
+ resolve the deliverable, persist the reply, and broadcast completion.
6
+
7
+ Supports absolute query timeout, optional idle silence watchdog, soft-complete
8
+ policies (IG-651), and optional attachment compaction before send.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from enum import IntEnum
19
+ from typing import Any
20
+
21
+ from soothe_client.appkit.attachments import CompactImageOptions, compact_attachments
22
+ from soothe_client.appkit.broadcaster import SSEBroadcaster, SSEEvent
23
+ from soothe_client.appkit.classifier import ChatEventTerminal, EventClassifier
24
+ from soothe_client.appkit.pool import ConnectionPool, PooledConn
25
+ from soothe_client.appkit.query_gate import QueryGate
26
+ from soothe_client.appkit.session_store import SessionMessage, SessionStore
27
+ from soothe_client.intent_hints import validate_loop_input_intent_hint
28
+
29
+ Attachment = dict[str, Any]
30
+ OnComplete = Callable[[str, str, str, str, float], None]
31
+ OnError = Callable[[str, str, BaseException], None]
32
+ InputBuilder = Callable[
33
+ [str, str, list[Attachment] | None, "InputOpts | None"],
34
+ dict[str, Any],
35
+ ]
36
+
37
+
38
+ class ErrQueryTimeout(Exception): # noqa: N818 — match Go/TS name
39
+ """Raised when a turn exceeds the configured query timeout (fail policy)."""
40
+
41
+ def __init__(self) -> None:
42
+ super().__init__("appkit: query timeout")
43
+
44
+
45
+ class ErrIdleTimeout(Exception): # noqa: N818 — match Go name
46
+ """Raised when no events arrive within IdleTimeout (fail policy)."""
47
+
48
+ def __init__(self) -> None:
49
+ super().__init__("appkit: idle timeout")
50
+
51
+
52
+ class TimeoutPolicy(IntEnum):
53
+ """Fail vs soft-complete behaviour for idle, query, and stream-close."""
54
+
55
+ FAIL = 0
56
+ SOFT_COMPLETE = 1
57
+
58
+
59
+ StreamClosePolicy = TimeoutPolicy
60
+ STREAM_CLOSE_FAIL = TimeoutPolicy.FAIL
61
+ STREAM_CLOSE_SOFT_COMPLETE = TimeoutPolicy.SOFT_COMPLETE
62
+
63
+
64
+ @dataclass(slots=True)
65
+ class TurnConfig:
66
+ """Configures a ``TurnRunner``."""
67
+
68
+ query_timeout_s: float = 30 * 60
69
+ idle_timeout_s: float = 0.0
70
+ min_idle_timeout_with_attachments_s: float = 0.0
71
+ on_idle_timeout: TimeoutPolicy = TimeoutPolicy.FAIL
72
+ on_query_timeout: TimeoutPolicy = TimeoutPolicy.FAIL
73
+ on_stream_close: TimeoutPolicy = TimeoutPolicy.FAIL
74
+ compact_attachments_before_send: bool = False
75
+ compact_image_opts: CompactImageOptions | None = None
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class InputOpts:
80
+ """Optional daemon hints on a ``loop_input`` payload."""
81
+
82
+ intent_hint: str | None = None
83
+ preferred_subagent: str | None = None
84
+ response_schema: dict[str, Any] | None = None
85
+ response_schema_name: str | None = None
86
+ response_schema_strict: bool | None = None
87
+
88
+
89
+ def input_message_for_loop(
90
+ text: str,
91
+ loop_id: str,
92
+ attachments: list[Attachment] | None = None,
93
+ opts: InputOpts | None = None,
94
+ ) -> dict[str, Any]:
95
+ """Build a ``loop_input`` payload with optional attachments and hints."""
96
+ msg: dict[str, Any] = {"type": "loop_input", "content": text}
97
+ if loop_id:
98
+ msg["loop_id"] = loop_id
99
+ if attachments:
100
+ msg["attachments"] = attachments
101
+ if opts is not None:
102
+ if opts.intent_hint and opts.intent_hint.strip():
103
+ hint_err = validate_loop_input_intent_hint(opts.intent_hint)
104
+ if hint_err:
105
+ raise ValueError(hint_err)
106
+ msg["intent_hint"] = opts.intent_hint.strip()
107
+ if opts.preferred_subagent and opts.preferred_subagent.strip():
108
+ msg["preferred_subagent"] = opts.preferred_subagent.strip()
109
+ if opts.response_schema:
110
+ msg["response_schema"] = opts.response_schema
111
+ if opts.response_schema_name and opts.response_schema_name.strip():
112
+ msg["response_schema_name"] = opts.response_schema_name.strip()
113
+ if opts.response_schema_strict is not None:
114
+ msg["response_schema_strict"] = opts.response_schema_strict
115
+ return msg
116
+
117
+
118
+ def idle_timeout_for_turn(cfg: TurnConfig, has_attachments: bool) -> float:
119
+ """Compute effective idle timeout seconds for one turn (0 = disabled)."""
120
+ idle = cfg.idle_timeout_s
121
+ if idle <= 0:
122
+ return 0.0
123
+ floor = cfg.min_idle_timeout_with_attachments_s
124
+ if has_attachments and floor > 0 and idle < floor:
125
+ return floor
126
+ return idle
127
+
128
+
129
+ class TurnRunner:
130
+ """Execute one query turn end-to-end."""
131
+
132
+ def __init__(
133
+ self,
134
+ pool: ConnectionPool,
135
+ gate: QueryGate,
136
+ classifier: EventClassifier,
137
+ store: SessionStore,
138
+ broadcaster: SSEBroadcaster | None,
139
+ cfg: TurnConfig | None = None,
140
+ ) -> None:
141
+ self._pool = pool
142
+ self._gate = gate
143
+ self._classifier = classifier
144
+ self._store = store
145
+ self._broadcaster = broadcaster
146
+ base = cfg or TurnConfig()
147
+ timeout = base.query_timeout_s if base.query_timeout_s > 0 else 30 * 60
148
+ self._cfg = TurnConfig(
149
+ query_timeout_s=timeout,
150
+ idle_timeout_s=base.idle_timeout_s,
151
+ min_idle_timeout_with_attachments_s=base.min_idle_timeout_with_attachments_s,
152
+ on_idle_timeout=base.on_idle_timeout,
153
+ on_query_timeout=base.on_query_timeout,
154
+ on_stream_close=base.on_stream_close,
155
+ compact_attachments_before_send=base.compact_attachments_before_send,
156
+ compact_image_opts=base.compact_image_opts,
157
+ )
158
+ self._build_input: InputBuilder = input_message_for_loop
159
+ self._on_complete: OnComplete | None = None
160
+ self._on_error: OnError | None = None
161
+
162
+ def with_input_builder(self, builder: InputBuilder) -> TurnRunner:
163
+ """Override the loop_input payload builder."""
164
+ self._build_input = builder
165
+ return self
166
+
167
+ def with_on_complete(self, hook: OnComplete) -> TurnRunner:
168
+ """Set a completion hook (runs inline on success)."""
169
+ self._on_complete = hook
170
+ return self
171
+
172
+ def with_on_error(self, hook: OnError) -> TurnRunner:
173
+ """Set an error hook (runs inline on failure)."""
174
+ self._on_error = hook
175
+ return self
176
+
177
+ async def execute(
178
+ self,
179
+ session_id: str,
180
+ message: str,
181
+ user_id: str,
182
+ workspace_id: str,
183
+ attachments: list[Attachment] | None = None,
184
+ opts: InputOpts | None = None,
185
+ *,
186
+ cancel_event: asyncio.Event | None = None,
187
+ ) -> None:
188
+ """Run one query turn; persist and broadcast the result (no return value)."""
189
+ try:
190
+ conn = await self._pool.acquire(session_id, workspace_id, user_id)
191
+ except Exception as err:
192
+ await self._persist_failed(session_id, "", err)
193
+ self._broadcast_error(session_id, err)
194
+ if self._on_error is not None:
195
+ self._on_error(session_id, "", err)
196
+ raise
197
+
198
+ loop_id = conn.get_loop_id()
199
+ query_timed_out = asyncio.Event()
200
+ query_timer = asyncio.create_task(
201
+ self._arm_sleep(query_timed_out, self._cfg.query_timeout_s)
202
+ )
203
+
204
+ async def send_cancel() -> None:
205
+ await self._send_loop_cancel(conn, loop_id)
206
+
207
+ def local_cancel() -> None:
208
+ query_timed_out.set()
209
+
210
+ try:
211
+ await self._gate.acquire(session_id, local_cancel, send_cancel)
212
+ except Exception as err:
213
+ query_timer.cancel()
214
+ with contextlib.suppress(asyncio.CancelledError):
215
+ await query_timer
216
+ await self._pool.release(session_id)
217
+ await self._persist_failed(session_id, loop_id, err)
218
+ self._broadcast_error(session_id, err)
219
+ if self._on_error is not None:
220
+ self._on_error(session_id, loop_id, err)
221
+ raise
222
+
223
+ idle_for_turn = idle_timeout_for_turn(self._cfg, bool(attachments))
224
+ idle_fired = asyncio.Event()
225
+ idle_task: asyncio.Task[None] | None = None
226
+
227
+ def arm_idle() -> None:
228
+ nonlocal idle_task
229
+ if idle_for_turn <= 0:
230
+ return
231
+ if idle_task is not None:
232
+ idle_task.cancel()
233
+ idle_fired.clear()
234
+ idle_task = asyncio.create_task(self._arm_sleep(idle_fired, idle_for_turn))
235
+
236
+ try:
237
+ atts = attachments
238
+ if self._cfg.compact_attachments_before_send and atts:
239
+ atts = compact_attachments(atts, self._cfg.compact_image_opts)
240
+
241
+ input_msg = self._build_input(message, loop_id, atts, opts)
242
+ try:
243
+ await conn.client.send_message(input_msg)
244
+ except Exception as err:
245
+ await self._persist_failed(session_id, loop_id, err)
246
+ self._broadcast_error(session_id, err)
247
+ if self._on_error is not None:
248
+ self._on_error(session_id, loop_id, err)
249
+ raise
250
+
251
+ event_stream = conn.event_stream
252
+ if event_stream is None:
253
+ err = RuntimeError(
254
+ f"missing event stream for session {session_id} (loop {loop_id})"
255
+ )
256
+ await self._persist_failed(session_id, loop_id, err)
257
+ self._broadcast_error(session_id, err)
258
+ if self._on_error is not None:
259
+ self._on_error(session_id, loop_id, err)
260
+ raise err
261
+
262
+ assistant_content = ""
263
+ started_at = time.time()
264
+ agen = event_stream.__aiter__()
265
+ arm_idle()
266
+
267
+ while True:
268
+ if cancel_event is not None and cancel_event.is_set():
269
+ err = RuntimeError("aborted")
270
+ await self._fail_turn(session_id, loop_id, err)
271
+ raise err
272
+
273
+ if query_timed_out.is_set():
274
+ with contextlib.suppress(Exception):
275
+ await self._send_loop_cancel(conn, loop_id)
276
+ await self._finish_timeout(
277
+ session_id,
278
+ loop_id,
279
+ assistant_content,
280
+ started_at,
281
+ ErrQueryTimeout(),
282
+ "query_timeout",
283
+ self._cfg.on_query_timeout,
284
+ )
285
+ return
286
+
287
+ if idle_fired.is_set():
288
+ with contextlib.suppress(Exception):
289
+ await self._send_loop_cancel(conn, loop_id)
290
+ await self._finish_timeout(
291
+ session_id,
292
+ loop_id,
293
+ assistant_content,
294
+ started_at,
295
+ ErrIdleTimeout(),
296
+ "idle_timeout",
297
+ self._cfg.on_idle_timeout,
298
+ )
299
+ return
300
+
301
+ next_task = asyncio.create_task(agen.__anext__())
302
+ waiters: list[asyncio.Task[Any]] = [
303
+ next_task,
304
+ asyncio.create_task(query_timed_out.wait()),
305
+ ]
306
+ idle_wait: asyncio.Task[Any] | None = None
307
+ if idle_for_turn > 0:
308
+ idle_wait = asyncio.create_task(idle_fired.wait())
309
+ waiters.append(idle_wait)
310
+ caller_wait: asyncio.Task[Any] | None = None
311
+ if cancel_event is not None:
312
+ caller_wait = asyncio.create_task(cancel_event.wait())
313
+ waiters.append(caller_wait)
314
+
315
+ done, pending = await asyncio.wait(
316
+ waiters,
317
+ return_when=asyncio.FIRST_COMPLETED,
318
+ )
319
+ for task in pending:
320
+ task.cancel()
321
+ with contextlib.suppress(asyncio.CancelledError):
322
+ await task
323
+
324
+ if waiters[1] in done and query_timed_out.is_set():
325
+ with contextlib.suppress(Exception):
326
+ await self._send_loop_cancel(conn, loop_id)
327
+ await self._finish_timeout(
328
+ session_id,
329
+ loop_id,
330
+ assistant_content,
331
+ started_at,
332
+ ErrQueryTimeout(),
333
+ "query_timeout",
334
+ self._cfg.on_query_timeout,
335
+ )
336
+ return
337
+
338
+ if idle_wait is not None and idle_wait in done and idle_fired.is_set():
339
+ with contextlib.suppress(Exception):
340
+ await self._send_loop_cancel(conn, loop_id)
341
+ await self._finish_timeout(
342
+ session_id,
343
+ loop_id,
344
+ assistant_content,
345
+ started_at,
346
+ ErrIdleTimeout(),
347
+ "idle_timeout",
348
+ self._cfg.on_idle_timeout,
349
+ )
350
+ return
351
+
352
+ if caller_wait is not None and caller_wait in done:
353
+ err = RuntimeError("aborted")
354
+ await self._fail_turn(session_id, loop_id, err)
355
+ raise err
356
+
357
+ try:
358
+ msg = next_task.result()
359
+ except StopAsyncIteration:
360
+ if (
361
+ self._cfg.on_stream_close == TimeoutPolicy.SOFT_COMPLETE
362
+ and assistant_content.strip()
363
+ ):
364
+ await self._complete_turn(
365
+ session_id,
366
+ loop_id,
367
+ assistant_content.strip(),
368
+ started_at,
369
+ "stream_closed",
370
+ )
371
+ return
372
+ err = RuntimeError("event stream closed")
373
+ await self._fail_turn(session_id, loop_id, err)
374
+ raise err from None
375
+
376
+ arm_idle()
377
+
378
+ event_result = self._classifier.classify(msg, assistant_content)
379
+ if (
380
+ event_result.err is not None
381
+ and event_result.terminal == ChatEventTerminal.FAILED_COMPLETE
382
+ ):
383
+ await self._fail_turn(session_id, loop_id, event_result.err)
384
+ raise event_result.err
385
+
386
+ step = (event_result.thinking_step or "").strip()
387
+ if step:
388
+ self._broadcast_thinking_step(session_id, step)
389
+
390
+ if event_result.content:
391
+ if event_result.content.startswith(assistant_content):
392
+ delta = event_result.content[len(assistant_content) :]
393
+ assistant_content = event_result.content
394
+ else:
395
+ delta = event_result.content
396
+ assistant_content += event_result.content
397
+ if delta:
398
+ self._broadcast_delta(session_id, delta)
399
+
400
+ final, deliverable = self._classifier.resolve_deliverable_final_content(
401
+ event_result,
402
+ assistant_content,
403
+ )
404
+ if deliverable:
405
+ await self._complete_turn(
406
+ session_id,
407
+ loop_id,
408
+ final,
409
+ started_at,
410
+ event_result.completion_event or "",
411
+ )
412
+ return
413
+ finally:
414
+ query_timer.cancel()
415
+ with contextlib.suppress(asyncio.CancelledError):
416
+ await query_timer
417
+ if idle_task is not None:
418
+ idle_task.cancel()
419
+ with contextlib.suppress(asyncio.CancelledError):
420
+ await idle_task
421
+ await self._gate.release(session_id)
422
+
423
+ async def _arm_sleep(self, flag: asyncio.Event, seconds: float) -> None:
424
+ try:
425
+ await asyncio.sleep(seconds)
426
+ flag.set()
427
+ except asyncio.CancelledError:
428
+ return
429
+
430
+ async def _finish_timeout(
431
+ self,
432
+ session_id: str,
433
+ loop_id: str,
434
+ content: str,
435
+ started_at: float,
436
+ fail_err: BaseException,
437
+ completion_event: str,
438
+ policy: TimeoutPolicy,
439
+ ) -> None:
440
+ if policy == TimeoutPolicy.SOFT_COMPLETE and content.strip():
441
+ await self._complete_turn(
442
+ session_id,
443
+ loop_id,
444
+ content.strip(),
445
+ started_at,
446
+ completion_event,
447
+ )
448
+ return
449
+ await self._fail_turn(session_id, loop_id, fail_err)
450
+ raise fail_err
451
+
452
+ async def _complete_turn(
453
+ self,
454
+ session_id: str,
455
+ loop_id: str,
456
+ final: str,
457
+ started_at: float,
458
+ completion_event: str,
459
+ ) -> None:
460
+ elapsed_ms = (time.time() - started_at) * 1000.0
461
+ await self._persist_response(session_id, loop_id, final, started_at, completion_event)
462
+ self._broadcast_complete(session_id, final)
463
+ if self._on_complete is not None:
464
+ self._on_complete(session_id, loop_id, final, completion_event, elapsed_ms)
465
+
466
+ async def _fail_turn(
467
+ self,
468
+ session_id: str,
469
+ loop_id: str,
470
+ err: BaseException,
471
+ ) -> None:
472
+ await self._persist_failed(session_id, loop_id, err)
473
+ self._broadcast_error(session_id, err)
474
+ if self._on_error is not None:
475
+ self._on_error(session_id, loop_id, err)
476
+
477
+ async def _send_loop_cancel(self, conn: PooledConn, loop_id: str) -> None:
478
+ lid = (loop_id or "").strip()
479
+ if not lid:
480
+ return
481
+ cancel_msg = {"type": "command_request", "command": "cancel", "loop_id": lid}
482
+ await conn.client.send_message(cancel_msg)
483
+
484
+ async def _persist_response(
485
+ self,
486
+ session_id: str,
487
+ loop_id: str,
488
+ content: str,
489
+ started_at: float,
490
+ completion_event: str,
491
+ ) -> None:
492
+ now = time.time()
493
+ msg = SessionMessage(
494
+ role="assistant",
495
+ content=content,
496
+ metadata={
497
+ "started_at": started_at,
498
+ "completed_at": now,
499
+ "duration_ms": (now - started_at) * 1000.0,
500
+ "status": "completed",
501
+ "completion_event": completion_event,
502
+ "deliverable": True,
503
+ "loop_id": loop_id,
504
+ },
505
+ )
506
+ with contextlib.suppress(Exception):
507
+ await self._store.append_message(session_id, msg)
508
+
509
+ async def _persist_failed(
510
+ self,
511
+ session_id: str,
512
+ _loop_id: str,
513
+ err: BaseException,
514
+ ) -> None:
515
+ msg = SessionMessage(
516
+ role="error",
517
+ content=str(err),
518
+ metadata={"status": "failed", "error_message": str(err)},
519
+ )
520
+ with contextlib.suppress(Exception):
521
+ await self._store.append_message(session_id, msg)
522
+
523
+ def _broadcast_thinking_step(self, session_id: str, step: str) -> None:
524
+ if self._broadcaster is None:
525
+ return
526
+ self._broadcaster.broadcast(
527
+ session_id,
528
+ SSEEvent(type="thinking_step", data=f"{step}\n"),
529
+ )
530
+
531
+ def _broadcast_delta(self, session_id: str, delta: str) -> None:
532
+ if self._broadcaster is None or not delta:
533
+ return
534
+ self._broadcaster.broadcast(session_id, SSEEvent(type="delta", data=delta))
535
+
536
+ def _broadcast_complete(self, session_id: str, content: str) -> None:
537
+ if self._broadcaster is None:
538
+ return
539
+ self._broadcaster.broadcast(session_id, SSEEvent(type="complete", data=content))
540
+
541
+ def _broadcast_error(self, session_id: str, err: BaseException) -> None:
542
+ if self._broadcaster is None:
543
+ return
544
+ self._broadcaster.broadcast(
545
+ session_id,
546
+ SSEEvent(type="query_error", data=str(err)),
547
+ )
548
+
549
+
550
+ __all__ = [
551
+ "STREAM_CLOSE_FAIL",
552
+ "STREAM_CLOSE_SOFT_COMPLETE",
553
+ "Attachment",
554
+ "ErrIdleTimeout",
555
+ "ErrQueryTimeout",
556
+ "InputOpts",
557
+ "OnComplete",
558
+ "OnError",
559
+ "StreamClosePolicy",
560
+ "TimeoutPolicy",
561
+ "TurnConfig",
562
+ "TurnRunner",
563
+ "idle_timeout_for_turn",
564
+ "input_message_for_loop",
565
+ ]
@@ -0,0 +1,65 @@
1
+ """Client-facing error types for soothe-client-python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+ from typing import Any
7
+
8
+
9
+ class DisconnectCause(IntEnum):
10
+ """Distinguishes clean vs unclean connection loss."""
11
+
12
+ UNCLEAN = 0
13
+ CLEAN = 1
14
+
15
+
16
+ def disconnect_cause_name(cause: DisconnectCause) -> str:
17
+ """Human-readable cause name for logging."""
18
+ return "clean" if cause == DisconnectCause.CLEAN else "unclean"
19
+
20
+
21
+ class DaemonError(Exception):
22
+ """Error reported by the daemon (protocol-1 structured error object)."""
23
+
24
+ def __init__(
25
+ self,
26
+ code: int,
27
+ message: str,
28
+ data: Any | None = None,
29
+ ) -> None:
30
+ super().__init__(f"daemon error [{code}]: {message}")
31
+ self.code = code
32
+ self.daemon_message = message
33
+ self.data = data
34
+
35
+
36
+ class StaleLoopError(Exception):
37
+ """Loop accepted reattach but failed the ``loop_get`` liveness probe."""
38
+
39
+ def __init__(self, loop_id: str, cause: BaseException | None = None) -> None:
40
+ detail = f": {cause}" if cause is not None else ""
41
+ super().__init__(
42
+ f"stale loop {loop_id}: reattach accepted but liveness probe failed{detail}"
43
+ )
44
+ self.loop_id = loop_id
45
+ self.cause = cause
46
+
47
+
48
+ class ReconnectError(Exception):
49
+ """Bounded reconnect attempts exhausted."""
50
+
51
+ def __init__(self, url: str, attempts: int, cause: BaseException | None = None) -> None:
52
+ detail = f": {cause}" if cause is not None else ""
53
+ super().__init__(f"reconnect to {url} failed after {attempts} attempts{detail}")
54
+ self.url = url
55
+ self.attempts = attempts
56
+ self.cause = cause
57
+
58
+
59
+ __all__ = [
60
+ "DaemonError",
61
+ "DisconnectCause",
62
+ "ReconnectError",
63
+ "StaleLoopError",
64
+ "disconnect_cause_name",
65
+ ]