soothe-client-python 0.9.4__tar.gz → 0.9.6__tar.gz

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 (31) hide show
  1. soothe_client_python-0.9.6/PKG-INFO +85 -0
  2. soothe_client_python-0.9.6/README.md +52 -0
  3. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/pyproject.toml +6 -5
  4. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/__init__.py +27 -4
  5. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/__init__.py +7 -10
  6. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/attachments.py +4 -7
  7. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/broadcaster.py +1 -1
  8. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/classifier.py +1 -1
  9. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/daemon_session.py +40 -8
  10. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/managed_client.py +68 -6
  11. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/pool.py +1 -1
  12. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/query_gate.py +1 -1
  13. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/session_store.py +1 -1
  14. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/thinking_step.py +1 -1
  15. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/turn_runner.py +2 -2
  16. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/helpers.py +93 -18
  17. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/protocol_params.py +131 -67
  18. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/schemas.py +3 -9
  19. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/session.py +6 -7
  20. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/websocket.py +53 -45
  21. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/ws_command_client.py +29 -21
  22. soothe_client_python-0.9.4/PKG-INFO +0 -131
  23. soothe_client_python-0.9.4/README.md +0 -97
  24. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/.gitignore +0 -0
  25. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/LICENSE +0 -0
  26. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/chunk_filter.py +0 -0
  27. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/events.py +0 -0
  28. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/observability.py +0 -0
  29. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/appkit/turn.py +0 -0
  30. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/errors.py +0 -0
  31. {soothe_client_python-0.9.4 → soothe_client_python-0.9.6}/src/soothe_client/intent_hints.py +0 -0
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: soothe-client-python
3
+ Version: 0.9.6
4
+ Summary: WebSocket client + appkit for soothe-daemon (Python)
5
+ Project-URL: Homepage, https://github.com/mirasoth/soothe-client-python
6
+ Project-URL: Documentation, https://soothe.readthedocs.io
7
+ Project-URL: Repository, https://github.com/mirasoth/soothe-client-python
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,ai,client,soothe,websocket
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: <3.15,>=3.11
22
+ Requires-Dist: pillow<12.0.0,>=10.0.0
23
+ Requires-Dist: pydantic<3.0.0,>=2.0.0
24
+ Requires-Dist: soothe-sdk<1.0.0,>=0.8.1
25
+ Requires-Dist: websockets>=12.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
29
+ Requires-Dist: pytest-cov; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.12.0; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # soothe-client-python
35
+
36
+ Talk to a running **soothe-daemon** over WebSocket — send prompts, stream agent
37
+ turns, run jobs.
38
+
39
+ ```bash
40
+ pip install soothe-client-python
41
+ ```
42
+
43
+ Requires a local daemon (default `ws://127.0.0.1:8765`).
44
+
45
+ ## Quick start
46
+
47
+ ```python
48
+ import asyncio
49
+ from soothe_client.appkit import DaemonSession
50
+
51
+ async def main() -> None:
52
+ session = DaemonSession("ws://127.0.0.1:8765")
53
+ await session.connect()
54
+ await session.send_turn("Summarize this in one sentence: agents need tools.")
55
+ async for _namespace, mode, data in session.iter_turn_chunks():
56
+ if mode == "custom" and isinstance(data, dict):
57
+ text = data.get("content") or data.get("text")
58
+ if text:
59
+ print(text, end="", flush=True)
60
+ print()
61
+ await session.close()
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ More patterns: [`examples/`](examples/) (hello → streaming → multi-turn → pool → jobs).
67
+
68
+ ## What you get
69
+
70
+ | Need | Use |
71
+ |------|-----|
72
+ | One conversation, stream replies | `DaemonSession` |
73
+ | Raw WebSocket / custom RPCs | `WebSocketClient` |
74
+ | Many users / HTTP backend | `ConnectionPool` + `TurnRunner` |
75
+ | Jobs / autopilot / cron | `WsCommandClient` |
76
+
77
+ ## Develop
78
+
79
+ ```bash
80
+ make sync-dev
81
+ make check # lint + unit tests
82
+ make test-examples-offline # offline appkit examples
83
+ make test-examples # live 01–06 (needs soothed)
84
+ make test-integration # live integration suite (needs soothed)
85
+ ```
@@ -0,0 +1,52 @@
1
+ # soothe-client-python
2
+
3
+ Talk to a running **soothe-daemon** over WebSocket — send prompts, stream agent
4
+ turns, run jobs.
5
+
6
+ ```bash
7
+ pip install soothe-client-python
8
+ ```
9
+
10
+ Requires a local daemon (default `ws://127.0.0.1:8765`).
11
+
12
+ ## Quick start
13
+
14
+ ```python
15
+ import asyncio
16
+ from soothe_client.appkit import DaemonSession
17
+
18
+ async def main() -> None:
19
+ session = DaemonSession("ws://127.0.0.1:8765")
20
+ await session.connect()
21
+ await session.send_turn("Summarize this in one sentence: agents need tools.")
22
+ async for _namespace, mode, data in session.iter_turn_chunks():
23
+ if mode == "custom" and isinstance(data, dict):
24
+ text = data.get("content") or data.get("text")
25
+ if text:
26
+ print(text, end="", flush=True)
27
+ print()
28
+ await session.close()
29
+
30
+ asyncio.run(main())
31
+ ```
32
+
33
+ More patterns: [`examples/`](examples/) (hello → streaming → multi-turn → pool → jobs).
34
+
35
+ ## What you get
36
+
37
+ | Need | Use |
38
+ |------|-----|
39
+ | One conversation, stream replies | `DaemonSession` |
40
+ | Raw WebSocket / custom RPCs | `WebSocketClient` |
41
+ | Many users / HTTP backend | `ConnectionPool` + `TurnRunner` |
42
+ | Jobs / autopilot / cron | `WsCommandClient` |
43
+
44
+ ## Develop
45
+
46
+ ```bash
47
+ make sync-dev
48
+ make check # lint + unit tests
49
+ make test-examples-offline # offline appkit examples
50
+ make test-examples # live 01–06 (needs soothed)
51
+ make test-integration # live integration suite (needs soothed)
52
+ ```
@@ -23,15 +23,13 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
24
  ]
25
25
  dependencies = [
26
- "soothe-sdk>=0.5.25,<1.0.0",
26
+ "soothe-sdk>=0.8.1,<1.0.0",
27
27
  "pydantic>=2.0.0,<3.0.0",
28
28
  "websockets>=12.0",
29
+ "pillow>=10.0.0,<12.0.0",
29
30
  ]
30
31
 
31
32
  [project.optional-dependencies]
32
- image = [
33
- "pillow>=10.0.0",
34
- ]
35
33
  dev = [
36
34
  "pytest>=8.0.0",
37
35
  "pytest-asyncio>=1.3.0",
@@ -79,5 +77,8 @@ disallow_untyped_defs = true
79
77
 
80
78
  [tool.pytest.ini_options]
81
79
  asyncio_mode = "auto"
82
- testpaths = ["tests", "examples"]
80
+ testpaths = ["tests/unit", "examples/appkit"]
83
81
  addopts = "--import-mode=importlib"
82
+ markers = [
83
+ "integration: live soothe-daemon tests (skipped when unreachable)",
84
+ ]
@@ -1,8 +1,7 @@
1
- """Soothe WebSocket client for soothe-daemon.
1
+ """Soothe WebSocket client for talking to a running soothe-daemon.
2
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`).
3
+ Transport helpers (connect, loops, RPCs) live here. Higher-level session
4
+ and multi-user turn tools live in ``soothe_client.appkit``.
6
5
  """
7
6
 
8
7
  from __future__ import annotations
@@ -21,12 +20,14 @@ from soothe_client.errors import (
21
20
  )
22
21
  from soothe_client.helpers import (
23
22
  check_daemon_status,
23
+ connected_websocket,
24
24
  fetch_config_section,
25
25
  fetch_loop_cards,
26
26
  fetch_loop_history,
27
27
  fetch_loop_messages,
28
28
  fetch_skills_catalog,
29
29
  is_daemon_live,
30
+ protocol1_rpc,
30
31
  request_daemon_config_reload,
31
32
  request_daemon_shutdown,
32
33
  websocket_url_from_config,
@@ -42,7 +43,17 @@ from soothe_client.intent_hints import (
42
43
  from soothe_client.protocol_params import (
43
44
  AuthParams,
44
45
  AuthRefreshParams,
46
+ AutopilotCancelGoalParams,
47
+ AutopilotDreamParams,
48
+ AutopilotGetGoalParams,
49
+ AutopilotGetJobParams,
50
+ AutopilotListGoalsParams,
51
+ AutopilotListJobsParams,
52
+ AutopilotResumeParams,
53
+ AutopilotStatusParams,
54
+ AutopilotSubmitParams,
45
55
  AutopilotSubscribeParams,
56
+ AutopilotWakeParams,
46
57
  ConfigGetParams,
47
58
  ConfigReloadParams,
48
59
  CronAddParams,
@@ -114,6 +125,8 @@ __all__ = [
114
125
  "bootstrap_loop_session",
115
126
  "connect_websocket_with_retries",
116
127
  "websocket_url_from_config",
128
+ "connected_websocket",
129
+ "protocol1_rpc",
117
130
  "check_daemon_status",
118
131
  "is_daemon_live",
119
132
  "request_daemon_config_reload",
@@ -144,6 +157,16 @@ __all__ = [
144
157
  "LoopDetachParams",
145
158
  "SubscribeParams",
146
159
  "AutopilotSubscribeParams",
160
+ "AutopilotStatusParams",
161
+ "AutopilotSubmitParams",
162
+ "AutopilotListGoalsParams",
163
+ "AutopilotGetGoalParams",
164
+ "AutopilotCancelGoalParams",
165
+ "AutopilotWakeParams",
166
+ "AutopilotDreamParams",
167
+ "AutopilotResumeParams",
168
+ "AutopilotListJobsParams",
169
+ "AutopilotGetJobParams",
147
170
  "JobCreateParams",
148
171
  "JobStatusParams",
149
172
  "JobPauseParams",
@@ -1,15 +1,12 @@
1
- """Reusable application-architecture layer over Layer 0 (RFC-629).
1
+ """Application helpers built on WebSocketClient.
2
2
 
3
- appkit is product-agnostic. Deliverable phases, persistence, and UI copy stay
4
- in the application (e.g. soothe-cli). Building blocks:
3
+ Product-agnostic building blocks for agent UIs and backends:
5
4
 
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
5
+ - ``DaemonSession`` — dual-socket loop session + streamed turns
6
+ - ``ConnectionPool`` / ``TurnRunner`` multi-session turn execution
7
+ - ``QueryGate`` — one-in-flight query per session
8
+ - ``EventClassifier`` / ``extract_thinking_step`` stream deliverable mapping
9
+ - ``SSEBroadcaster`` drop-on-full fan-out to subscribers
13
10
  - ``SessionStore`` — persistence seam (Protocol)
14
11
  """
15
12
 
@@ -1,8 +1,7 @@
1
- """Image attachment compaction for appkit (RFC-629 Layer 1).
1
+ """Image attachment compaction for appkit.
2
2
 
3
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.
4
+ Non-images and decode failures pass through unchanged.
6
5
  """
7
6
 
8
7
  from __future__ import annotations
@@ -12,6 +11,8 @@ import io
12
11
  from dataclasses import dataclass
13
12
  from typing import Any
14
13
 
14
+ from PIL import Image
15
+
15
16
 
16
17
  @dataclass(slots=True)
17
18
  class CompactImageOptions:
@@ -49,10 +50,6 @@ def compact_image_attachment(
49
50
  """
50
51
  if not data_b64 or not mime_type.startswith("image/"):
51
52
  return mime_type, data_b64
52
- try:
53
- from PIL import Image
54
- except ImportError:
55
- return mime_type, data_b64
56
53
 
57
54
  try:
58
55
  raw = base64.b64decode(data_b64, validate=False)
@@ -1,4 +1,4 @@
1
- """SSE-style pub/sub fan-out for appkit (RFC-629 Layer 1).
1
+ """SSE-style pub/sub fan-out for appkit.
2
2
 
3
3
  Generic, string-keyed pub/sub for SSE-style event delivery. Slow consumers do
4
4
  not stall the broadcaster: each subscriber has a bounded queue and overflowing
@@ -1,4 +1,4 @@
1
- """Event classifier for appkit (RFC-629 Layer 1).
1
+ """Event classifier for appkit.
2
2
 
3
3
  Maps a stream of decoded daemon events into deliverable/streaming/terminal
4
4
  outcomes, keyed on (namespace, mode, phase). Product apps pass their own
@@ -1,9 +1,8 @@
1
- """Dual-socket daemon loop session with turn streaming (CLI-grade mechanics).
1
+ """Dual-socket daemon loop session with turn streaming.
2
2
 
3
3
  Owns a subscribed stream WebSocket plus an RPC sidecar so metadata calls do not
4
- starve ``loop_events``. ``iter_turn_chunks`` implements stale-idle guarding,
5
- post-idle drain, loop scoping, and connection-loss detection — behavior that
6
- originated in soothe-cli and is not yet mirrored in Go/TS TurnRunner.
4
+ starve loop events. ``iter_turn_chunks`` handles idle timeout, post-idle drain,
5
+ loop scoping, and connection-loss detection.
7
6
  """
8
7
 
9
8
  from __future__ import annotations
@@ -25,7 +24,12 @@ logger = logging.getLogger(__name__)
25
24
 
26
25
  DEFAULT_POST_IDLE_DRAIN_S = 0.5
27
26
  _RPC_HANDSHAKE_TIMEOUT_S = 20.0
28
- _TURN_END_CUSTOM_TYPES = frozenset({"soothe.cognition.strange_loop.completed"})
27
+ _TURN_END_CUSTOM_TYPES = frozenset(
28
+ {
29
+ "soothe.cognition.strange_loop.completed",
30
+ "soothe.stream.end",
31
+ }
32
+ )
29
33
 
30
34
  EarlyDropFn = Callable[[tuple[Any, ...], str, Any], bool]
31
35
  StatsFactory = Callable[[], Any]
@@ -210,6 +214,7 @@ class DaemonSession:
210
214
  clarification_mode: str | None = None,
211
215
  clarification_answer: bool = False,
212
216
  clarification_answers: list[str] | None = None,
217
+ intent_hint: str | None = None,
213
218
  ) -> None:
214
219
  """Send a new user turn to the daemon."""
215
220
  if not self._loop_id:
@@ -227,6 +232,7 @@ class DaemonSession:
227
232
  clarification_mode=clarification_mode,
228
233
  clarification_answer=clarification_answer,
229
234
  clarification_answers=clarification_answers,
235
+ intent_hint=intent_hint,
230
236
  )
231
237
 
232
238
  async def cancel_remote_query(self) -> None:
@@ -289,8 +295,19 @@ class DaemonSession:
289
295
  await self._ensure_rpc_connected()
290
296
  return await self._rpc_client.request("loop_list", {"limit": limit}, timeout=15.0)
291
297
 
292
- async def iter_turn_chunks(self) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
293
- """Yield ``(namespace, mode, data)`` chunks for the active daemon turn."""
298
+ async def iter_turn_chunks(
299
+ self,
300
+ *,
301
+ max_wait_s: float | None = None,
302
+ ) -> AsyncIterator[tuple[tuple[Any, ...], str, Any]]:
303
+ """Yield ``(namespace, mode, data)`` chunks for the active daemon turn.
304
+
305
+ Args:
306
+ max_wait_s: Optional absolute deadline for the whole turn. When set,
307
+ raises ``TimeoutError`` if the daemon never emits a turn-end
308
+ signal in time (idle after payload, stopped, stream.end, or
309
+ strange_loop.completed).
310
+ """
294
311
  self.turn_event_stats = self._new_turn_stats()
295
312
  self.last_turn_end_state = None
296
313
  self.last_turn_cancellation_seen = False
@@ -303,6 +320,9 @@ class DaemonSession:
303
320
  turn_read_started = time.monotonic()
304
321
  first_event_logged = False
305
322
  progress_seen = False
323
+ absolute_deadline = (
324
+ time.monotonic() + max_wait_s if max_wait_s is not None and max_wait_s > 0 else None
325
+ )
306
326
  peel = getattr(self._client, "peel_stale_pending_control_events", None)
307
327
  stale_pending = peel() if callable(peel) else []
308
328
  if stale_pending:
@@ -315,6 +335,11 @@ class DaemonSession:
315
335
  async with self._read_lock:
316
336
  try:
317
337
  while True:
338
+ if absolute_deadline is not None and time.monotonic() >= absolute_deadline:
339
+ raise TimeoutError(
340
+ f"Turn timed out after {max_wait_s:.0f}s "
341
+ f"(loop={expected_loop_id or '?'})"
342
+ )
318
343
  if not progress_seen and time.monotonic() - turn_read_started > 30.0:
319
344
  logger.warning(
320
345
  "No daemon stream progress after %.0fs (loop=%s, "
@@ -407,7 +432,14 @@ class DaemonSession:
407
432
  if mode == "custom" and isinstance(data, dict):
408
433
  custom_type = str(data.get("type", "")).strip()
409
434
  if custom_type in _TURN_END_CUSTOM_TYPES:
410
- self.last_turn_end_state = "completed"
435
+ # stream.end may be scope-tagged; treat blank / turn as end.
436
+ if custom_type == "soothe.stream.end":
437
+ scope = str(data.get("scope") or "turn").strip().lower()
438
+ if scope not in {"", "turn"}:
439
+ continue
440
+ self.last_turn_end_state = (
441
+ "stream_end" if custom_type == "soothe.stream.end" else "completed"
442
+ )
411
443
  async for chunk in self._drain_stream_events_after_idle(
412
444
  expected_loop_id=expected_loop_id,
413
445
  ):
@@ -1,7 +1,7 @@
1
- """ManagedClient seam for appkit ConnectionPool / TurnRunner (RFC-629).
1
+ """ManagedClient adapter used by ConnectionPool and TurnRunner.
2
2
 
3
- The concrete ``WebSocketClient`` satisfies it via ``WebSocketManagedClient``;
4
- tests supply fakes.
3
+ Wraps ``WebSocketClient`` and upgrades flat appkit payloads
4
+ (``loop_input``, ``command_request``, …) to protocol-1 envelopes before send.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -18,10 +18,72 @@ BootstrapFunc = Callable[
18
18
  Awaitable[str],
19
19
  ]
20
20
 
21
+ # Flat appkit payloads still use legacy ``{"type": "<op>", ...}`` maps (parity
22
+ # with Go InputMessageForLoop). The live daemon accepts protocol-1 envelopes
23
+ # only — coerce at the ManagedClient boundary before send.
24
+ _ALREADY_ENVELOPE_TYPES = frozenset(
25
+ {
26
+ "request",
27
+ "response",
28
+ "notification",
29
+ "subscribe",
30
+ "next",
31
+ "error",
32
+ "complete",
33
+ "unsubscribe",
34
+ "connection_init",
35
+ "connection_ack",
36
+ "ping",
37
+ "pong",
38
+ "receipt_response",
39
+ }
40
+ )
41
+ _NOTIFICATION_METHODS = frozenset({"loop_input", "slash_command", "disconnect", "delivery_ack"})
42
+ _REQUEST_METHOD_ALIASES = {
43
+ # TurnRunner cancel uses flat command_request; wire method is rpc_command.
44
+ "command_request": "rpc_command",
45
+ }
46
+
47
+
48
+ def _coerce_appkit_wire_message(msg: dict[str, Any], client: WebSocketClient) -> dict[str, Any]:
49
+ """Upgrade flat appkit dicts to protocol-1 envelopes when needed."""
50
+ from soothe_sdk.wire.codec import MessageType, WireEnvelope
51
+
52
+ msg_type = str(msg.get("type") or "")
53
+ if msg.get("proto") == "1" or msg_type in _ALREADY_ENVELOPE_TYPES:
54
+ return msg
55
+
56
+ params = {
57
+ key: value
58
+ for key, value in msg.items()
59
+ if key not in {"type", "proto", "method", "params", "id", "request_id"}
60
+ }
61
+
62
+ if msg_type in _NOTIFICATION_METHODS:
63
+ return WireEnvelope(
64
+ proto="1",
65
+ type=MessageType.NOTIFICATION.value,
66
+ method=msg_type,
67
+ params=params,
68
+ ).to_wire_dict()
69
+
70
+ if msg_type in _REQUEST_METHOD_ALIASES or msg_type:
71
+ request_method = _REQUEST_METHOD_ALIASES.get(msg_type, msg_type)
72
+ req_id = str(msg.get("request_id") or msg.get("id") or client._next_request_id())
73
+ return WireEnvelope(
74
+ proto="1",
75
+ type=MessageType.REQUEST.value,
76
+ method=request_method,
77
+ params=params,
78
+ id=req_id,
79
+ ).to_wire_dict()
80
+
81
+ return msg
82
+
21
83
 
22
84
  @runtime_checkable
23
85
  class ManagedClient(Protocol):
24
- """Subset of Layer 0 that ConnectionPool and TurnRunner depend on."""
86
+ """Subset of WebSocketClient that ConnectionPool and TurnRunner depend on."""
25
87
 
26
88
  async def connect(self) -> None:
27
89
  """Dial and handshake."""
@@ -72,7 +134,7 @@ class WebSocketManagedClient:
72
134
 
73
135
  @property
74
136
  def underlying(self) -> WebSocketClient:
75
- """Return the wrapped Layer 0 client."""
137
+ """Return the wrapped WebSocketClient."""
76
138
  return self._client
77
139
 
78
140
  async def connect(self) -> None:
@@ -89,7 +151,7 @@ class WebSocketManagedClient:
89
151
  async def send_message(self, msg: Any) -> None:
90
152
  if not isinstance(msg, dict):
91
153
  raise TypeError("send_message expects a dict payload")
92
- await self._client.send(msg)
154
+ await self._client.send(_coerce_appkit_wire_message(msg, self._client))
93
155
 
94
156
  async def receive_messages(
95
157
  self,
@@ -1,4 +1,4 @@
1
- """Per-session connection pool for appkit (RFC-629 Layer 1).
1
+ """Per-session connection pool for appkit.
2
2
 
3
3
  Manages a pool of daemon connections, one active per session. Reuses an
4
4
  active connection when still live, otherwise bootstraps a fresh loop or
@@ -1,4 +1,4 @@
1
- """Single-flight query gate (RFC-629 Layer 1)."""
1
+ """Single-flight query gate."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -1,4 +1,4 @@
1
- """Persistence seam for appkit (RFC-629 Layer 1)."""
1
+ """Persistence seam for appkit."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -1,4 +1,4 @@
1
- """Thinking-step extraction for appkit (RFC-629 Layer 1).
1
+ """Thinking-step extraction for appkit.
2
2
 
3
3
  Maps an allowlisted progress event to one structured UI line. Free-form
4
4
  streams (tokens, reports, reasoning) are excluded. Ported from Go/TS appkit.
@@ -1,11 +1,11 @@
1
- """Turn runner for appkit (RFC-629 Layer 1).
1
+ """Turn runner for appkit.
2
2
 
3
3
  Executes one query turn end-to-end: acquire a pooled connection, enforce
4
4
  single-flight, send loop_input, consume the event stream, classify events,
5
5
  resolve the deliverable, persist the reply, and broadcast completion.
6
6
 
7
7
  Supports absolute query timeout, optional idle silence watchdog, soft-complete
8
- policies (IG-651), and optional attachment compaction before send.
8
+ policies, and optional attachment compaction before send.
9
9
  """
10
10
 
11
11
  from __future__ import annotations