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,171 @@
1
+ """Soothe WebSocket client for soothe-daemon.
2
+
3
+ Layer 0 transport and session helpers. Layer 1 application mechanics live in
4
+ ``soothe_client.appkit``. Shared wire codec and path constants live in
5
+ soothe-sdk (`soothe_sdk.wire`, `soothe_sdk.paths`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.metadata
11
+
12
+ from soothe_sdk.core.types import VerbosityLevel
13
+ from soothe_sdk.wire.codec import ProtocolError
14
+
15
+ from soothe_client.errors import (
16
+ DaemonError,
17
+ DisconnectCause,
18
+ ReconnectError,
19
+ StaleLoopError,
20
+ disconnect_cause_name,
21
+ )
22
+ from soothe_client.helpers import (
23
+ check_daemon_status,
24
+ fetch_config_section,
25
+ fetch_loop_cards,
26
+ fetch_loop_history,
27
+ fetch_loop_messages,
28
+ fetch_skills_catalog,
29
+ is_daemon_live,
30
+ request_daemon_config_reload,
31
+ request_daemon_shutdown,
32
+ websocket_url_from_config,
33
+ )
34
+ from soothe_client.intent_hints import (
35
+ DEFAULT_DELIVERABLE_PHASES,
36
+ EMBED,
37
+ IMAGE_TO_TEXT,
38
+ OCR,
39
+ TEXT_COMPLETION,
40
+ validate_loop_input_intent_hint,
41
+ )
42
+ from soothe_client.protocol_params import (
43
+ AuthParams,
44
+ AuthRefreshParams,
45
+ AutopilotSubscribeParams,
46
+ ConfigGetParams,
47
+ ConfigReloadParams,
48
+ CronAddParams,
49
+ CronCancelParams,
50
+ CronListParams,
51
+ CronShowParams,
52
+ DaemonShutdownParams,
53
+ DaemonStatusParams,
54
+ DisconnectParams,
55
+ InvokeSkillParams,
56
+ JobCancelParams,
57
+ JobCreateParams,
58
+ JobDagParams,
59
+ JobGuidanceParams,
60
+ JobPauseParams,
61
+ JobResumeParams,
62
+ JobStatusParams,
63
+ LoopCardsFetchParams,
64
+ LoopDeleteParams,
65
+ LoopDetachParams,
66
+ LoopGetParams,
67
+ LoopInputParams,
68
+ LoopListParams,
69
+ LoopMessagesParams,
70
+ LoopNewParams,
71
+ LoopPruneParams,
72
+ LoopReattachParams,
73
+ LoopStateGetParams,
74
+ LoopStateUpdateParams,
75
+ LoopTreeParams,
76
+ McpStatusParams,
77
+ ModelsListParams,
78
+ RpcCommandParams,
79
+ SkillsListParams,
80
+ SlashCommandParams,
81
+ SubscribeParams,
82
+ )
83
+ from soothe_client.session import (
84
+ bootstrap_loop_session,
85
+ connect_websocket_with_retries,
86
+ )
87
+ from soothe_client.websocket import WebSocketClient
88
+ from soothe_client.ws_command_client import (
89
+ SyncWsCommandClient,
90
+ WsCommandClient,
91
+ async_ws_command_client_from_config,
92
+ ws_command_client_from_config,
93
+ )
94
+
95
+ try:
96
+ __version__ = importlib.metadata.version("soothe-client-python")
97
+ except importlib.metadata.PackageNotFoundError:
98
+ __version__ = "0.0.0"
99
+
100
+ __all__ = [
101
+ "__version__",
102
+ "WebSocketClient",
103
+ "VerbosityLevel",
104
+ "ProtocolError",
105
+ "DaemonError",
106
+ "DisconnectCause",
107
+ "ReconnectError",
108
+ "StaleLoopError",
109
+ "disconnect_cause_name",
110
+ "WsCommandClient",
111
+ "SyncWsCommandClient",
112
+ "ws_command_client_from_config",
113
+ "async_ws_command_client_from_config",
114
+ "bootstrap_loop_session",
115
+ "connect_websocket_with_retries",
116
+ "websocket_url_from_config",
117
+ "check_daemon_status",
118
+ "is_daemon_live",
119
+ "request_daemon_config_reload",
120
+ "request_daemon_shutdown",
121
+ "fetch_skills_catalog",
122
+ "fetch_config_section",
123
+ "fetch_loop_history",
124
+ "fetch_loop_cards",
125
+ "fetch_loop_messages",
126
+ "TEXT_COMPLETION",
127
+ "IMAGE_TO_TEXT",
128
+ "OCR",
129
+ "EMBED",
130
+ "DEFAULT_DELIVERABLE_PHASES",
131
+ "validate_loop_input_intent_hint",
132
+ "LoopGetParams",
133
+ "LoopListParams",
134
+ "LoopTreeParams",
135
+ "LoopPruneParams",
136
+ "LoopDeleteParams",
137
+ "LoopNewParams",
138
+ "LoopReattachParams",
139
+ "LoopInputParams",
140
+ "LoopMessagesParams",
141
+ "LoopStateGetParams",
142
+ "LoopStateUpdateParams",
143
+ "LoopCardsFetchParams",
144
+ "LoopDetachParams",
145
+ "SubscribeParams",
146
+ "AutopilotSubscribeParams",
147
+ "JobCreateParams",
148
+ "JobStatusParams",
149
+ "JobPauseParams",
150
+ "JobResumeParams",
151
+ "JobCancelParams",
152
+ "JobDagParams",
153
+ "JobGuidanceParams",
154
+ "CronAddParams",
155
+ "CronListParams",
156
+ "CronShowParams",
157
+ "CronCancelParams",
158
+ "DaemonStatusParams",
159
+ "DaemonShutdownParams",
160
+ "ConfigGetParams",
161
+ "ConfigReloadParams",
162
+ "SkillsListParams",
163
+ "ModelsListParams",
164
+ "InvokeSkillParams",
165
+ "McpStatusParams",
166
+ "AuthParams",
167
+ "AuthRefreshParams",
168
+ "SlashCommandParams",
169
+ "RpcCommandParams",
170
+ "DisconnectParams",
171
+ ]
@@ -0,0 +1,138 @@
1
+ """Reusable application-architecture layer over Layer 0 (RFC-629).
2
+
3
+ appkit is product-agnostic. Deliverable phases, persistence, and UI copy stay
4
+ in the application (e.g. soothe-cli). Building blocks:
5
+
6
+ - ``unwrap_next`` / ``is_loop_scoped_event`` — protocol-1 stream helpers
7
+ - ``QueryGate`` — single-flight cancel-before-context gating
8
+ - ``TurnEventPipeline`` — reader / processor / applier concurrency
9
+ - ``DaemonSession`` — dual-socket loop session + ``iter_turn_chunks``
10
+ - ``EventClassifier`` / ``extract_thinking_step`` — deliverable terminal mapping
11
+ - ``SSEBroadcaster`` — drop-on-full SSE-style fan-out
12
+ - ``ConnectionPool`` / ``TurnRunner`` — pooled multi-session turn execution
13
+ - ``SessionStore`` — persistence seam (Protocol)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from soothe_client.appkit.attachments import (
19
+ CompactImageOptions,
20
+ compact_attachments,
21
+ compact_image_attachment,
22
+ )
23
+ from soothe_client.appkit.broadcaster import SSEBroadcaster, SSEEvent
24
+ from soothe_client.appkit.chunk_filter import should_drop_stream_chunk_early
25
+ from soothe_client.appkit.classifier import (
26
+ EVENT_FINAL_REPORT,
27
+ ChatEventResult,
28
+ ChatEventTerminal,
29
+ ClassifierConfig,
30
+ EventClassifier,
31
+ )
32
+ from soothe_client.appkit.daemon_session import DEFAULT_POST_IDLE_DRAIN_S, DaemonSession
33
+ from soothe_client.appkit.events import is_loop_scoped_event, unwrap_next
34
+ from soothe_client.appkit.managed_client import (
35
+ BootstrapFunc,
36
+ ClientFactory,
37
+ ManagedClient,
38
+ WebSocketManagedClient,
39
+ default_bootstrap_func,
40
+ default_client_factory,
41
+ )
42
+ from soothe_client.appkit.observability import TurnEventStats
43
+ from soothe_client.appkit.pool import (
44
+ ConnectionPool,
45
+ ErrPoolExhausted,
46
+ PoolConfig,
47
+ PooledConn,
48
+ default_pool_config,
49
+ )
50
+ from soothe_client.appkit.query_gate import ErrQueryBusy, QueryGate
51
+ from soothe_client.appkit.session_store import SessionEntry, SessionMessage, SessionStore
52
+ from soothe_client.appkit.thinking_step import (
53
+ DEFAULT_THINKING_STEP_EVENTS,
54
+ extract_thinking_step,
55
+ )
56
+ from soothe_client.appkit.turn import (
57
+ PRIORITY_CRITICAL,
58
+ PRIORITY_HIGH,
59
+ PRIORITY_LOW,
60
+ PRIORITY_NORMAL,
61
+ TurnApplyBatcher,
62
+ TurnEventPipeline,
63
+ run_turn_pipeline,
64
+ )
65
+ from soothe_client.appkit.turn_runner import (
66
+ STREAM_CLOSE_FAIL,
67
+ STREAM_CLOSE_SOFT_COMPLETE,
68
+ Attachment,
69
+ ErrIdleTimeout,
70
+ ErrQueryTimeout,
71
+ InputOpts,
72
+ OnComplete,
73
+ OnError,
74
+ StreamClosePolicy,
75
+ TimeoutPolicy,
76
+ TurnConfig,
77
+ TurnRunner,
78
+ idle_timeout_for_turn,
79
+ input_message_for_loop,
80
+ )
81
+
82
+ __all__ = [
83
+ "DEFAULT_POST_IDLE_DRAIN_S",
84
+ "DEFAULT_THINKING_STEP_EVENTS",
85
+ "STREAM_CLOSE_FAIL",
86
+ "STREAM_CLOSE_SOFT_COMPLETE",
87
+ "Attachment",
88
+ "BootstrapFunc",
89
+ "ClientFactory",
90
+ "CompactImageOptions",
91
+ "ConnectionPool",
92
+ "DaemonSession",
93
+ "EVENT_FINAL_REPORT",
94
+ "ErrIdleTimeout",
95
+ "ErrPoolExhausted",
96
+ "ErrQueryBusy",
97
+ "ErrQueryTimeout",
98
+ "ChatEventResult",
99
+ "ChatEventTerminal",
100
+ "ClassifierConfig",
101
+ "EventClassifier",
102
+ "InputOpts",
103
+ "ManagedClient",
104
+ "OnComplete",
105
+ "OnError",
106
+ "PRIORITY_CRITICAL",
107
+ "PRIORITY_HIGH",
108
+ "PRIORITY_LOW",
109
+ "PRIORITY_NORMAL",
110
+ "PoolConfig",
111
+ "PooledConn",
112
+ "QueryGate",
113
+ "SSEBroadcaster",
114
+ "SSEEvent",
115
+ "SessionEntry",
116
+ "SessionMessage",
117
+ "SessionStore",
118
+ "StreamClosePolicy",
119
+ "TimeoutPolicy",
120
+ "TurnApplyBatcher",
121
+ "TurnConfig",
122
+ "TurnEventPipeline",
123
+ "TurnEventStats",
124
+ "TurnRunner",
125
+ "WebSocketManagedClient",
126
+ "compact_attachments",
127
+ "compact_image_attachment",
128
+ "default_bootstrap_func",
129
+ "default_client_factory",
130
+ "default_pool_config",
131
+ "extract_thinking_step",
132
+ "idle_timeout_for_turn",
133
+ "input_message_for_loop",
134
+ "is_loop_scoped_event",
135
+ "run_turn_pipeline",
136
+ "should_drop_stream_chunk_early",
137
+ "unwrap_next",
138
+ ]
@@ -0,0 +1,118 @@
1
+ """Image attachment compaction for appkit (RFC-629 Layer 1).
2
+
3
+ Downscales ``image/*`` payloads when either dimension exceeds a max size.
4
+ Non-images and decode failures pass through unchanged. Requires Pillow when
5
+ compaction is requested; without Pillow, inputs are returned unchanged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import io
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class CompactImageOptions:
18
+ """Controls ``compact_image_attachment``. Zero / unset fields use defaults."""
19
+
20
+ max_dim: int = 768
21
+ jpeg_quality: int = 85
22
+
23
+
24
+ def _compact_defaults(opts: CompactImageOptions | None) -> tuple[int, int]:
25
+ max_dim, quality = 768, 85
26
+ if opts is None:
27
+ return max_dim, quality
28
+ if opts.max_dim > 0:
29
+ max_dim = opts.max_dim
30
+ if opts.jpeg_quality > 0:
31
+ quality = opts.jpeg_quality
32
+ return max_dim, quality
33
+
34
+
35
+ def compact_image_attachment(
36
+ mime_type: str,
37
+ data_b64: str,
38
+ opts: CompactImageOptions | None = None,
39
+ ) -> tuple[str, str]:
40
+ """Downscale an image attachment when either dimension exceeds ``max_dim``.
41
+
42
+ Args:
43
+ mime_type: Attachment MIME type (e.g. ``image/png``).
44
+ data_b64: Base64-encoded image bytes.
45
+ opts: Optional size / JPEG quality overrides.
46
+
47
+ Returns:
48
+ ``(out_mime, out_b64)``. Non-images and failures return inputs unchanged.
49
+ """
50
+ if not data_b64 or not mime_type.startswith("image/"):
51
+ return mime_type, data_b64
52
+ try:
53
+ from PIL import Image
54
+ except ImportError:
55
+ return mime_type, data_b64
56
+
57
+ try:
58
+ raw = base64.b64decode(data_b64, validate=False)
59
+ except Exception:
60
+ return mime_type, data_b64
61
+ if not raw:
62
+ return mime_type, data_b64
63
+
64
+ try:
65
+ with Image.open(io.BytesIO(raw)) as img:
66
+ img.load()
67
+ w, h = img.size
68
+ max_dim, quality = _compact_defaults(opts)
69
+ if w <= max_dim and h <= max_dim:
70
+ return mime_type, data_b64
71
+ if w >= h:
72
+ nw = max_dim if w > max_dim else w
73
+ nh = max(1, h * nw // w) if w > max_dim else h
74
+ else:
75
+ nh = max_dim if h > max_dim else h
76
+ nw = max(1, w * nh // h) if h > max_dim else w
77
+ resized = img.resize((nw, nh), Image.Resampling.LANCZOS)
78
+ buf = io.BytesIO()
79
+ out_mime = mime_type
80
+ if mime_type == "image/png":
81
+ if resized.mode not in ("RGB", "RGBA", "L", "P"):
82
+ resized = resized.convert("RGBA")
83
+ resized.save(buf, format="PNG")
84
+ else:
85
+ if resized.mode != "RGB":
86
+ resized = resized.convert("RGB")
87
+ resized.save(buf, format="JPEG", quality=quality)
88
+ out_mime = "image/jpeg"
89
+ return out_mime, base64.b64encode(buf.getvalue()).decode("ascii")
90
+ except Exception:
91
+ return mime_type, data_b64
92
+
93
+
94
+ def compact_attachments(
95
+ atts: list[dict[str, Any]] | None,
96
+ opts: CompactImageOptions | None = None,
97
+ ) -> list[dict[str, Any]]:
98
+ """Apply ``compact_image_attachment`` to each ``mime_type`` + ``data`` map."""
99
+ if not atts:
100
+ return atts or []
101
+ out: list[dict[str, Any]] = []
102
+ for att in atts:
103
+ cp = dict(att)
104
+ mime = cp.get("mime_type")
105
+ data = cp.get("data")
106
+ if isinstance(mime, str) and isinstance(data, str) and mime and data:
107
+ out_mime, out_data = compact_image_attachment(mime, data, opts)
108
+ cp["mime_type"] = out_mime
109
+ cp["data"] = out_data
110
+ out.append(cp)
111
+ return out
112
+
113
+
114
+ __all__ = [
115
+ "CompactImageOptions",
116
+ "compact_attachments",
117
+ "compact_image_attachment",
118
+ ]
@@ -0,0 +1,116 @@
1
+ """SSE-style pub/sub fan-out for appkit (RFC-629 Layer 1).
2
+
3
+ Generic, string-keyed pub/sub for SSE-style event delivery. Slow consumers do
4
+ not stall the broadcaster: each subscriber has a bounded queue and overflowing
5
+ events are dropped (drop-on-full).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ from collections.abc import AsyncIterator
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+ from uuid import uuid4
16
+
17
+ SUBSCRIBER_QUEUE_CAP = 100
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class SSEEvent:
22
+ """One Server-Sent Event payload. The ``type`` vocabulary is app-defined."""
23
+
24
+ type: str
25
+ data: Any = None
26
+
27
+
28
+ @dataclass
29
+ class _Subscriber:
30
+ queue: asyncio.Queue[SSEEvent | None] = field(
31
+ default_factory=lambda: asyncio.Queue(maxsize=SUBSCRIBER_QUEUE_CAP)
32
+ )
33
+ closed: bool = False
34
+
35
+
36
+ class SSEBroadcaster:
37
+ """Fan events out to all subscribers for a session id (non-blocking)."""
38
+
39
+ def __init__(self) -> None:
40
+ self._subscribers: dict[str, dict[str, _Subscriber]] = {}
41
+
42
+ def subscribe(self, session_id: str) -> tuple[AsyncIterator[SSEEvent], str]:
43
+ """Register a subscriber and return ``(async iterator, subscription id)``.
44
+
45
+ Unsubscribe via ``unsubscribe`` or ``close``.
46
+ """
47
+ sub_id = str(uuid4())
48
+ sub = _Subscriber()
49
+ subs = self._subscribers.get(session_id)
50
+ if subs is None:
51
+ subs = {}
52
+ self._subscribers[session_id] = subs
53
+ subs[sub_id] = sub
54
+ return _iterate(sub), sub_id
55
+
56
+ def unsubscribe(self, session_id: str, sub_id: str) -> None:
57
+ """Remove a subscriber by id and close its iterator. Safe if unknown."""
58
+ subs = self._subscribers.get(session_id)
59
+ if not subs:
60
+ return
61
+ sub = subs.pop(sub_id, None)
62
+ if sub is None:
63
+ return
64
+ self._close_sub(sub)
65
+ if not subs:
66
+ del self._subscribers[session_id]
67
+
68
+ def broadcast(self, session_id: str, event: SSEEvent) -> None:
69
+ """Send an event to all subscribers (drop-on-full, non-blocking)."""
70
+ subs = self._subscribers.get(session_id)
71
+ if not subs:
72
+ return
73
+ for sub in subs.values():
74
+ if sub.closed:
75
+ continue
76
+ try:
77
+ sub.queue.put_nowait(event)
78
+ except asyncio.QueueFull:
79
+ pass # drop-on-full
80
+
81
+ def close(self, session_id: str) -> None:
82
+ """Close all subscribers for a session id and remove the entry."""
83
+ subs = self._subscribers.pop(session_id, None)
84
+ if not subs:
85
+ return
86
+ for sub in subs.values():
87
+ self._close_sub(sub)
88
+
89
+ def close_all(self) -> None:
90
+ """Close every subscriber channel across all sessions."""
91
+ for session_id in list(self._subscribers):
92
+ self.close(session_id)
93
+
94
+ @staticmethod
95
+ def _close_sub(sub: _Subscriber) -> None:
96
+ if sub.closed:
97
+ return
98
+ sub.closed = True
99
+ try:
100
+ sub.queue.put_nowait(None)
101
+ except asyncio.QueueFull:
102
+ with contextlib.suppress(asyncio.QueueEmpty):
103
+ sub.queue.get_nowait()
104
+ with contextlib.suppress(asyncio.QueueFull):
105
+ sub.queue.put_nowait(None)
106
+
107
+
108
+ async def _iterate(sub: _Subscriber) -> AsyncIterator[SSEEvent]:
109
+ while True:
110
+ item = await sub.queue.get()
111
+ if item is None:
112
+ return
113
+ yield item
114
+
115
+
116
+ __all__ = ["SSEEvent", "SSEBroadcaster", "SUBSCRIBER_QUEUE_CAP"]
@@ -0,0 +1,99 @@
1
+ """Early filters for daemon stream chunks (wire / dict-shaped payloads).
2
+
3
+ CLI callers that also see LangChain message objects should pass a richer filter
4
+ callback (e.g. ``soothe_cli.runtime.wire.chunk_filter.should_drop_stream_chunk_early``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ _MSG_PAIR_LEN = 2
12
+
13
+
14
+ def updates_chunk_is_noop(data: Any) -> bool:
15
+ """True when an ``updates`` chunk carries no LangGraph interrupt."""
16
+ if not isinstance(data, dict):
17
+ return True
18
+ return "__interrupt__" not in data
19
+
20
+
21
+ def _wire_body(msg: dict[str, Any]) -> dict[str, Any]:
22
+ for key in ("kwargs", "data"):
23
+ nested = msg.get(key)
24
+ if isinstance(nested, dict):
25
+ return nested
26
+ return msg
27
+
28
+
29
+ def _dict_has_tool_invocation(msg: dict[str, Any]) -> bool:
30
+ body = _wire_body(msg)
31
+ if body.get("tool_calls") or body.get("tool_call_chunks"):
32
+ return True
33
+ for key in ("content", "content_blocks"):
34
+ raw = body.get(key)
35
+ if isinstance(raw, list):
36
+ for item in raw:
37
+ if isinstance(item, dict) and item.get("type") in {
38
+ "tool_call",
39
+ "tool_call_chunk",
40
+ "tool_use",
41
+ }:
42
+ return True
43
+ return False
44
+
45
+
46
+ def _plain_text(msg: dict[str, Any]) -> str:
47
+ body = _wire_body(msg)
48
+ content = body.get("content", msg.get("content"))
49
+ if isinstance(content, str):
50
+ return content
51
+ if isinstance(content, list):
52
+ parts: list[str] = []
53
+ for block in content:
54
+ if isinstance(block, str):
55
+ parts.append(block)
56
+ elif isinstance(block, dict):
57
+ text = block.get("text")
58
+ if isinstance(text, str):
59
+ parts.append(text)
60
+ return "".join(parts)
61
+ return ""
62
+
63
+
64
+ def message_chunk_is_non_actionable(data: Any) -> bool:
65
+ """True when a wire ``messages`` pair has no tool, text, or loop phase payload."""
66
+ if not isinstance(data, (list, tuple)) or len(data) != _MSG_PAIR_LEN:
67
+ return False
68
+ msg = data[0]
69
+ if msg is None:
70
+ return True
71
+ if not isinstance(msg, dict):
72
+ # Non-dict messages (e.g. LangChain objects) are not filtered here.
73
+ return False
74
+ body = _wire_body(msg)
75
+ raw = str(body.get("type") or msg.get("type") or "")
76
+ if raw in ("tool", "ToolMessage") or raw.endswith("ToolMessage"):
77
+ return False
78
+ if _dict_has_tool_invocation(msg):
79
+ return False
80
+ if body.get("phase") or msg.get("phase"):
81
+ return False
82
+ return not _plain_text(msg).strip()
83
+
84
+
85
+ def should_drop_stream_chunk_early(namespace: tuple[Any, ...], mode: str, data: Any) -> bool:
86
+ """Return True when the chunk can be skipped before the turn pipeline."""
87
+ del namespace # reserved for product filters
88
+ if mode == "updates":
89
+ return updates_chunk_is_noop(data)
90
+ if mode == "messages":
91
+ return message_chunk_is_non_actionable(data)
92
+ return False
93
+
94
+
95
+ __all__ = [
96
+ "message_chunk_is_non_actionable",
97
+ "should_drop_stream_chunk_early",
98
+ "updates_chunk_is_noop",
99
+ ]