soothe-daemon 0.5.6__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.
Files changed (56) hide show
  1. soothe_daemon/README.md +122 -0
  2. soothe_daemon/__init__.py +9 -0
  3. soothe_daemon/__main__.py +6 -0
  4. soothe_daemon/_handlers.py +288 -0
  5. soothe_daemon/_rpc_handlers.py +406 -0
  6. soothe_daemon/bootstrap_env.py +35 -0
  7. soothe_daemon/cli/__init__.py +1 -0
  8. soothe_daemon/cli/daemon_main.py +355 -0
  9. soothe_daemon/client_session.py +369 -0
  10. soothe_daemon/config/__init__.py +29 -0
  11. soothe_daemon/config/env.py +23 -0
  12. soothe_daemon/config/models.py +252 -0
  13. soothe_daemon/config/settings.py +167 -0
  14. soothe_daemon/entrypoint.py +121 -0
  15. soothe_daemon/event_bus.py +273 -0
  16. soothe_daemon/event_size_stats.py +166 -0
  17. soothe_daemon/health/__init__.py +50 -0
  18. soothe_daemon/health/checker.py +243 -0
  19. soothe_daemon/health/checks/__init__.py +3 -0
  20. soothe_daemon/health/checks/config_check.py +196 -0
  21. soothe_daemon/health/checks/daemon_check.py +673 -0
  22. soothe_daemon/health/checks/embedding_warmup_check.py +92 -0
  23. soothe_daemon/health/checks/external_apis_check.py +177 -0
  24. soothe_daemon/health/checks/mcp_check.py +118 -0
  25. soothe_daemon/health/checks/observability_check.py +162 -0
  26. soothe_daemon/health/checks/persistence_check.py +242 -0
  27. soothe_daemon/health/checks/protocols_check.py +66 -0
  28. soothe_daemon/health/checks/providers_check.py +143 -0
  29. soothe_daemon/health/checks/vector_stores_check.py +156 -0
  30. soothe_daemon/health/formatters.py +194 -0
  31. soothe_daemon/health/models.py +133 -0
  32. soothe_daemon/image_understanding.py +177 -0
  33. soothe_daemon/logging.py +90 -0
  34. soothe_daemon/loop_isolation.py +177 -0
  35. soothe_daemon/message_router.py +1486 -0
  36. soothe_daemon/paths.py +23 -0
  37. soothe_daemon/protocol_v2.py +154 -0
  38. soothe_daemon/query_engine.py +816 -0
  39. soothe_daemon/reattachment_handler.py +122 -0
  40. soothe_daemon/runner/__init__.py +10 -0
  41. soothe_daemon/runner/factory.py +93 -0
  42. soothe_daemon/runner/pool_runner.py +1500 -0
  43. soothe_daemon/runner/ray_actor.py +59 -0
  44. soothe_daemon/runner/ray_runner.py +98 -0
  45. soothe_daemon/server.py +1086 -0
  46. soothe_daemon/singleton.py +67 -0
  47. soothe_daemon/thread_state.py +125 -0
  48. soothe_daemon/transport_manager.py +214 -0
  49. soothe_daemon/transports/__init__.py +5 -0
  50. soothe_daemon/transports/base.py +141 -0
  51. soothe_daemon/transports/http_rest.py +490 -0
  52. soothe_daemon/transports/websocket.py +326 -0
  53. soothe_daemon-0.5.6.dist-info/METADATA +75 -0
  54. soothe_daemon-0.5.6.dist-info/RECORD +56 -0
  55. soothe_daemon-0.5.6.dist-info/WHEEL +4 -0
  56. soothe_daemon-0.5.6.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,122 @@
1
+ # soothe.daemon
2
+
3
+ Long-running background process that serves the Soothe agent over multiple
4
+ transports. Acts as a **transport adapter** around `SootheRunner` — it does
5
+ not re-implement orchestration logic.
6
+
7
+ ---
8
+
9
+ ## Relationship to `soothe.core`
10
+
11
+ ```
12
+ ┌──────────────────────────────────────────┐
13
+ │ TUI / CLI client │
14
+ └───────────────┬──────────────────────────┘
15
+ │ WebSocket / HTTP REST
16
+ ┌───────────────▼──────────────────────────┐
17
+ │ soothe.daemon │
18
+ │ │
19
+ │ SootheDaemon process lifecycle │
20
+ │ TransportManager multi-transport │
21
+ │ MessageRouter JSON → runner API │
22
+ │ QueryEngine streaming + cancel│
23
+ │ ThreadStateRegistry per-thread state │
24
+ └───────────────┬──────────────────────────┘
25
+ │ constructs / calls
26
+ ┌───────────────▼──────────────────────────┐
27
+ │ soothe.core.runner.SootheRunner │
28
+ │ (orchestration, protocols, streaming) │
29
+ └──────────────────────────────────────────┘
30
+ ```
31
+
32
+ `SootheDaemon` holds a single `SootheRunner` instance and delegates all
33
+ query execution to it via public APIs (`astream`, thread helpers). The
34
+ daemon **never** duplicates protocol, memory, or planning logic.
35
+
36
+ ---
37
+
38
+ ## Directory map
39
+
40
+ | File / Package | Responsibility |
41
+ |----------------|----------------|
42
+ | `server.py` | `SootheDaemon` — process lifecycle, WebSocket server, Unix socket |
43
+ | `entrypoint.py` | `run_daemon()` — CLI entry point, signal handling |
44
+ | `transport_manager.py` | Manages multiple transport servers (WebSocket, HTTP REST) |
45
+ | `transports/` | `WebSocketTransport`, `HttpRestTransport`, `TransportServer` base |
46
+ | `message_router.py` | Routes incoming JSON messages to runner public APIs |
47
+ | `query_engine.py` | `QueryEngine` — streams a single query, owns cancel / ownership |
48
+ | `thread_state.py` | `ThreadStateRegistry` — per-thread draft, history, logger |
49
+ | `client_session.py` | Tracks connected client metadata and event filtering |
50
+ | `event_bus.py` | In-process pub/sub for broadcasting events to all clients |
51
+ | `protocol.py` / `protocol_v2.py` | Wire-format encode/decode helpers |
52
+ | `websocket_client.py` | `WebSocketClient` — for CLI commands that talk to the daemon |
53
+ | `singleton.py` | Single-instance enforcement |
54
+ | `paths.py` | `pid_path()`, `socket_path()` — canonical filesystem paths |
55
+ | `health/` | `HealthChecker` and per-category check implementations |
56
+
57
+ ---
58
+
59
+ ## health/ subpackage
60
+
61
+ Health checks verify all Soothe components including daemon socket,
62
+ persistence, providers, protocols, and external APIs.
63
+
64
+ ```
65
+ daemon/health/
66
+ ├── __init__.py # HealthChecker, format_* exports
67
+ ├── checker.py # HealthChecker orchestrator
68
+ ├── models.py # CheckResult, CategoryResult, HealthReport
69
+ ├── formatters.py # format_text, format_markdown, format_json
70
+ └── checks/
71
+ ├── config_check.py
72
+ ├── daemon_check.py # uses soothe_daemon.paths (pid_path, socket_path)
73
+ ├── persistence_check.py
74
+ ├── protocols_check.py
75
+ ├── providers_check.py
76
+ ├── vector_stores_check.py
77
+ ├── mcp_check.py
78
+ ├── external_apis_check.py
79
+ └── observability_check.py
80
+ ```
81
+
82
+ Health checks live here (not in `core`) because they legitimately depend
83
+ on daemon-layer paths (`pid_path`, `socket_path`) and daemon connectivity.
84
+
85
+ ---
86
+
87
+ ## Boundary rules
88
+
89
+ | Direction | Rule |
90
+ |-----------|------|
91
+ | `daemon` → `core` | OK — daemon composes `SootheRunner` |
92
+ | `daemon` → `soothe.logging` | OK |
93
+ | `daemon` → `config` | OK |
94
+ | `daemon` → `ux` | **Forbidden** |
95
+ | `daemon.health` → `daemon.paths` | OK — intra-daemon import |
96
+ | Orchestration logic in daemon | **Forbidden** — belongs in `core` |
97
+
98
+ ---
99
+
100
+ ## Key types
101
+
102
+ ```python
103
+ from soothe_daemon import SootheDaemon # main daemon class
104
+ from soothe_daemon import WebSocketClient # client for CLI ↔ daemon
105
+ from soothe_daemon import run_daemon # entrypoint
106
+ from soothe_daemon import pid_path # ~/.soothe/soothe.pid
107
+ from soothe_daemon import socket_path # ~/.soothe/soothe.sock
108
+ from soothe_daemon.health import HealthChecker
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Message flow
114
+
115
+ ```
116
+ Client connects (WebSocket / HTTP)
117
+ → TransportManager routes connection to handler
118
+ → MessageRouter.handle(msg) dispatches by msg["type"]
119
+ → QueryEngine.stream(runner, query, thread_id, ...)
120
+ → runner.astream(...) yields (namespace, mode, data)
121
+ → events broadcast via EventBus to all clients
122
+ ```
@@ -0,0 +1,9 @@
1
+ """Soothe daemon subpackage - background agent runner with WebSocket IPC."""
2
+
3
+ from soothe_sdk.client import WebSocketClient
4
+
5
+ from soothe_daemon.entrypoint import run_daemon
6
+ from soothe_daemon.paths import pid_path, socket_path
7
+ from soothe_daemon.server import SootheDaemon
8
+
9
+ __all__ = ["SootheDaemon", "WebSocketClient", "pid_path", "run_daemon", "socket_path"]
@@ -0,0 +1,6 @@
1
+ """Allow running the daemon as a module: python -m soothe_daemon."""
2
+
3
+ from soothe_daemon.entrypoint import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,288 @@
1
+ """Client connection handling for the daemon (IG-110).
2
+
3
+ Heavy logic lives in ``message_router`` and ``query_engine``; this mixin wires
4
+ transport entrypoints and the input queue loop.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import logging
12
+ from typing import Any
13
+
14
+ import websockets.exceptions
15
+ from soothe.core.events import ERROR
16
+ from soothe_sdk.client.protocol import decode, encode
17
+
18
+ # Import RPC command handlers (RFC-404)
19
+ from soothe_daemon._rpc_handlers import (
20
+ _cmd_autopilot_dashboard,
21
+ _cmd_cancel,
22
+ _cmd_clear,
23
+ _cmd_config,
24
+ _cmd_detach,
25
+ _cmd_exit,
26
+ _cmd_history,
27
+ _cmd_memory,
28
+ _cmd_plan,
29
+ _cmd_policy,
30
+ _cmd_quit,
31
+ _cmd_resume,
32
+ _cmd_review,
33
+ _cmd_thread,
34
+ _handle_command_request,
35
+ _send_command_response,
36
+ )
37
+ from soothe_daemon.message_router import (
38
+ _coerce_loop_input_text,
39
+ _queue_options_from_daemon_message,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class DaemonHandlersMixin:
46
+ """Client connection handling and query execution mixin.
47
+
48
+ Mixed into ``SootheDaemon`` -- all ``self.*`` attributes are defined
49
+ on the concrete class.
50
+ """
51
+
52
+ # Attach RPC handlers to mixin (RFC-404)
53
+ _handle_command_request = _handle_command_request
54
+ _send_command_response = _send_command_response
55
+ _cmd_clear = _cmd_clear
56
+ _cmd_exit = _cmd_exit
57
+ _cmd_quit = _cmd_quit
58
+ _cmd_detach = _cmd_detach
59
+ _cmd_cancel = _cmd_cancel
60
+ _cmd_memory = _cmd_memory
61
+ _cmd_policy = _cmd_policy
62
+ _cmd_history = _cmd_history
63
+ _cmd_config = _cmd_config
64
+ _cmd_review = _cmd_review
65
+ _cmd_plan = _cmd_plan
66
+ _cmd_thread = _cmd_thread
67
+ _cmd_resume = _cmd_resume
68
+ _cmd_autopilot_dashboard = _cmd_autopilot_dashboard
69
+
70
+ async def _send_client_message(self, client_id: Any, msg: dict[str, Any]) -> None:
71
+ """Send a direct response to a specific client when possible.
72
+
73
+ Handles normal client disconnects gracefully without logging errors.
74
+ """
75
+ try:
76
+ session = (
77
+ await self._session_manager.get_session(client_id)
78
+ if isinstance(client_id, str)
79
+ else None
80
+ )
81
+ if session is not None:
82
+ await session.transport.send(session.transport_client, msg)
83
+ return
84
+ if hasattr(client_id, "writer"):
85
+ await self._send(client_id, msg)
86
+ except websockets.exceptions.ConnectionClosedOK:
87
+ # Normal disconnect (code 1000) - expected, no error logging
88
+ logger.debug("Client %r disconnected normally", client_id)
89
+ except (websockets.exceptions.ConnectionClosedError, ConnectionError):
90
+ # Abnormal disconnect - log as warning without full traceback
91
+ logger.debug("Client %r disconnected unexpectedly", client_id)
92
+ except Exception:
93
+ # Unexpected error - log with full traceback
94
+ logger.debug("Failed to send direct response to client %r", client_id, exc_info=True)
95
+
96
+ async def _handle_client(
97
+ self,
98
+ reader: asyncio.StreamReader,
99
+ writer: asyncio.StreamWriter,
100
+ ) -> None:
101
+ from soothe_daemon.server import _ClientConn
102
+
103
+ client = _ClientConn(reader=reader, writer=writer)
104
+ self._clients.append(client)
105
+ logger.info("Client connected (total=%d)", len(self._clients))
106
+
107
+ try:
108
+ initial_state = (
109
+ "running" if self._query_running else ("idle" if self._running else "stopped")
110
+ )
111
+ initial_msg = {
112
+ "type": "status",
113
+ "state": initial_state,
114
+ "input_history": [],
115
+ }
116
+
117
+ client.writer.write(encode(initial_msg))
118
+ client.writer.write(encode(self.daemon_ready_message()))
119
+ await client.writer.drain()
120
+ except Exception:
121
+ logger.exception("Failed to send initial status to client")
122
+
123
+ try:
124
+ while True:
125
+ line = await reader.readline()
126
+ if not line:
127
+ break
128
+ msg = decode(line)
129
+ if msg is None:
130
+ continue
131
+ await self._message_router.dispatch(f"legacy:{id(client)}", msg)
132
+ except (asyncio.CancelledError, ConnectionError):
133
+ pass
134
+ finally:
135
+ self._clients = [c for c in self._clients if c is not client]
136
+ with contextlib.suppress(Exception):
137
+ writer.close()
138
+ await writer.wait_closed()
139
+ logger.info("Client disconnected (total=%d)", len(self._clients))
140
+
141
+ async def _handle_client_message(self, client_id: str, msg: dict[str, Any]) -> None:
142
+ """Handle a message from a client (WebSocket / HTTP transports)."""
143
+ await self._message_router.dispatch(client_id, msg)
144
+
145
+ async def _process_loop_input_message(self, loop_id: str, msg: dict[str, Any]) -> None:
146
+ """Process one loop-scoped message from ``LoopInputDispatcher`` (IG-408).
147
+
148
+ Supported ``msg["type"]`` values for user turns: ``input`` (normalized queue
149
+ payload from ``loop_input`` RPC) or ``loop_input`` (wire-shaped dict with
150
+ ``content``). Other types are ignored with a warning except ``command`` and
151
+ ``command_request``, which are handled above.
152
+ """
153
+ from soothe_daemon.loop_isolation import bind_execution_thread_for_loop
154
+
155
+ msg_type = msg.get("type", "")
156
+ try:
157
+ checkpoint_thread_id = await bind_execution_thread_for_loop(self, loop_id)
158
+ except Exception as exc:
159
+ logger.warning(
160
+ "Failed to bind LangGraph checkpoint for loop %s: %s",
161
+ loop_id,
162
+ exc,
163
+ )
164
+ client_id = msg.get("client_id")
165
+ if client_id:
166
+ await self._send_client_message(
167
+ client_id,
168
+ {"type": "error", "code": "LOOP_CONTEXT", "message": str(exc)},
169
+ )
170
+ return
171
+
172
+ try:
173
+ if msg_type == "command":
174
+ cmd = msg.get("cmd", "")
175
+ if cmd in ("/exit", "/quit"):
176
+ logger.warning(
177
+ "Received %s in loop worker — should be handled in MessageRouter",
178
+ cmd,
179
+ )
180
+ return
181
+ if cmd.strip().lower() == "/cancel":
182
+ if self._query_engine is not None:
183
+ await self._query_engine.cancel_loop(loop_id)
184
+ return
185
+ logger.warning("Received legacy 'command' message in loop worker — ignoring")
186
+ return
187
+ if msg_type == "command_request":
188
+ req = dict(msg)
189
+ req.setdefault("loop_id", loop_id)
190
+ await self._handle_command_request(req)
191
+ return
192
+ if msg_type not in ("input", "loop_input"):
193
+ logger.warning(
194
+ "Loop worker ignoring unsupported queue message type=%r loop_id=%s",
195
+ msg_type,
196
+ loop_id[:16] if loop_id else "?",
197
+ )
198
+ return
199
+
200
+ if msg_type == "loop_input":
201
+ prompt_text = _coerce_loop_input_text(msg.get("content"))
202
+ if prompt_text is None:
203
+ logger.warning(
204
+ "Loop worker loop_input missing usable content loop_id=%s",
205
+ loop_id[:16] if loop_id else "?",
206
+ )
207
+ return
208
+ else:
209
+ raw_text = msg.get("text")
210
+ if not isinstance(raw_text, str):
211
+ logger.warning(
212
+ "Loop worker input missing str text loop_id=%s",
213
+ loop_id[:16] if loop_id else "?",
214
+ )
215
+ return
216
+ prompt_text = raw_text
217
+
218
+ if self._query_engine is not None:
219
+ qo = _queue_options_from_daemon_message(msg)
220
+ model_params = qo["model_params"]
221
+ model_kw = qo["model"]
222
+ intent_hint = qo["intent_hint"]
223
+ raw_att = msg.get("attachments")
224
+ attachments = raw_att if isinstance(raw_att, list) and raw_att else None
225
+ await self._query_engine.run_query(
226
+ prompt_text,
227
+ loop_id=loop_id,
228
+ autonomous=qo["autonomous"],
229
+ max_iterations=qo["max_iterations"],
230
+ preferred_subagent=qo["preferred_subagent"],
231
+ client_id=msg.get("client_id"),
232
+ interactive=qo["interactive"],
233
+ model=model_kw,
234
+ model_params=model_params,
235
+ attachments=attachments,
236
+ checkpoint_thread_id=checkpoint_thread_id,
237
+ intent_hint=intent_hint,
238
+ )
239
+ except Exception:
240
+ logger.exception("Daemon loop input handler error")
241
+ self._query_running = False
242
+ lid = str(loop_id or "").strip()
243
+ if lid and self._query_engine is not None:
244
+ qe = self._query_engine
245
+ await self._broadcast(
246
+ qe._loop_scoped_client_message(
247
+ lid,
248
+ {
249
+ "type": "event",
250
+ "namespace": [],
251
+ "mode": "custom",
252
+ "data": {"type": ERROR, "error": "Daemon failed to process input"},
253
+ },
254
+ )
255
+ )
256
+ await self._broadcast(
257
+ qe._loop_scoped_client_message(lid, {"type": "status", "state": "idle"})
258
+ )
259
+
260
+ async def _run_query(
261
+ self,
262
+ text: str,
263
+ *,
264
+ loop_id: str | None = None,
265
+ autonomous: bool = False,
266
+ max_iterations: int | None = None,
267
+ preferred_subagent: str | None = None,
268
+ client_id: str | None = None,
269
+ interactive: bool = False,
270
+ model: str | None = None,
271
+ model_params: dict | None = None,
272
+ attachments: list[dict[str, str]] | None = None,
273
+ checkpoint_thread_id: str | None = None,
274
+ ) -> None:
275
+ """Delegate to ``QueryEngine`` (keeps unit tests and legacy callers working)."""
276
+ await self._query_engine.run_query(
277
+ text,
278
+ loop_id=loop_id,
279
+ autonomous=autonomous,
280
+ max_iterations=max_iterations,
281
+ preferred_subagent=preferred_subagent,
282
+ client_id=client_id,
283
+ interactive=interactive,
284
+ model=model,
285
+ model_params=model_params,
286
+ attachments=attachments,
287
+ checkpoint_thread_id=checkpoint_thread_id,
288
+ )