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.
- soothe_client/__init__.py +171 -0
- soothe_client/appkit/__init__.py +138 -0
- soothe_client/appkit/attachments.py +118 -0
- soothe_client/appkit/broadcaster.py +116 -0
- soothe_client/appkit/chunk_filter.py +99 -0
- soothe_client/appkit/classifier.py +498 -0
- soothe_client/appkit/daemon_session.py +657 -0
- soothe_client/appkit/events.py +63 -0
- soothe_client/appkit/managed_client.py +163 -0
- soothe_client/appkit/observability.py +29 -0
- soothe_client/appkit/pool.py +258 -0
- soothe_client/appkit/query_gate.py +93 -0
- soothe_client/appkit/session_store.py +100 -0
- soothe_client/appkit/thinking_step.py +147 -0
- soothe_client/appkit/turn.py +362 -0
- soothe_client/appkit/turn_runner.py +565 -0
- soothe_client/errors.py +65 -0
- soothe_client/helpers.py +404 -0
- soothe_client/intent_hints.py +43 -0
- soothe_client/protocol_params.py +604 -0
- soothe_client/schemas.py +55 -0
- soothe_client/session.py +199 -0
- soothe_client/websocket.py +1626 -0
- soothe_client/ws_command_client.py +633 -0
- soothe_client_python-0.9.4.dist-info/METADATA +131 -0
- soothe_client_python-0.9.4.dist-info/RECORD +28 -0
- soothe_client_python-0.9.4.dist-info/WHEEL +4 -0
- soothe_client_python-0.9.4.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
]
|
soothe_client/errors.py
ADDED
|
@@ -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
|
+
]
|