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
soothe_client/session.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Shared WebSocket session bootstrap for CLI headless and TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from soothe_sdk.wire.codec import ProtocolError
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_CONNECT_RETRY_COUNT = 40
|
|
15
|
+
_CONNECT_RETRY_DELAY_S = 0.25
|
|
16
|
+
_CONNECT_TIMEOUT_S = 5.0
|
|
17
|
+
_DAEMON_READY_TIMEOUT_S = 20.0
|
|
18
|
+
_SESSION_BOOTSTRAP_TIMEOUT_S = 30.0
|
|
19
|
+
|
|
20
|
+
# Wire-compat: interactive loops are always solo; daemon jobs use AutopilotService.
|
|
21
|
+
_LEGACY_LOOP_AUTOPILOT_MODE = "solo"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def connect_websocket_with_retries(client: Any) -> None:
|
|
25
|
+
"""Connect to the daemon with bounded retries for cold-start races.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
client: WebSocketClient instance.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ConnectionError: If connection fails after all retries.
|
|
32
|
+
"""
|
|
33
|
+
last_error: OSError | ConnectionError | TimeoutError | None = None
|
|
34
|
+
for attempt in range(_CONNECT_RETRY_COUNT):
|
|
35
|
+
try:
|
|
36
|
+
await asyncio.wait_for(client.connect(), timeout=_CONNECT_TIMEOUT_S)
|
|
37
|
+
except (ConnectionRefusedError, OSError, ConnectionError, TimeoutError) as exc:
|
|
38
|
+
last_error = exc
|
|
39
|
+
if attempt == _CONNECT_RETRY_COUNT - 1:
|
|
40
|
+
raise
|
|
41
|
+
await asyncio.sleep(_CONNECT_RETRY_DELAY_S)
|
|
42
|
+
else:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
if last_error is not None:
|
|
46
|
+
raise last_error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def bootstrap_loop_session(
|
|
50
|
+
client: Any,
|
|
51
|
+
*,
|
|
52
|
+
resume_loop_id: str | None,
|
|
53
|
+
workspace: str | Path | None = None,
|
|
54
|
+
user_id: str | None = None,
|
|
55
|
+
client_workspace_id: str | None = None,
|
|
56
|
+
stream_delivery: str = "adaptive",
|
|
57
|
+
is_ephemeral: bool = False,
|
|
58
|
+
daemon_ready_timeout_s: float = _DAEMON_READY_TIMEOUT_S,
|
|
59
|
+
subscribe_timeout_s: float = _SESSION_BOOTSTRAP_TIMEOUT_S,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Handshake with the daemon, create or attach to a loop, and subscribe for events.
|
|
62
|
+
|
|
63
|
+
Uses the protocol-1 wire contract (RFC-450): ``connection_init``/``ack``
|
|
64
|
+
handshake, then ``request('loop_new')`` or ``request('loop_reattach')`` for
|
|
65
|
+
resume, then ``subscribe('loop_events')`` for the event stream.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
client: ``WebSocketClient`` instance (connected).
|
|
69
|
+
resume_loop_id: If set, reattach to this existing loop via
|
|
70
|
+
``loop_reattach``. Otherwise create a new loop via ``loop_new``.
|
|
71
|
+
stream_delivery: Daemon stream shaping — one of ``batch`` | ``adaptive``
|
|
72
|
+
(default, IG-441) | ``streaming``.
|
|
73
|
+
is_ephemeral: When True, loop execution data is GC'd after idle period.
|
|
74
|
+
workspace: Optional client project directory (e.g. user's CWD). Sent as
|
|
75
|
+
``workspace`` on ``loop_new`` and used directly by the runner when set.
|
|
76
|
+
Ignored on resume when the loop already has workspace metadata.
|
|
77
|
+
user_id: Optional user id for ``$SOOTHE_HOME/workspaces/<user>/`` layout.
|
|
78
|
+
client_workspace_id: Optional stable scope when ``workspace`` is omitted.
|
|
79
|
+
daemon_ready_timeout_s: Max seconds for connection_ack handshake.
|
|
80
|
+
subscribe_timeout_s: Max seconds for ``loop_new`` / ``loop_reattach`` /
|
|
81
|
+
``loop_events`` RPCs.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A dict with at least ``loop_id`` on success, or an ``error`` event-shaped dict.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
TimeoutError: If a waited step times out.
|
|
88
|
+
RuntimeError: If daemon reports not-ready during handshake.
|
|
89
|
+
ConnectionError: If the WebSocket is closed and cannot be re-established.
|
|
90
|
+
ProtocolError: If the daemon returns a protocol-level error for
|
|
91
|
+
``loop_new``, ``loop_reattach``, or the ``loop_events`` subscription.
|
|
92
|
+
"""
|
|
93
|
+
alive_check = getattr(client, "is_connection_alive", None)
|
|
94
|
+
if alive_check is not None and not alive_check():
|
|
95
|
+
await client.close()
|
|
96
|
+
await connect_websocket_with_retries(client)
|
|
97
|
+
|
|
98
|
+
await client.request_connection_init()
|
|
99
|
+
await client.wait_for_connection_ack(ack_timeout_s=daemon_ready_timeout_s)
|
|
100
|
+
|
|
101
|
+
mapping_data: dict[str, Any] | None = None
|
|
102
|
+
|
|
103
|
+
if resume_loop_id:
|
|
104
|
+
# Resume path: reattach to the existing loop via ``loop_reattach`` so the
|
|
105
|
+
# daemon replays card history before we subscribe to the live event stream.
|
|
106
|
+
reattach_params: dict[str, Any] = {"loop_id": resume_loop_id}
|
|
107
|
+
try:
|
|
108
|
+
await client.request(
|
|
109
|
+
"loop_reattach",
|
|
110
|
+
reattach_params,
|
|
111
|
+
timeout=subscribe_timeout_s,
|
|
112
|
+
)
|
|
113
|
+
except ProtocolError:
|
|
114
|
+
logger.warning(
|
|
115
|
+
"loop_reattach failed for loop %s; subscribing without replay",
|
|
116
|
+
resume_loop_id,
|
|
117
|
+
exc_info=True,
|
|
118
|
+
)
|
|
119
|
+
loop_id = resume_loop_id
|
|
120
|
+
else:
|
|
121
|
+
# New-loop path: ``loop_new`` creates the conversation. Per RFC-450 §10.1
|
|
122
|
+
# the field is ``workspace`` (renamed from ``client_workspace``).
|
|
123
|
+
loop_new_params: dict[str, Any] = {}
|
|
124
|
+
if workspace is not None:
|
|
125
|
+
workspace_str = str(workspace).strip()
|
|
126
|
+
if workspace_str:
|
|
127
|
+
loop_new_params["workspace"] = workspace_str
|
|
128
|
+
if user_id is not None and str(user_id).strip():
|
|
129
|
+
loop_new_params["user_id"] = str(user_id).strip()
|
|
130
|
+
if client_workspace_id is not None and str(client_workspace_id).strip():
|
|
131
|
+
loop_new_params["client_workspace_id"] = str(client_workspace_id).strip()
|
|
132
|
+
if is_ephemeral:
|
|
133
|
+
loop_new_params["is_ephemeral"] = True
|
|
134
|
+
# ``request`` raises ``ProtocolError`` if the daemon returns an error
|
|
135
|
+
# envelope (e.g. INVALID_PARAMS, WORKSPACE_RESOLUTION_FAILED). Let it
|
|
136
|
+
# propagate — bootstrap cannot continue without a valid loop_id.
|
|
137
|
+
new_resp = await client.request(
|
|
138
|
+
"loop_new",
|
|
139
|
+
loop_new_params,
|
|
140
|
+
timeout=subscribe_timeout_s,
|
|
141
|
+
)
|
|
142
|
+
loop_id = str(new_resp.get("loop_id") or "")
|
|
143
|
+
if not loop_id:
|
|
144
|
+
raise ValueError("loop_new response missing loop_id")
|
|
145
|
+
|
|
146
|
+
# RFC-621: parse workspace mapping for container path translation
|
|
147
|
+
mapping_data = new_resp.get("workspace_mapping")
|
|
148
|
+
if mapping_data and mapping_data.get("host_root") and mapping_data.get("container_root"):
|
|
149
|
+
from soothe_sdk.wire.protocol import WorkspaceMapping
|
|
150
|
+
|
|
151
|
+
workspace_mapping = WorkspaceMapping(
|
|
152
|
+
host_root=mapping_data["host_root"],
|
|
153
|
+
container_root=mapping_data["container_root"],
|
|
154
|
+
)
|
|
155
|
+
# Store on client for use in event path translation
|
|
156
|
+
if hasattr(client, "workspace_mapping"):
|
|
157
|
+
client.workspace_mapping = workspace_mapping
|
|
158
|
+
|
|
159
|
+
# IG-441: three first-class modes (batch / adaptive / streaming). Unknown
|
|
160
|
+
# values fall back to ``adaptive`` (the new bootstrap default).
|
|
161
|
+
delivery = (
|
|
162
|
+
stream_delivery if stream_delivery in ("batch", "adaptive", "streaming") else "adaptive"
|
|
163
|
+
)
|
|
164
|
+
# Subscribe to the loop's event stream. Protocol-1 uses ``subscribe`` with
|
|
165
|
+
# the ``loop_events`` target — the subscription is confirmed implicitly
|
|
166
|
+
# (RFC-450 §9.4). If the daemon cannot honour it, an ``error`` with the
|
|
167
|
+
# subscription ``id`` arrives within the timeout window and ``subscribe``
|
|
168
|
+
# raises ``ProtocolError``.
|
|
169
|
+
await client.subscribe(
|
|
170
|
+
"loop_events",
|
|
171
|
+
{
|
|
172
|
+
"loop_id": loop_id,
|
|
173
|
+
"stream_delivery": delivery,
|
|
174
|
+
"wire_tier": "full",
|
|
175
|
+
},
|
|
176
|
+
timeout=subscribe_timeout_s,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
logger.info(
|
|
180
|
+
"Subscribed to loop %s with stream_delivery=%s",
|
|
181
|
+
loop_id,
|
|
182
|
+
delivery,
|
|
183
|
+
)
|
|
184
|
+
result: dict[str, Any] = {
|
|
185
|
+
"type": "session_ready",
|
|
186
|
+
"loop_id": loop_id,
|
|
187
|
+
"success": True,
|
|
188
|
+
# Deprecated wire field: interactive loops are always solo.
|
|
189
|
+
"autopilot_mode": _LEGACY_LOOP_AUTOPILOT_MODE,
|
|
190
|
+
}
|
|
191
|
+
if mapping_data and mapping_data.get("host_root"):
|
|
192
|
+
result["workspace_mapping"] = mapping_data
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
__all__ = [
|
|
197
|
+
"bootstrap_loop_session",
|
|
198
|
+
"connect_websocket_with_retries",
|
|
199
|
+
]
|