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,63 @@
1
+ """Protocol-1 streaming frame helpers for application loops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def unwrap_next(event: dict[str, Any] | None) -> dict[str, Any] | None:
9
+ """Unwrap a protocol-1 ``next`` envelope to its inner streaming frame.
10
+
11
+ Under protocol-1 the daemon wraps free-form streaming frames
12
+ (``event`` / ``command_response`` / card replay) in a
13
+ ``{proto, type:"next", payload:{namespace, mode, data}}`` envelope. This
14
+ helper returns the inner ``data`` dict (the legacy frame) so turn loops
15
+ can branch on the same fields as before the migration.
16
+
17
+ ``status`` / ``error`` / ``response`` / ``complete`` are sent raw and pass
18
+ through unchanged.
19
+
20
+ Args:
21
+ event: A raw wire frame as returned by ``client.read_event()``.
22
+
23
+ Returns:
24
+ The inner ``payload.data`` dict for ``next`` envelopes, the original
25
+ frame otherwise, or ``None`` if ``event`` is ``None``.
26
+ """
27
+ if not isinstance(event, dict):
28
+ return event
29
+ if event.get("type") != "next":
30
+ return event
31
+ payload = event.get("payload")
32
+ if not isinstance(payload, dict):
33
+ return event
34
+ data = payload.get("data")
35
+ return data if isinstance(data, dict) else event
36
+
37
+
38
+ def is_loop_scoped_event(event: dict[str, Any], *, active_loop_id: str) -> bool:
39
+ """Return whether a daemon frame belongs to the active StrangeLoop session.
40
+
41
+ Unwraps protocol-1 ``next`` envelopes first, then checks ``loop_id`` on the
42
+ inner streaming frame. Non-scoped types (``response``, ``error``,
43
+ ``complete``, etc.) are always considered in-scope.
44
+
45
+ Args:
46
+ event: Raw or already-unwrapped wire frame.
47
+ active_loop_id: Loop id for the in-flight session.
48
+
49
+ Returns:
50
+ True when the frame should be processed for ``active_loop_id``.
51
+ """
52
+ event_type = event.get("type", "")
53
+ if event_type == "next":
54
+ inner = unwrap_next(event)
55
+ if isinstance(inner, dict):
56
+ event_type = inner.get("type", "")
57
+ return event_type not in {"status", "event"} or (inner.get("loop_id") == active_loop_id)
58
+ if event_type not in {"status", "event"}:
59
+ return True
60
+ return event.get("loop_id") == active_loop_id
61
+
62
+
63
+ __all__ = ["is_loop_scoped_event", "unwrap_next"]
@@ -0,0 +1,163 @@
1
+ """ManagedClient seam for appkit ConnectionPool / TurnRunner (RFC-629).
2
+
3
+ The concrete ``WebSocketClient`` satisfies it via ``WebSocketManagedClient``;
4
+ tests supply fakes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import AsyncIterator, Awaitable, Callable
10
+ from typing import Any, Protocol, runtime_checkable
11
+
12
+ from soothe_client.errors import DisconnectCause
13
+ from soothe_client.websocket import WebSocketClient
14
+
15
+ ClientFactory = Callable[[str], "ManagedClient"]
16
+ BootstrapFunc = Callable[
17
+ ["ManagedClient", str, str],
18
+ Awaitable[str],
19
+ ]
20
+
21
+
22
+ @runtime_checkable
23
+ class ManagedClient(Protocol):
24
+ """Subset of Layer 0 that ConnectionPool and TurnRunner depend on."""
25
+
26
+ async def connect(self) -> None:
27
+ """Dial and handshake."""
28
+ ...
29
+
30
+ async def reconnect(self) -> None:
31
+ """Re-dial after a drop."""
32
+ ...
33
+
34
+ async def reattach_and_probe(self, loop_id: str) -> None:
35
+ """Resume a loop by id and probe liveness."""
36
+ ...
37
+
38
+ async def send_message(self, msg: Any) -> None:
39
+ """Send a fire-and-forget JSON frame (legacy flat or wire envelope)."""
40
+ ...
41
+
42
+ def receive_messages(
43
+ self,
44
+ *,
45
+ cancel_event: Any | None = None,
46
+ ) -> AsyncIterator[dict[str, Any]]:
47
+ """Yield decoded daemon events until EOF or cancel."""
48
+ ...
49
+
50
+ def is_disconnected(self) -> bool:
51
+ """Return whether the disconnect signal has fired."""
52
+ ...
53
+
54
+ def disconnect_cause(self) -> DisconnectCause | None:
55
+ """Return the disconnect cause, or None if still connected."""
56
+ ...
57
+
58
+ def is_connected(self) -> bool:
59
+ """Report connection liveness."""
60
+ ...
61
+
62
+ async def close(self) -> None:
63
+ """Tear down the connection."""
64
+ ...
65
+
66
+
67
+ class WebSocketManagedClient:
68
+ """Adapter wrapping ``WebSocketClient`` as a ``ManagedClient``."""
69
+
70
+ def __init__(self, client: WebSocketClient) -> None:
71
+ self._client = client
72
+
73
+ @property
74
+ def underlying(self) -> WebSocketClient:
75
+ """Return the wrapped Layer 0 client."""
76
+ return self._client
77
+
78
+ async def connect(self) -> None:
79
+ await self._client.connect()
80
+ await self._client.request_connection_init()
81
+ await self._client.wait_for_connection_ack()
82
+
83
+ async def reconnect(self) -> None:
84
+ await self._client.reconnect()
85
+
86
+ async def reattach_and_probe(self, loop_id: str) -> None:
87
+ await self._client.reattach_and_probe(loop_id)
88
+
89
+ async def send_message(self, msg: Any) -> None:
90
+ if not isinstance(msg, dict):
91
+ raise TypeError("send_message expects a dict payload")
92
+ await self._client.send(msg)
93
+
94
+ async def receive_messages(
95
+ self,
96
+ *,
97
+ cancel_event: Any | None = None,
98
+ ) -> AsyncIterator[dict[str, Any]]:
99
+ while True:
100
+ if cancel_event is not None and cancel_event.is_set():
101
+ return
102
+ event = await self._client.read_event()
103
+ if event is None:
104
+ return
105
+ yield event
106
+
107
+ def is_disconnected(self) -> bool:
108
+ return self._client.is_disconnected()
109
+
110
+ def disconnect_cause(self) -> DisconnectCause | None:
111
+ return self._client.disconnect_cause()
112
+
113
+ def is_connected(self) -> bool:
114
+ alive = getattr(self._client, "is_connection_alive", None)
115
+ if callable(alive):
116
+ return bool(alive()) and not self._client.is_disconnected()
117
+ return bool(self._client.is_connected) and not self._client.is_disconnected()
118
+
119
+ async def close(self) -> None:
120
+ await self._client.close()
121
+
122
+
123
+ def default_client_factory() -> ClientFactory:
124
+ """Return a factory that builds ``WebSocketManagedClient`` instances."""
125
+
126
+ def _factory(url: str) -> ManagedClient:
127
+ return WebSocketManagedClient(WebSocketClient(url=url))
128
+
129
+ return _factory
130
+
131
+
132
+ def default_bootstrap_func() -> BootstrapFunc:
133
+ """Default bootstrap: ``loop_new`` + ``subscribe(loop_events)``."""
134
+
135
+ async def _bootstrap(client: ManagedClient, workspace_id: str, user_id: str) -> str:
136
+ from soothe_client.session import bootstrap_loop_session
137
+
138
+ underlying = getattr(client, "underlying", client)
139
+ status = await bootstrap_loop_session(
140
+ underlying,
141
+ resume_loop_id=None,
142
+ user_id=user_id or None,
143
+ client_workspace_id=workspace_id or None,
144
+ workspace=workspace_id or None,
145
+ )
146
+ if status.get("type") == "error":
147
+ raise RuntimeError(str(status.get("message", "daemon bootstrap failed")))
148
+ loop_id = str(status.get("loop_id") or "").strip()
149
+ if not loop_id:
150
+ raise RuntimeError("bootstrap_loop_session returned no loop_id")
151
+ return loop_id
152
+
153
+ return _bootstrap
154
+
155
+
156
+ __all__ = [
157
+ "BootstrapFunc",
158
+ "ClientFactory",
159
+ "ManagedClient",
160
+ "WebSocketManagedClient",
161
+ "default_bootstrap_func",
162
+ "default_client_factory",
163
+ ]
@@ -0,0 +1,29 @@
1
+ """Per-turn observability counters for daemon stream consumption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class TurnEventStats:
10
+ """Event counts accumulated over a single daemon turn.
11
+
12
+ Apps may subclass or replace this via ``DaemonSession``'s stats factory.
13
+ """
14
+
15
+ total: int = 0
16
+ messages: int = 0
17
+ updates: int = 0
18
+ custom: int = 0
19
+ skipped: int = 0
20
+ filtered_early: int = 0
21
+ tool_calls: int = 0
22
+ tool_results: int = 0
23
+ text_chunks: int = 0
24
+ heartbeats_dropped: int = 0
25
+ post_idle_drained: int = 0
26
+ inbound_dropped: int = 0
27
+
28
+
29
+ __all__ = ["TurnEventStats"]
@@ -0,0 +1,258 @@
1
+ """Per-session connection pool for appkit (RFC-629 Layer 1).
2
+
3
+ Manages a pool of daemon connections, one active per session. Reuses an
4
+ active connection when still live, otherwise bootstraps a fresh loop or
5
+ reattaches an existing one. Persistence of session↔loop mappings is
6
+ abstracted behind ``SessionStore``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import time
14
+ from collections.abc import AsyncIterator
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from soothe_client.appkit.managed_client import (
19
+ BootstrapFunc,
20
+ ClientFactory,
21
+ ManagedClient,
22
+ default_bootstrap_func,
23
+ default_client_factory,
24
+ )
25
+ from soothe_client.appkit.session_store import SessionStore
26
+ from soothe_client.errors import StaleLoopError
27
+
28
+
29
+ class ErrPoolExhausted(Exception): # noqa: N818 — match Go/TS name
30
+ """Raised when no free connection slot is available."""
31
+
32
+ def __init__(self) -> None:
33
+ super().__init__("appkit: connection pool exhausted")
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class PoolConfig:
38
+ """Configures a ``ConnectionPool``. Zero / negative sizes use defaults."""
39
+
40
+ pool_size: int = 1000
41
+ query_timeout_s: float = 30 * 60
42
+ connection_timeout_s: float = 30.0
43
+ max_idle_time_s: float = 10 * 60
44
+ health_check_interval_s: float = 30.0
45
+
46
+
47
+ def default_pool_config() -> PoolConfig:
48
+ """Return conservative pool defaults suitable for most apps."""
49
+ return PoolConfig()
50
+
51
+
52
+ class PooledConn:
53
+ """One connection slot in the pool."""
54
+
55
+ __slots__ = (
56
+ "slot_id",
57
+ "client",
58
+ "event_stream",
59
+ "stream_cancel",
60
+ "session_id",
61
+ "loop_id",
62
+ "workspace_id",
63
+ "last_used",
64
+ )
65
+
66
+ def __init__(self, slot_id: int, client: ManagedClient) -> None:
67
+ self.slot_id = slot_id
68
+ self.client = client
69
+ self.event_stream: AsyncIterator[dict[str, Any]] | None = None
70
+ self.stream_cancel: asyncio.Event | None = None
71
+ self.session_id = ""
72
+ self.loop_id = ""
73
+ self.workspace_id = ""
74
+ self.last_used = 0.0
75
+
76
+ def is_disconnected(self) -> bool:
77
+ """Report whether the underlying client signalled a drop."""
78
+ return self.client.is_disconnected()
79
+
80
+ def is_connected(self) -> bool:
81
+ """Report whether the slot is still usable."""
82
+ return self.client.is_connected() and not self.is_disconnected()
83
+
84
+ def get_loop_id(self) -> str:
85
+ """Return the active loop id for this slot."""
86
+ return self.loop_id
87
+
88
+
89
+ class ConnectionPool:
90
+ """Manage a pool of daemon connections, one active per session."""
91
+
92
+ def __init__(
93
+ self,
94
+ url: str,
95
+ store: SessionStore,
96
+ cfg: PoolConfig | None = None,
97
+ factory: ClientFactory | None = None,
98
+ ) -> None:
99
+ self._cfg = cfg or default_pool_config()
100
+ if self._cfg.pool_size <= 0:
101
+ self._cfg = PoolConfig(
102
+ pool_size=default_pool_config().pool_size,
103
+ query_timeout_s=self._cfg.query_timeout_s,
104
+ connection_timeout_s=self._cfg.connection_timeout_s,
105
+ max_idle_time_s=self._cfg.max_idle_time_s,
106
+ health_check_interval_s=self._cfg.health_check_interval_s,
107
+ )
108
+ self._factory = factory or default_client_factory()
109
+ self._bootstrap: BootstrapFunc = default_bootstrap_func()
110
+ self._store = store
111
+ self._url = url
112
+ self._pool: list[PooledConn] = []
113
+ self._active: dict[str, PooledConn] = {}
114
+ self._next_slot_id = 1
115
+ self._lock = asyncio.Lock()
116
+ for _ in range(self._cfg.pool_size):
117
+ self._pool.append(self._new_slot())
118
+
119
+ def with_bootstrap(self, bootstrap: BootstrapFunc) -> ConnectionPool:
120
+ """Override the loop bootstrap function (useful for test fakes)."""
121
+ self._bootstrap = bootstrap
122
+ return self
123
+
124
+ def with_client_factory(self, factory: ClientFactory) -> ConnectionPool:
125
+ """Override the client factory (useful for test fakes)."""
126
+ self._factory = factory
127
+ return self
128
+
129
+ def stats(self) -> dict[str, int]:
130
+ """Return a snapshot of active and idle slot counts."""
131
+ return {"active": len(self._active), "idle": len(self._pool)}
132
+
133
+ def _new_slot(self) -> PooledConn:
134
+ slot_id = self._next_slot_id
135
+ self._next_slot_id += 1
136
+ return PooledConn(slot_id, self._factory(self._url))
137
+
138
+ async def acquire(
139
+ self,
140
+ session_id: str,
141
+ workspace_id: str,
142
+ user_id: str,
143
+ ) -> PooledConn:
144
+ """Return a live connection for ``session_id``.
145
+
146
+ Caller must ``release`` when done (turn completes or session reset).
147
+ """
148
+ async with self._lock:
149
+ existing = self._active.get(session_id)
150
+ if existing is not None:
151
+ if existing.is_disconnected() or not existing.is_connected():
152
+ await self._release_unlocked(session_id)
153
+ else:
154
+ existing.last_used = time.monotonic()
155
+ with contextlib.suppress(Exception):
156
+ await self._store.update_last_used(session_id)
157
+ return existing
158
+
159
+ if not self._pool:
160
+ raise ErrPoolExhausted()
161
+ conn = self._pool.pop()
162
+ self._active[session_id] = conn
163
+
164
+ loop_id = ""
165
+ ok = False
166
+ with contextlib.suppress(Exception):
167
+ loop_id, ok = await self._store.get_loop_id_for_session(session_id)
168
+
169
+ final_loop_id = ""
170
+ try:
171
+ if not ok or not loop_id:
172
+ await conn.client.connect()
173
+ final_loop_id = await self._bootstrap_new(conn, workspace_id, user_id)
174
+ with contextlib.suppress(Exception):
175
+ await self._store.create_session(workspace_id, session_id, final_loop_id, "")
176
+ else:
177
+ try:
178
+ await self._resume_and_reattach(conn, loop_id)
179
+ final_loop_id = loop_id
180
+ except Exception:
181
+ await conn.client.connect()
182
+ final_loop_id = await self._bootstrap_new(conn, workspace_id, user_id)
183
+ with contextlib.suppress(Exception):
184
+ await self._store.create_session(
185
+ workspace_id, session_id, final_loop_id, ""
186
+ )
187
+ except Exception:
188
+ await self.release(session_id)
189
+ raise
190
+
191
+ conn.session_id = session_id
192
+ conn.loop_id = final_loop_id
193
+ conn.workspace_id = workspace_id
194
+ conn.last_used = time.monotonic()
195
+ with contextlib.suppress(Exception):
196
+ await self._store.update_last_used(session_id)
197
+ return conn
198
+
199
+ async def release(self, session_id: str) -> None:
200
+ """Tear down the connection for ``session_id`` and return a fresh slot."""
201
+ async with self._lock:
202
+ await self._release_unlocked(session_id)
203
+
204
+ async def _release_unlocked(self, session_id: str) -> None:
205
+ conn = self._active.pop(session_id, None)
206
+ if conn is None:
207
+ return
208
+ if conn.stream_cancel is not None:
209
+ conn.stream_cancel.set()
210
+ conn.stream_cancel = None
211
+ with contextlib.suppress(Exception):
212
+ await conn.client.close()
213
+ conn.session_id = ""
214
+ conn.loop_id = ""
215
+ conn.event_stream = None
216
+ self._pool.append(self._new_slot())
217
+
218
+ async def reset_session(self, session_id: str) -> None:
219
+ """Tear down so the next acquire bootstraps fresh."""
220
+ await self.release(session_id)
221
+
222
+ async def stop(self) -> None:
223
+ """Gracefully shut down all active connections."""
224
+ async with self._lock:
225
+ for sid in list(self._active):
226
+ await self._release_unlocked(sid)
227
+
228
+ async def _bootstrap_new(
229
+ self,
230
+ conn: PooledConn,
231
+ workspace_id: str,
232
+ user_id: str,
233
+ ) -> str:
234
+ loop_id = await self._bootstrap(conn.client, workspace_id, user_id)
235
+ self._start_reader(conn)
236
+ return loop_id
237
+
238
+ async def _resume_and_reattach(self, conn: PooledConn, loop_id: str) -> None:
239
+ await conn.client.connect()
240
+ try:
241
+ await conn.client.reattach_and_probe(loop_id)
242
+ except StaleLoopError:
243
+ raise
244
+ self._start_reader(conn)
245
+
246
+ def _start_reader(self, conn: PooledConn) -> None:
247
+ cancel = asyncio.Event()
248
+ conn.stream_cancel = cancel
249
+ conn.event_stream = conn.client.receive_messages(cancel_event=cancel)
250
+
251
+
252
+ __all__ = [
253
+ "ConnectionPool",
254
+ "ErrPoolExhausted",
255
+ "PoolConfig",
256
+ "PooledConn",
257
+ "default_pool_config",
258
+ ]
@@ -0,0 +1,93 @@
1
+ """Single-flight query gate (RFC-629 Layer 1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Any
8
+
9
+ SendCancelFn = Callable[[], Awaitable[None] | None]
10
+
11
+
12
+ class ErrQueryBusy(Exception): # noqa: N818 — match Go/TS `ErrQueryBusy` name
13
+ """Raised when a session already has an in-flight query."""
14
+
15
+ def __init__(self) -> None:
16
+ super().__init__("appkit: query already in progress for session")
17
+
18
+
19
+ class QueryGate:
20
+ """Enforce single-flight query execution per session id.
21
+
22
+ Cancel ordering: daemon cancel (detached timeout) runs before the local
23
+ cancel callback, matching Go/TS appkit semantics.
24
+ """
25
+
26
+ def __init__(self, *, cancel_timeout_s: float = 10.0) -> None:
27
+ self._lock = asyncio.Lock()
28
+ self._cancels: dict[str, Callable[[], None]] = {}
29
+ self._send_cancels: dict[str, SendCancelFn] = {}
30
+ self._cancel_timeout_s = cancel_timeout_s
31
+
32
+ async def acquire(
33
+ self,
34
+ session_id: str,
35
+ cancel: Callable[[], None],
36
+ send_cancel: SendCancelFn | None = None,
37
+ ) -> None:
38
+ """Reserve ``session_id`` for one agent turn.
39
+
40
+ Args:
41
+ session_id: Application session key (often the loop id).
42
+ cancel: Local cancel callback (e.g. set an Event / cancel a task).
43
+ send_cancel: Optional async daemon-cancel sender.
44
+
45
+ Raises:
46
+ ErrQueryBusy: A query is already in flight for ``session_id``.
47
+ """
48
+ async with self._lock:
49
+ if session_id in self._cancels:
50
+ raise ErrQueryBusy()
51
+ self._cancels[session_id] = cancel
52
+ if send_cancel is not None:
53
+ self._send_cancels[session_id] = send_cancel
54
+
55
+ async def cancel(self, session_id: str) -> None:
56
+ """Stop an in-flight query: daemon cancel first, then local cancel."""
57
+ async with self._lock:
58
+ cancel = self._cancels.pop(session_id, None)
59
+ send_cancel = self._send_cancels.pop(session_id, None)
60
+
61
+ if cancel is None and send_cancel is None:
62
+ return
63
+
64
+ if send_cancel is not None:
65
+ try:
66
+ await asyncio.wait_for(
67
+ _await_maybe(send_cancel()),
68
+ timeout=self._cancel_timeout_s,
69
+ )
70
+ except Exception: # noqa: BLE001 — proceed to local cancel
71
+ pass
72
+
73
+ if cancel is not None:
74
+ cancel()
75
+
76
+ async def release(self, session_id: str) -> None:
77
+ """Clear the gate without sending a daemon cancel."""
78
+ async with self._lock:
79
+ self._cancels.pop(session_id, None)
80
+ self._send_cancels.pop(session_id, None)
81
+
82
+ async def is_active(self, session_id: str) -> bool:
83
+ """Return whether a query is in flight for ``session_id``."""
84
+ async with self._lock:
85
+ return session_id in self._cancels
86
+
87
+
88
+ async def _await_maybe(result: Any) -> None:
89
+ if asyncio.iscoroutine(result) or isinstance(result, Awaitable):
90
+ await result # type: ignore[misc]
91
+
92
+
93
+ __all__ = ["ErrQueryBusy", "QueryGate"]