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,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
|
+
]
|