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,100 @@
1
+ """Persistence seam for appkit (RFC-629 Layer 1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol, runtime_checkable
6
+
7
+
8
+ class SessionEntry:
9
+ """Persisted mapping between an application session id and a daemon loop id."""
10
+
11
+ __slots__ = (
12
+ "workspace_id",
13
+ "session_id",
14
+ "loop_id",
15
+ "session_type",
16
+ "purpose",
17
+ "is_active",
18
+ "reset_count",
19
+ "last_used_at",
20
+ )
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ workspace_id: str,
26
+ session_id: str,
27
+ loop_id: str,
28
+ session_type: str,
29
+ purpose: str | None = None,
30
+ is_active: bool = True,
31
+ reset_count: int = 0,
32
+ last_used_at: float = 0.0,
33
+ ) -> None:
34
+ self.workspace_id = workspace_id
35
+ self.session_id = session_id
36
+ self.loop_id = loop_id
37
+ self.session_type = session_type
38
+ self.purpose = purpose
39
+ self.is_active = is_active
40
+ self.reset_count = reset_count
41
+ self.last_used_at = last_used_at
42
+
43
+
44
+ class SessionMessage:
45
+ """A persisted message row (assistant reply or error)."""
46
+
47
+ __slots__ = ("id", "role", "content", "context", "metadata")
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ role: str,
53
+ content: str,
54
+ id: str | None = None, # noqa: A002
55
+ context: Any = None,
56
+ metadata: dict[str, Any] | None = None,
57
+ ) -> None:
58
+ self.id = id
59
+ self.role = role
60
+ self.content = content
61
+ self.context = context
62
+ self.metadata = metadata
63
+
64
+
65
+ @runtime_checkable
66
+ class SessionStore(Protocol):
67
+ """Persistence seam between appkit and the application's storage backend."""
68
+
69
+ async def get_session(self, session_id: str) -> SessionEntry | None:
70
+ """Return the persisted entry for ``session_id``, or None."""
71
+ ...
72
+
73
+ async def create_session(
74
+ self,
75
+ workspace_id: str,
76
+ session_id: str,
77
+ loop_id: str,
78
+ session_type: str,
79
+ ) -> None:
80
+ """Persist a new session↔loop mapping."""
81
+ ...
82
+
83
+ async def update_last_used(self, session_id: str) -> None:
84
+ """Stamp the session's last-used timestamp."""
85
+ ...
86
+
87
+ async def increment_reset_count(self, session_id: str) -> None:
88
+ """Bump the reset counter (fresh bootstrap vs reattach)."""
89
+ ...
90
+
91
+ async def get_loop_id_for_session(self, session_id: str) -> tuple[str, bool]:
92
+ """Return ``(loop_id, ok)``; ``ok is False`` triggers fresh ``loop_new``."""
93
+ ...
94
+
95
+ async def append_message(self, session_id: str, message: SessionMessage) -> None:
96
+ """Write a message row for the session."""
97
+ ...
98
+
99
+
100
+ __all__ = ["SessionEntry", "SessionMessage", "SessionStore"]
@@ -0,0 +1,147 @@
1
+ """Thinking-step extraction for appkit (RFC-629 Layer 1).
2
+
3
+ Maps an allowlisted progress event to one structured UI line. Free-form
4
+ streams (tokens, reports, reasoning) are excluded. Ported from Go/TS appkit.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from typing import Any
11
+
12
+ MAX_THINKING_STEP_RUNES = 280
13
+
14
+ DEFAULT_THINKING_STEP_EVENTS: frozenset[str] = frozenset(
15
+ {
16
+ "soothe.cognition.plan.step.started",
17
+ "soothe.cognition.plan.step.completed",
18
+ "soothe.cognition.plan.step.failed",
19
+ "soothe.lifecycle.iteration.started",
20
+ "soothe.agent.loop.step.started",
21
+ "soothe.agent.loop.started",
22
+ "soothe.cognition.plan.batch.started",
23
+ "soothe.cognition.plan.created",
24
+ "soothe.cognition.goal.created",
25
+ "soothe.tool.execution.started",
26
+ }
27
+ )
28
+
29
+
30
+ def extract_thinking_step(
31
+ event_type: str,
32
+ data: Mapping[str, Any] | None,
33
+ allow: frozenset[str] | set[str] | None = None,
34
+ ) -> tuple[str, bool]:
35
+ """Map an allowlisted progress event to one structured UI line.
36
+
37
+ Args:
38
+ event_type: Wire event type string.
39
+ data: Event payload map.
40
+ allow: Optional override of the default thinking-step allowlist.
41
+
42
+ Returns:
43
+ ``(line, True)`` for a recognized event; ``("", False)`` otherwise.
44
+ """
45
+ if not event_type or data is None:
46
+ return "", False
47
+ et = event_type.strip()
48
+ if not et:
49
+ return "", False
50
+
51
+ allowlist = allow if allow is not None else DEFAULT_THINKING_STEP_EVENTS
52
+ if et not in allowlist:
53
+ return "", False
54
+
55
+ line = ""
56
+ if et == "soothe.cognition.plan.step.started":
57
+ line = _format_plan_step_line(data, "")
58
+ elif et == "soothe.cognition.plan.step.completed":
59
+ line = _format_plan_step_line(data, "done")
60
+ elif et == "soothe.cognition.plan.step.failed":
61
+ step_id = _str_field(data, "step_id")
62
+ err_msg = _str_field(data, "error")
63
+ if step_id and err_msg:
64
+ line = f"Step {step_id} failed: {err_msg}"
65
+ elif step_id:
66
+ line = f"Step {step_id} failed"
67
+ elif err_msg:
68
+ line = f"Step failed: {err_msg}"
69
+ elif et == "soothe.agent.loop.step.started":
70
+ line = _format_agent_step_line(data, "")
71
+ elif et == "soothe.cognition.plan.batch.started":
72
+ n = data.get("parallel_count")
73
+ if isinstance(n, (int, float)) and n > 0:
74
+ line = f"Running {int(n)} steps in parallel"
75
+ elif et in ("soothe.cognition.plan.created", "soothe.agent.loop.started"):
76
+ g = _str_field(data, "goal")
77
+ if g:
78
+ line = f"Goal: {g}"
79
+ elif et == "soothe.cognition.goal.created":
80
+ g = _str_field(data, "friendly_message", "description")
81
+ if g:
82
+ line = f"Goal: {g}"
83
+ elif et == "soothe.lifecycle.iteration.started":
84
+ g = _str_field(data, "goal_description")
85
+ if g:
86
+ line = f"Iteration: {g}"
87
+ elif et == "soothe.tool.execution.started":
88
+ name = _str_field(data, "tool_name", "name")
89
+ if name:
90
+ line = f"Tool: {name}"
91
+ else:
92
+ return "", False
93
+
94
+ line = line.strip()
95
+ if not line:
96
+ return "", False
97
+ runes = list(line)
98
+ if len(runes) > MAX_THINKING_STEP_RUNES:
99
+ line = "".join(runes[:MAX_THINKING_STEP_RUNES]) + "…"
100
+ return line, True
101
+
102
+
103
+ def _format_plan_step_line(data: Mapping[str, Any], suffix: str) -> str:
104
+ step_id = _str_field(data, "step_id")
105
+ desc = _str_field(data, "description")
106
+ if step_id and suffix:
107
+ return f"Step {step_id}: {suffix}"
108
+ if step_id and desc:
109
+ return f"Step {step_id}: {desc}"
110
+ if step_id:
111
+ return f"Step {step_id}"
112
+ if desc and suffix:
113
+ return f"Step: {suffix}"
114
+ if desc:
115
+ return f"Step: {desc}"
116
+ if suffix:
117
+ return f"Step: {suffix}"
118
+ return ""
119
+
120
+
121
+ def _format_agent_step_line(data: Mapping[str, Any], suffix: str) -> str:
122
+ step_id = _str_field(data, "step_id")
123
+ desc = _str_field(data, "description")
124
+ if step_id and desc:
125
+ return f"Step {step_id}: {desc}"
126
+ if desc:
127
+ return f"Step: {suffix}" if suffix else f"Step: {desc}"
128
+ if step_id:
129
+ return f"Step {step_id}"
130
+ return ""
131
+
132
+
133
+ def _str_field(data: Mapping[str, Any], *keys: str) -> str:
134
+ for key in keys:
135
+ val = data.get(key)
136
+ if isinstance(val, str):
137
+ s = val.strip()
138
+ if s:
139
+ return s
140
+ return ""
141
+
142
+
143
+ __all__ = [
144
+ "DEFAULT_THINKING_STEP_EVENTS",
145
+ "MAX_THINKING_STEP_RUNES",
146
+ "extract_thinking_step",
147
+ ]
@@ -0,0 +1,362 @@
1
+ """Decouple daemon stream ingestion from chunk processing and UI application.
2
+
3
+ Three stages run concurrently during a turn:
4
+
5
+ 1. **Reader** (async): pulls chunks from the daemon and enqueues them.
6
+ 2. **Processor** (dedicated thread): runs CPU-heavy parsing without blocking the UI.
7
+ 3. **Applier** (async): consumes prepared plans and performs product-specific updates.
8
+
9
+ High-priority chunks (tool wire, loop step events) are applied before low-priority text.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import queue
17
+ import threading
18
+ import time
19
+ from collections.abc import AsyncIterator, Callable
20
+ from typing import Any, Generic, Protocol, TypeVar
21
+
22
+ from soothe_sdk.ux.loop_stream import assistant_output_phase
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ T = TypeVar("T")
27
+ _SENTINEL = object()
28
+
29
+ # Lower number = higher priority (matches asyncio.PriorityQueue ordering).
30
+ PRIORITY_CRITICAL = -1 # step lifecycle / plan progress — never evict from queue
31
+ PRIORITY_HIGH = 0
32
+ PRIORITY_NORMAL = 1
33
+ PRIORITY_LOW = 2
34
+
35
+ _DEFAULT_BATCH_SIZE = 10
36
+ _DEFAULT_BATCH_DELAY_MS = 50
37
+ _DEFAULT_INBOUND_MAXSIZE = 2048
38
+ _DEFAULT_OUTBOUND_MAXSIZE = 1024
39
+
40
+
41
+ class SupportsTurnLatency(Protocol):
42
+ """Minimal latency recorder used by ``run_turn_pipeline``."""
43
+
44
+ turn_start_monotonic: float
45
+
46
+ def record_first_chunk(self) -> None:
47
+ """Record time-to-first-chunk once per turn."""
48
+
49
+ def record_goal_completion(self) -> None:
50
+ """Record synthesis-visible latency once per turn."""
51
+
52
+
53
+ class TurnApplyBatcher(Generic[T]):
54
+ """Accumulate prepared chunks for batched apply.
55
+
56
+ HIGH priority items trigger immediate flush for responsiveness.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ max_batch_size: int = _DEFAULT_BATCH_SIZE,
63
+ max_batch_delay_ms: int = _DEFAULT_BATCH_DELAY_MS,
64
+ ) -> None:
65
+ self._max_batch_size = max_batch_size
66
+ self._max_batch_delay_s = max_batch_delay_ms / 1000.0
67
+ self._pending: list[T] = []
68
+ self._high_priority_count: int = 0
69
+ self._last_flush_monotonic: float = time.monotonic()
70
+
71
+ def add(self, prepared: T) -> bool:
72
+ """Add chunk to batch. Returns True if batch should flush now."""
73
+ self._pending.append(prepared)
74
+ priority = getattr(prepared, "priority", PRIORITY_LOW)
75
+ if priority <= PRIORITY_HIGH:
76
+ self._high_priority_count += 1
77
+
78
+ now = time.monotonic()
79
+ if len(self._pending) >= self._max_batch_size:
80
+ return True
81
+ if self._high_priority_count > 0:
82
+ return True
83
+ if now - self._last_flush_monotonic >= self._max_batch_delay_s:
84
+ return True
85
+ return False
86
+
87
+ def flush(self) -> list[T]:
88
+ """Return accumulated batch and reset state."""
89
+ batch = list(self._pending)
90
+ self._pending.clear()
91
+ self._high_priority_count = 0
92
+ self._last_flush_monotonic = time.monotonic()
93
+ return batch
94
+
95
+ def has_pending(self) -> bool:
96
+ """Return True if there are pending items."""
97
+ return bool(self._pending)
98
+
99
+ @property
100
+ def pending_count(self) -> int:
101
+ """Return number of pending items."""
102
+ return len(self._pending)
103
+
104
+
105
+ class TurnEventPipeline(Generic[T]):
106
+ """Bridge daemon chunk ingestion, background processing, and UI application."""
107
+
108
+ def __init__(
109
+ self,
110
+ loop: asyncio.AbstractEventLoop,
111
+ *,
112
+ inbound_maxsize: int = _DEFAULT_INBOUND_MAXSIZE,
113
+ outbound_maxsize: int = _DEFAULT_OUTBOUND_MAXSIZE,
114
+ ) -> None:
115
+ self._loop = loop
116
+ self._inbound: queue.Queue[Any] = queue.Queue(maxsize=inbound_maxsize)
117
+ self._outbound: queue.PriorityQueue[tuple[int, int, Any]] = queue.PriorityQueue(
118
+ maxsize=outbound_maxsize
119
+ )
120
+ self._outbound_seq = 0
121
+ self._outbound_dropped = 0
122
+ self._stop = threading.Event()
123
+ self._thread: threading.Thread | None = None
124
+ self._processor_error: BaseException | None = None
125
+
126
+ async def feed_chunks(self, chunk_source: AsyncIterator[Any]) -> None:
127
+ """Read all chunks from *chunk_source* into the inbound queue."""
128
+ try:
129
+ async for chunk in chunk_source:
130
+ if self._stop.is_set():
131
+ break
132
+ await asyncio.to_thread(self._inbound.put, chunk)
133
+ finally:
134
+ await asyncio.to_thread(self._inbound.put, _SENTINEL)
135
+
136
+ def start_processor(
137
+ self,
138
+ process_fn: Callable[[Any], T],
139
+ ) -> None:
140
+ """Start the background processor thread."""
141
+
142
+ def _worker() -> None:
143
+ while not self._stop.is_set():
144
+ try:
145
+ item = self._inbound.get(timeout=0.25)
146
+ except queue.Empty:
147
+ continue
148
+ if item is _SENTINEL:
149
+ self._put_outbound(PRIORITY_LOW, _SENTINEL)
150
+ break
151
+ try:
152
+ prepared = process_fn(item)
153
+ except Exception as exc:
154
+ logger.exception("Turn chunk processor failed")
155
+ self._processor_error = exc
156
+ self._put_outbound(PRIORITY_LOW, _SENTINEL)
157
+ break
158
+ if prepared is None:
159
+ continue
160
+ priority = getattr(prepared, "priority", PRIORITY_LOW)
161
+ self._put_outbound(int(priority), prepared)
162
+
163
+ self._thread = threading.Thread(
164
+ target=_worker,
165
+ name="soothe-appkit-turn-processor",
166
+ daemon=True,
167
+ )
168
+ self._thread.start()
169
+
170
+ def _put_outbound(self, priority: int, item: Any) -> None:
171
+ """Enqueue a prepared chunk from the processor thread (thread-safe)."""
172
+ seq = self._outbound_seq
173
+ self._outbound_seq += 1
174
+ entry = (priority, seq, item)
175
+ try:
176
+ self._outbound.put_nowait(entry)
177
+ return
178
+ except queue.Full:
179
+ pass
180
+
181
+ if not self._evict_outbound_drop_candidate(incoming_priority=priority):
182
+ if priority <= PRIORITY_HIGH:
183
+ try:
184
+ self._outbound.put(entry, block=True, timeout=5.0)
185
+ return
186
+ except queue.Full:
187
+ logger.warning(
188
+ "Turn outbound queue still full after wait; dropping prepared chunk "
189
+ "(priority=%d)",
190
+ priority,
191
+ )
192
+ self._outbound_dropped += 1
193
+ return
194
+ logger.warning(
195
+ "Turn outbound queue full; dropping low-priority prepared chunk (priority=%d)",
196
+ priority,
197
+ )
198
+ self._outbound_dropped += 1
199
+ return
200
+
201
+ try:
202
+ self._outbound.put_nowait(entry)
203
+ except queue.Full:
204
+ if priority <= PRIORITY_HIGH:
205
+ try:
206
+ self._outbound.put(entry, block=True, timeout=5.0)
207
+ except queue.Full:
208
+ logger.warning(
209
+ "Turn outbound queue full after eviction; dropping prepared chunk "
210
+ "(priority=%d)",
211
+ priority,
212
+ )
213
+ self._outbound_dropped += 1
214
+ else:
215
+ logger.warning(
216
+ "Turn outbound queue full after eviction; dropping prepared chunk "
217
+ "(priority=%d)",
218
+ priority,
219
+ )
220
+ self._outbound_dropped += 1
221
+
222
+ def _evict_outbound_drop_candidate(self, *, incoming_priority: int) -> bool:
223
+ """Drop one evictable chunk from the outbound queue to make room."""
224
+ temp: list[tuple[int, int, Any]] = []
225
+ drop_target: tuple[int, int, Any] | None = None
226
+ drop_priority = PRIORITY_CRITICAL - 1
227
+
228
+ while True:
229
+ try:
230
+ temp.append(self._outbound.get_nowait())
231
+ except queue.Empty:
232
+ break
233
+
234
+ for queued in temp:
235
+ queued_priority = queued[0]
236
+ if queued_priority > drop_priority:
237
+ drop_priority = queued_priority
238
+ drop_target = queued
239
+
240
+ min_evictable = PRIORITY_NORMAL if incoming_priority <= PRIORITY_HIGH else PRIORITY_LOW
241
+ if drop_target is None or drop_priority < min_evictable:
242
+ for queued in temp:
243
+ self._outbound.put_nowait(queued)
244
+ return False
245
+
246
+ self._outbound_dropped += 1
247
+ if self._outbound_dropped == 1 or self._outbound_dropped % 500 == 0:
248
+ logger.warning(
249
+ "Turn outbound queue overflow: evicted buffered chunk (priority=%d, dropped=%d)",
250
+ drop_priority,
251
+ self._outbound_dropped,
252
+ )
253
+
254
+ for queued in temp:
255
+ if queued is drop_target:
256
+ continue
257
+ self._outbound.put_nowait(queued)
258
+ return True
259
+
260
+ async def iter_prepared(self) -> AsyncIterator[T]:
261
+ """Yield prepared chunk plans until the stream ends."""
262
+ while True:
263
+ _priority, _seq, item = await asyncio.to_thread(self._outbound.get)
264
+ if item is _SENTINEL:
265
+ if self._processor_error is not None:
266
+ raise self._processor_error
267
+ break
268
+ yield item
269
+
270
+ def shutdown(self) -> None:
271
+ """Signal the processor thread to stop (best-effort)."""
272
+ self._stop.set()
273
+ if self._thread is not None and self._thread.is_alive():
274
+ self._thread.join(timeout=2.0)
275
+
276
+
277
+ def _record_apply_latency(prepared: Any, latency: SupportsTurnLatency | None) -> None:
278
+ """Record first-chunk and synthesis-visible timings."""
279
+ if latency is None or getattr(prepared, "skip", False):
280
+ return
281
+ latency.record_first_chunk()
282
+ if getattr(prepared, "mode", None) == "messages":
283
+ message = getattr(prepared, "normalized_message", None)
284
+ if message is not None and assistant_output_phase(message) == "goal_completion":
285
+ latency.record_goal_completion()
286
+
287
+
288
+ async def run_turn_pipeline(
289
+ chunk_source: AsyncIterator[Any],
290
+ process_fn: Callable[[Any], T],
291
+ apply_fn: Callable[[T], Any],
292
+ *,
293
+ batch_size: int = _DEFAULT_BATCH_SIZE,
294
+ batch_delay_ms: int = _DEFAULT_BATCH_DELAY_MS,
295
+ batching_enabled: bool = True,
296
+ latency_stats: SupportsTurnLatency | None = None,
297
+ ) -> None:
298
+ """Run reader, processor thread, and applier coroutine to completion."""
299
+ loop = asyncio.get_running_loop()
300
+ if latency_stats is not None and latency_stats.turn_start_monotonic <= 0:
301
+ latency_stats.turn_start_monotonic = time.monotonic()
302
+ pipeline: TurnEventPipeline[T] = TurnEventPipeline(loop)
303
+ pipeline.start_processor(process_fn)
304
+
305
+ async def _apply_with_latency(prepared: T) -> None:
306
+ _record_apply_latency(prepared, latency_stats)
307
+ await apply_fn(prepared)
308
+
309
+ if batching_enabled:
310
+ batcher: TurnApplyBatcher[T] = TurnApplyBatcher(
311
+ max_batch_size=batch_size,
312
+ max_batch_delay_ms=batch_delay_ms,
313
+ )
314
+
315
+ async def _apply_batched() -> None:
316
+ async for prepared in pipeline.iter_prepared():
317
+ if batcher.add(prepared):
318
+ batch = batcher.flush()
319
+ for p in batch:
320
+ await _apply_with_latency(p)
321
+ await asyncio.sleep(0)
322
+
323
+ if batcher.has_pending():
324
+ for p in batcher.flush():
325
+ await _apply_with_latency(p)
326
+
327
+ try:
328
+ await asyncio.gather(
329
+ pipeline.feed_chunks(chunk_source),
330
+ _apply_batched(),
331
+ )
332
+ finally:
333
+ pipeline.shutdown()
334
+ else:
335
+
336
+ async def _apply_all() -> None:
337
+ async for prepared in pipeline.iter_prepared():
338
+ await _apply_with_latency(prepared)
339
+
340
+ try:
341
+ await asyncio.gather(
342
+ pipeline.feed_chunks(chunk_source),
343
+ _apply_all(),
344
+ )
345
+ finally:
346
+ pipeline.shutdown()
347
+
348
+
349
+ __all__ = [
350
+ "PRIORITY_CRITICAL",
351
+ "PRIORITY_HIGH",
352
+ "PRIORITY_LOW",
353
+ "PRIORITY_NORMAL",
354
+ "SupportsTurnLatency",
355
+ "TurnApplyBatcher",
356
+ "TurnEventPipeline",
357
+ "run_turn_pipeline",
358
+ "_DEFAULT_BATCH_SIZE",
359
+ "_DEFAULT_BATCH_DELAY_MS",
360
+ "_DEFAULT_INBOUND_MAXSIZE",
361
+ "_DEFAULT_OUTBOUND_MAXSIZE",
362
+ ]