steerable-sidecar 0.1.0__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.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: steerable-sidecar
3
+ Version: 0.1.0
4
+ Summary: Steerable agent runtime sidecar — JSON-RPC over stdio.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pydantic>=2.10.0
8
+ Requires-Dist: steerable-agent-protocol<1.0.0,>=0.1.0
9
+ Requires-Dist: steerable-agent-harness<1.0.0,>=0.1.0
10
+ Requires-Dist: steerable-agent-runtime<1.0.0,>=0.1.0
11
+
12
+ # steerable-sidecar
13
+
14
+ The portable Python entrypoint for the **steerable** framework. Designed to be
15
+ spawned by an Electron / desktop host (or directly from a CLI) and addressed
16
+ via stdio JSON-RPC 2.0.
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ pip install steerable-sidecar
22
+ python -m steerable_sidecar
23
+ ```
24
+
25
+ Or use the console script:
26
+
27
+ ```bash
28
+ steerable-sidecar
29
+ ```
30
+
31
+ The process:
32
+
33
+ 1. Initializes the in-memory storage and a default tool router.
34
+ 2. Prints the **ready signal** on **stderr** as a single line:
35
+ `__SIDECAR_READY__:<json>` matching `spec/sidecar/SidecarHealth`.
36
+ 3. Begins reading JSON-RPC frames from stdin (one frame per line).
37
+ 4. Writes responses and notifications to stdout.
38
+
39
+ See `spec/sidecar/README.md` for the full method/notification catalog.
40
+
41
+ ## Embedding
42
+
43
+ ```python
44
+ import asyncio
45
+ from steerable_sidecar import Sidecar
46
+
47
+ async def main() -> None:
48
+ sidecar = Sidecar()
49
+ sidecar.tools.register(my_tool)
50
+ await sidecar.serve()
51
+
52
+ asyncio.run(main())
53
+ ```
@@ -0,0 +1,42 @@
1
+ # steerable-sidecar
2
+
3
+ The portable Python entrypoint for the **steerable** framework. Designed to be
4
+ spawned by an Electron / desktop host (or directly from a CLI) and addressed
5
+ via stdio JSON-RPC 2.0.
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ pip install steerable-sidecar
11
+ python -m steerable_sidecar
12
+ ```
13
+
14
+ Or use the console script:
15
+
16
+ ```bash
17
+ steerable-sidecar
18
+ ```
19
+
20
+ The process:
21
+
22
+ 1. Initializes the in-memory storage and a default tool router.
23
+ 2. Prints the **ready signal** on **stderr** as a single line:
24
+ `__SIDECAR_READY__:<json>` matching `spec/sidecar/SidecarHealth`.
25
+ 3. Begins reading JSON-RPC frames from stdin (one frame per line).
26
+ 4. Writes responses and notifications to stdout.
27
+
28
+ See `spec/sidecar/README.md` for the full method/notification catalog.
29
+
30
+ ## Embedding
31
+
32
+ ```python
33
+ import asyncio
34
+ from steerable_sidecar import Sidecar
35
+
36
+ async def main() -> None:
37
+ sidecar = Sidecar()
38
+ sidecar.tools.register(my_tool)
39
+ await sidecar.serve()
40
+
41
+ asyncio.run(main())
42
+ ```
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "steerable-sidecar"
3
+ version = "0.1.0"
4
+ description = "Steerable agent runtime sidecar — JSON-RPC over stdio."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ # Pre-1.0 inter-package pins use `>=X,<1.0.0` ranges so release-please can
8
+ # bump individual packages without forcing a coordinated re-pin here.
9
+ # See packages/agent-harness/py/pyproject.toml for the rationale.
10
+ dependencies = [
11
+ "pydantic>=2.10.0",
12
+ "steerable-agent-protocol>=0.1.0,<1.0.0",
13
+ "steerable-agent-harness>=0.1.0,<1.0.0",
14
+ "steerable-agent-runtime>=0.1.0,<1.0.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ steerable-sidecar = "steerable_sidecar.__main__:main"
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=68", "wheel"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [tool.setuptools]
25
+ package-dir = {"" = "src"}
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.uv.sources]
31
+ steerable-agent-protocol = { workspace = true }
32
+ steerable-agent-harness = { workspace = true }
33
+ steerable-agent-runtime = { workspace = true }
34
+
35
+ [tool.pytest.ini_options]
36
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """steerable-sidecar — portable runtime entrypoint."""
2
+
3
+ from .sidecar import Sidecar, SidecarConfig
4
+
5
+ __all__ = ["Sidecar", "SidecarConfig"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,41 @@
1
+ """``python -m steerable_sidecar`` entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import logging
8
+
9
+ from .sidecar import Sidecar, SidecarConfig
10
+
11
+
12
+ def _build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(prog="steerable-sidecar")
14
+ parser.add_argument(
15
+ "--log-level",
16
+ default="INFO",
17
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
18
+ help="Sidecar log level (logged on stderr).",
19
+ )
20
+ parser.add_argument(
21
+ "--quiet-ready",
22
+ action="store_true",
23
+ help="Skip the __SIDECAR_READY__ stderr marker.",
24
+ )
25
+ return parser
26
+
27
+
28
+ def main() -> int:
29
+ parser = _build_parser()
30
+ args = parser.parse_args()
31
+ config = SidecarConfig(log_level=args.log_level, quiet_stderr=args.quiet_ready)
32
+ sidecar = Sidecar(config=config)
33
+ try:
34
+ asyncio.run(sidecar.serve())
35
+ except KeyboardInterrupt:
36
+ logging.getLogger("steerable_sidecar").info("interrupted")
37
+ return 0
38
+
39
+
40
+ if __name__ == "__main__": # pragma: no cover
41
+ raise SystemExit(main())
@@ -0,0 +1,582 @@
1
+ """Sidecar core: wires the runtime adapters into a JSON-RPC server.
2
+
3
+ Methods (see spec/sidecar/README.md for the full catalog):
4
+
5
+ system.ping -> SidecarHealth
6
+ system.shutdown -> null
7
+ system.shutdown_now -> null
8
+ agent.session.create -> AgentSession
9
+ agent.session.resume -> AgentSession
10
+ agent.session.list -> AgentSession[]
11
+ agent.chat.stream -> { streamId } (chunks pushed via `stream.chunk`,
12
+ terminator via `stream.done`)
13
+ agent.chat.cancel -> null (best-effort cancel of an in-flight stream)
14
+ tool.list -> ToolDescriptor[]
15
+ tool.invoke -> ToolResult
16
+ trace.fetch -> { trace, spans, events }
17
+ config.get / config.set
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ import platform
27
+ import sys
28
+ import time
29
+ import uuid
30
+ from dataclasses import dataclass, field
31
+ from typing import Any
32
+
33
+ from steerable_agent_protocol.generated import (
34
+ AgentSession,
35
+ SidecarHealth,
36
+ ToolCall,
37
+ )
38
+ from steerable_agent_runtime import (
39
+ BudgetExhaustedError,
40
+ PolicyDeniedError,
41
+ StorageError,
42
+ ToolDispatchError,
43
+ ToolRouter,
44
+ )
45
+ from steerable_agent_runtime.llm import LLMMessage, LLMProvider
46
+ from steerable_agent_runtime.storage import InMemoryStorage, StorageAdapter
47
+ from steerable_agent_runtime.transport.stdio_jsonrpc import (
48
+ JsonRpcError,
49
+ JsonRpcServer,
50
+ StdioJsonRpcTransport,
51
+ encode_frame,
52
+ )
53
+
54
+ logger = logging.getLogger("steerable_sidecar")
55
+
56
+ PROTOCOL_VERSION = "0.1.0"
57
+ SIDECAR_VERSION = "0.1.0"
58
+ READY_PREFIX = "__SIDECAR_READY__:"
59
+
60
+
61
+ @dataclass
62
+ class SidecarConfig:
63
+ """Sidecar runtime configuration."""
64
+
65
+ log_level: str = "INFO"
66
+ quiet_stderr: bool = False
67
+ grace_period_seconds: float = 5.0
68
+ install_signal_handlers: bool = True
69
+ initial_tools: list[Any] = field(default_factory=list)
70
+
71
+
72
+ class Sidecar:
73
+ """In-process sidecar harness.
74
+
75
+ The main entrypoint composes a `JsonRpcServer`, a default `ToolRouter`, an
76
+ `InMemoryStorage`, and a `StdioJsonRpcTransport`. Embedders can swap any of
77
+ these by setting the corresponding attribute before calling ``serve()``.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ *,
83
+ config: SidecarConfig | None = None,
84
+ storage: StorageAdapter | None = None,
85
+ tools: ToolRouter | None = None,
86
+ llm_provider_factory: Any | None = None,
87
+ ) -> None:
88
+ self.config = config or SidecarConfig()
89
+ self.storage: StorageAdapter = storage or InMemoryStorage()
90
+ self.tools: ToolRouter = tools or ToolRouter()
91
+ self.server = JsonRpcServer()
92
+ self._llm_provider_factory = llm_provider_factory or default_llm_provider_factory
93
+ self._streams: dict[str, asyncio.Task[Any]] = {}
94
+ self._transport: StdioJsonRpcTransport | None = None
95
+ self._started_ms = int(time.monotonic() * 1000)
96
+ self._wall_started_ms = int(time.time() * 1000)
97
+ self._shutdown_requested = asyncio.Event()
98
+ self._serving = False
99
+
100
+ self._register_default_methods()
101
+ for tool in self.config.initial_tools:
102
+ self.tools.register(tool)
103
+
104
+ # ------------------------------------------------------------------
105
+ # Method registration
106
+ # ------------------------------------------------------------------
107
+
108
+ def _register_default_methods(self) -> None:
109
+ register = self.server.register
110
+ register("system.ping", self._handle_ping)
111
+ register("system.shutdown", self._handle_shutdown)
112
+ register("system.shutdown_now", self._handle_shutdown_now)
113
+ register("agent.session.create", self._handle_session_create)
114
+ register("agent.session.resume", self._handle_session_resume)
115
+ register("agent.session.list", self._handle_session_list)
116
+ register("tool.list", self._handle_tool_list)
117
+ register("tool.invoke", self._handle_tool_invoke)
118
+ register("trace.fetch", self._handle_trace_fetch)
119
+ register("config.get", self._handle_config_get)
120
+ register("config.set", self._handle_config_set)
121
+ register("agent.chat.stream", self._handle_chat_stream)
122
+ register("agent.chat.cancel", self._handle_chat_cancel)
123
+
124
+ # ------------------------------------------------------------------
125
+ # Entrypoint
126
+ # ------------------------------------------------------------------
127
+
128
+ async def serve(self) -> None:
129
+ """Run the sidecar until shutdown is requested."""
130
+
131
+ self._configure_logging()
132
+ if self.config.install_signal_handlers:
133
+ self._install_signal_handlers()
134
+
135
+ ready = await self.snapshot_health()
136
+ self._emit_ready_marker(ready)
137
+
138
+ reader, writer = await self._connect_stdio()
139
+ transport = StdioJsonRpcTransport(writer)
140
+ self._transport = transport
141
+ await transport.emit_notification(
142
+ "lifecycle.ready",
143
+ {
144
+ "version": SIDECAR_VERSION,
145
+ "protocolVersion": PROTOCOL_VERSION,
146
+ "pid": os.getpid(),
147
+ "listenInfo": {"transport": "stdio"},
148
+ },
149
+ )
150
+
151
+ self._serving = True
152
+ try:
153
+ while not self._shutdown_requested.is_set():
154
+ line_task = asyncio.ensure_future(reader.readline())
155
+ shutdown_task = asyncio.ensure_future(self._shutdown_requested.wait())
156
+ done, pending = await asyncio.wait(
157
+ {line_task, shutdown_task},
158
+ return_when=asyncio.FIRST_COMPLETED,
159
+ )
160
+ for task in pending:
161
+ task.cancel()
162
+ if shutdown_task in done:
163
+ break
164
+ line = line_task.result()
165
+ if not line:
166
+ break
167
+ response = await self.server.handle_frame(line.decode("utf-8"))
168
+ if response is None:
169
+ continue
170
+ writer.write(encode_frame(response))
171
+ await self._maybe_drain(writer)
172
+ finally:
173
+ await transport.emit_notification(
174
+ "lifecycle.shutdown",
175
+ {"reason": "normal" if self._shutdown_requested.is_set() else "eof"},
176
+ )
177
+ await transport.aclose()
178
+ close = getattr(writer, "close", None)
179
+ if close is not None:
180
+ close()
181
+ self._serving = False
182
+
183
+ async def request_shutdown(self) -> None:
184
+ self._shutdown_requested.set()
185
+
186
+ async def snapshot_health(self) -> SidecarHealth:
187
+ uptime = int(time.monotonic() * 1000) - self._started_ms
188
+ return SidecarHealth(
189
+ status="ok" if self._serving or self._started_ms else "starting",
190
+ version=SIDECAR_VERSION,
191
+ protocolVersion=PROTOCOL_VERSION,
192
+ uptimeMs=max(0, uptime),
193
+ pid=os.getpid(),
194
+ pythonVersion=platform.python_version(),
195
+ platform=f"{sys.platform}-{platform.machine()}",
196
+ loadedTools=len(self.tools.list_tools()),
197
+ activeTraces=0,
198
+ )
199
+
200
+ # ------------------------------------------------------------------
201
+ # Method handlers
202
+ # ------------------------------------------------------------------
203
+
204
+ async def _handle_ping(self, _params: dict[str, Any] | None) -> dict[str, Any]:
205
+ health = await self.snapshot_health()
206
+ return health.model_dump(exclude_none=True)
207
+
208
+ async def _handle_shutdown(self, _params: dict[str, Any] | None) -> None:
209
+ # Schedule the actual stop so the response can be drained first.
210
+ loop = asyncio.get_running_loop()
211
+ loop.call_later(0.1, lambda: self._shutdown_requested.set())
212
+ return None
213
+
214
+ async def _handle_shutdown_now(self, _params: dict[str, Any] | None) -> None:
215
+ self._shutdown_requested.set()
216
+ return None
217
+
218
+ async def _handle_session_create(self, params: dict[str, Any] | None) -> dict[str, Any]:
219
+ params = _require_params(params)
220
+ session = AgentSession(
221
+ sessionId=params.get("sessionId") or _new_session_id(),
222
+ userId=params.get("userId") or "local",
223
+ chatId=params["chatId"],
224
+ currentStage=params.get("currentStage", "plan"),
225
+ isActive=True,
226
+ createdAt=_iso_now(),
227
+ updatedAt=_iso_now(),
228
+ scenario=params.get("scenario", "agent-entry"),
229
+ stageData=params.get("stageData"),
230
+ projectId=params.get("projectId"),
231
+ )
232
+ try:
233
+ stored = await self.storage.upsert_session(session)
234
+ except StorageError as exc:
235
+ raise JsonRpcError(str(exc), code=-32011, kind="internal") from exc
236
+ return stored.model_dump(exclude_none=True)
237
+
238
+ async def _handle_session_resume(self, params: dict[str, Any] | None) -> dict[str, Any]:
239
+ params = _require_params(params)
240
+ session_id = params.get("sessionId")
241
+ if not session_id:
242
+ raise JsonRpcError("sessionId required", code=-32602, kind="invalid_params")
243
+ session = await self.storage.get_session(session_id)
244
+ if session is None:
245
+ raise JsonRpcError(
246
+ f"session not found: {session_id}", code=-32004, kind="invalid_request"
247
+ )
248
+ return session.model_dump(exclude_none=True)
249
+
250
+ async def _handle_session_list(self, params: dict[str, Any] | None) -> list[dict[str, Any]]:
251
+ params = params or {}
252
+ sessions = await self.storage.list_sessions(
253
+ user_id=params.get("userId"),
254
+ chat_id=params.get("chatId"),
255
+ active_only=bool(params.get("activeOnly", False)),
256
+ )
257
+ return [s.model_dump(exclude_none=True) for s in sessions]
258
+
259
+ async def _handle_tool_list(self, _params: dict[str, Any] | None) -> list[dict[str, Any]]:
260
+ return self.tools.describe()
261
+
262
+ async def _handle_tool_invoke(self, params: dict[str, Any] | None) -> dict[str, Any]:
263
+ params = _require_params(params)
264
+ try:
265
+ call = ToolCall(
266
+ id=params.get("id") or _new_call_id(),
267
+ name=params["name"],
268
+ arguments=params.get("arguments") or {},
269
+ )
270
+ except KeyError as exc:
271
+ raise JsonRpcError(
272
+ f"missing argument: {exc.args[0]}", code=-32602, kind="invalid_params"
273
+ ) from exc
274
+ try:
275
+ result = await self.tools.dispatch(
276
+ call,
277
+ consent_granted=bool(params.get("consentGranted", False)),
278
+ context=params.get("context"),
279
+ )
280
+ except PolicyDeniedError as exc:
281
+ raise JsonRpcError(
282
+ exc.message, code=-32020, kind="policy_denied", data=exc.data
283
+ ) from exc
284
+ except BudgetExhaustedError as exc:
285
+ raise JsonRpcError(
286
+ exc.message, code=-32021, kind="budget_exhausted", data=exc.data
287
+ ) from exc
288
+ except ToolDispatchError as exc:
289
+ raise JsonRpcError(
290
+ exc.message, code=-32030, kind="tool_failed", data=exc.data
291
+ ) from exc
292
+ return result.model_dump(exclude_none=True)
293
+
294
+ async def _handle_trace_fetch(self, params: dict[str, Any] | None) -> dict[str, Any]:
295
+ params = _require_params(params)
296
+ trace_id = params.get("traceId")
297
+ if not trace_id:
298
+ raise JsonRpcError("traceId required", code=-32602, kind="invalid_params")
299
+ trace = await self.storage.get_trace(trace_id)
300
+ if trace is None:
301
+ raise JsonRpcError(
302
+ f"trace not found: {trace_id}", code=-32004, kind="invalid_request"
303
+ )
304
+ spans = await self.storage.list_spans(trace_id)
305
+ events = await self.storage.list_events(trace_id)
306
+ return {
307
+ "trace": trace.model_dump(exclude_none=True),
308
+ "spans": [s.model_dump(exclude_none=True) for s in spans],
309
+ "events": [e.model_dump(exclude_none=True) for e in events],
310
+ }
311
+
312
+ async def _handle_config_get(self, _params: dict[str, Any] | None) -> dict[str, Any]:
313
+ return {
314
+ "logLevel": self.config.log_level,
315
+ "gracePeriodSeconds": self.config.grace_period_seconds,
316
+ "version": SIDECAR_VERSION,
317
+ "protocolVersion": PROTOCOL_VERSION,
318
+ }
319
+
320
+ async def _handle_config_set(self, params: dict[str, Any] | None) -> None:
321
+ params = _require_params(params)
322
+ log_level = params.get("logLevel")
323
+ if log_level is not None:
324
+ self.config.log_level = str(log_level)
325
+ logging.getLogger().setLevel(self.config.log_level)
326
+ return None
327
+
328
+ async def _handle_chat_stream(self, params: dict[str, Any] | None) -> dict[str, Any]:
329
+ """Start a streaming chat-completion run.
330
+
331
+ Params shape (all optional unless noted)::
332
+
333
+ {
334
+ "provider": "openai_compat" | "anthropic" | <custom>, # required
335
+ "model": "gpt-4o-mini", # required
336
+ "messages": [{"role": "...", "content": "..."}], # required
337
+ "baseUrl": "https://api.example.com/v1",
338
+ "apiKey": "sk-...",
339
+ "temperature": 0.2,
340
+ "maxTokens": 1024,
341
+ "tools": [...], # OpenAI tool descriptors
342
+ "streamId": "str_xyz", # auto-generated if omitted
343
+ "providerOptions": {...}, # passthrough
344
+ }
345
+
346
+ Returns ``{"streamId": "..."}`` immediately. Chunks arrive as
347
+ ``stream.chunk`` notifications with ``{"streamId", "delta"}``;
348
+ completion is signalled by ``stream.done`` with ``{"streamId",
349
+ "finishReason", "usage"}``. Errors mid-stream are emitted as
350
+ ``stream.error``.
351
+ """
352
+
353
+ params = _require_params(params)
354
+ if self._transport is None:
355
+ raise JsonRpcError(
356
+ "transport not ready", code=-32099, kind="internal"
357
+ )
358
+ try:
359
+ provider = self._llm_provider_factory(params)
360
+ except Exception as exc: # surface as RPC error before scheduling task
361
+ raise JsonRpcError(
362
+ f"failed to construct LLM provider: {exc}",
363
+ code=-32602,
364
+ kind="invalid_params",
365
+ ) from exc
366
+
367
+ stream_id = params.get("streamId") or _new_stream_id()
368
+ messages = _coerce_messages(params.get("messages") or [])
369
+ kwargs = _build_provider_kwargs(params)
370
+
371
+ transport = self._transport
372
+ task = asyncio.create_task(
373
+ self._run_chat_stream(provider, messages, kwargs, stream_id, transport)
374
+ )
375
+ self._streams[stream_id] = task
376
+ return {"streamId": stream_id}
377
+
378
+ async def _handle_chat_cancel(self, params: dict[str, Any] | None) -> None:
379
+ params = _require_params(params)
380
+ stream_id = params.get("streamId")
381
+ if not stream_id:
382
+ raise JsonRpcError("streamId required", code=-32602, kind="invalid_params")
383
+ task = self._streams.pop(stream_id, None)
384
+ if task is not None and not task.done():
385
+ task.cancel()
386
+ return None
387
+
388
+ async def _run_chat_stream(
389
+ self,
390
+ provider: LLMProvider,
391
+ messages: list[LLMMessage],
392
+ kwargs: dict[str, Any],
393
+ stream_id: str,
394
+ transport: StdioJsonRpcTransport,
395
+ ) -> None:
396
+ try:
397
+ iterator = provider.stream(messages, **kwargs)
398
+ async for chunk in iterator:
399
+ payload: dict[str, Any] = {"streamId": stream_id}
400
+ if chunk.content_delta is not None:
401
+ payload["delta"] = chunk.content_delta
402
+ if chunk.reasoning_delta is not None:
403
+ payload["reasoningDelta"] = chunk.reasoning_delta
404
+ if chunk.tool_call_delta is not None:
405
+ payload["toolCall"] = chunk.tool_call_delta.model_dump(
406
+ exclude_none=True
407
+ )
408
+ if chunk.finish_reason is not None:
409
+ payload["finishReason"] = chunk.finish_reason
410
+ if chunk.usage is not None:
411
+ payload["usage"] = {
412
+ "promptTokens": chunk.usage.prompt_tokens,
413
+ "completionTokens": chunk.usage.completion_tokens,
414
+ "totalTokens": chunk.usage.total_tokens,
415
+ }
416
+ await transport.emit_notification("stream.chunk", payload)
417
+ await transport.emit_notification(
418
+ "stream.done", {"streamId": stream_id, "ok": True}
419
+ )
420
+ except asyncio.CancelledError:
421
+ await transport.emit_notification(
422
+ "stream.done", {"streamId": stream_id, "ok": False, "cancelled": True}
423
+ )
424
+ except Exception as exc:
425
+ logger.exception("chat stream %s failed", stream_id)
426
+ await transport.emit_notification(
427
+ "stream.error",
428
+ {
429
+ "streamId": stream_id,
430
+ "kind": exc.__class__.__name__,
431
+ "message": str(exc),
432
+ },
433
+ )
434
+ finally:
435
+ self._streams.pop(stream_id, None)
436
+
437
+ # ------------------------------------------------------------------
438
+ # Plumbing
439
+ # ------------------------------------------------------------------
440
+
441
+ def _emit_ready_marker(self, health: SidecarHealth) -> None:
442
+ if self.config.quiet_stderr:
443
+ return
444
+ payload = json.dumps(health.model_dump(exclude_none=True), separators=(",", ":"))
445
+ sys.stderr.write(f"{READY_PREFIX}{payload}\n")
446
+ sys.stderr.flush()
447
+
448
+ def _configure_logging(self) -> None:
449
+ logging.basicConfig(
450
+ level=self.config.log_level,
451
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
452
+ stream=sys.stderr,
453
+ )
454
+
455
+ def _install_signal_handlers(self) -> None:
456
+ loop = asyncio.get_running_loop()
457
+ try:
458
+ import signal
459
+
460
+ for sig in (signal.SIGINT, signal.SIGTERM):
461
+ loop.add_signal_handler(sig, lambda: self._shutdown_requested.set())
462
+ except (NotImplementedError, RuntimeError):
463
+ # Windows event-loop policies that lack add_signal_handler.
464
+ pass
465
+
466
+ @staticmethod
467
+ async def _connect_stdio() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
468
+ loop = asyncio.get_running_loop()
469
+ reader = asyncio.StreamReader()
470
+ protocol = asyncio.StreamReaderProtocol(reader)
471
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
472
+ transport, _ = await loop.connect_write_pipe(asyncio.streams.FlowControlMixin, sys.stdout)
473
+ writer = asyncio.StreamWriter(transport, protocol, reader, loop)
474
+ return reader, writer
475
+
476
+ @staticmethod
477
+ async def _maybe_drain(writer: Any) -> None:
478
+ drain = getattr(writer, "drain", None)
479
+ if drain is None:
480
+ return
481
+ result = drain()
482
+ if asyncio.iscoroutine(result):
483
+ await result
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # Helpers
488
+ # ---------------------------------------------------------------------------
489
+
490
+
491
+ def _require_params(params: Any) -> dict[str, Any]:
492
+ if not isinstance(params, dict):
493
+ raise JsonRpcError("params must be an object", code=-32602, kind="invalid_params")
494
+ return params
495
+
496
+
497
+ def _iso_now() -> str:
498
+ from datetime import datetime, timezone
499
+
500
+ return datetime.now(timezone.utc).isoformat()
501
+
502
+
503
+ def _new_session_id() -> str:
504
+ return f"sess_{uuid.uuid4().hex}"
505
+
506
+
507
+ def _new_call_id() -> str:
508
+ return f"call_{uuid.uuid4().hex}"
509
+
510
+
511
+ def _new_stream_id() -> str:
512
+ return f"str_{uuid.uuid4().hex}"
513
+
514
+
515
+ def _coerce_messages(items: Any) -> list[LLMMessage]:
516
+ if not isinstance(items, list):
517
+ raise JsonRpcError("messages must be a list", code=-32602, kind="invalid_params")
518
+ out: list[LLMMessage] = []
519
+ for entry in items:
520
+ if not isinstance(entry, dict):
521
+ raise JsonRpcError(
522
+ "each message must be an object", code=-32602, kind="invalid_params"
523
+ )
524
+ role = entry.get("role")
525
+ if role not in {"system", "user", "assistant", "tool"}:
526
+ raise JsonRpcError(
527
+ f"invalid role: {role!r}", code=-32602, kind="invalid_params"
528
+ )
529
+ out.append(
530
+ LLMMessage(
531
+ role=role, # type: ignore[arg-type]
532
+ content=str(entry.get("content", "")),
533
+ name=entry.get("name"),
534
+ tool_call_id=entry.get("toolCallId"),
535
+ )
536
+ )
537
+ return out
538
+
539
+
540
+ def _build_provider_kwargs(params: dict[str, Any]) -> dict[str, Any]:
541
+ kwargs: dict[str, Any] = {}
542
+ if (tools := params.get("tools")) is not None:
543
+ kwargs["tools"] = tools
544
+ if (temp := params.get("temperature")) is not None:
545
+ kwargs["temperature"] = float(temp)
546
+ if (max_tokens := params.get("maxTokens")) is not None:
547
+ kwargs["max_tokens"] = int(max_tokens)
548
+ extra = params.get("providerOptions") or {}
549
+ if isinstance(extra, dict):
550
+ kwargs.update(extra)
551
+ return kwargs
552
+
553
+
554
+ def default_llm_provider_factory(params: dict[str, Any]) -> LLMProvider:
555
+ """Construct an LLMProvider from a chat-stream request payload.
556
+
557
+ Embedders can override this by passing ``llm_provider_factory=`` to
558
+ ``Sidecar(...)`` — useful for tests or for sites that want to enforce a
559
+ single configured provider.
560
+ """
561
+
562
+ provider_kind = (params.get("provider") or "").strip().lower()
563
+ model = params.get("model")
564
+ if not model:
565
+ raise ValueError("model is required")
566
+ base_url = params.get("baseUrl") or params.get("base_url")
567
+ api_key = params.get("apiKey") or params.get("api_key") or ""
568
+
569
+ if provider_kind in {"openai", "openai_compat", "openai-compatible", "ollama"}:
570
+ from steerable_agent_runtime.llm import OpenAICompatProvider
571
+
572
+ return OpenAICompatProvider(
573
+ base_url=base_url or "https://api.openai.com/v1",
574
+ api_key=api_key,
575
+ model=str(model),
576
+ )
577
+ if provider_kind in {"anthropic", "claude"}:
578
+ from steerable_agent_runtime.llm import AnthropicProvider
579
+
580
+ return AnthropicProvider(api_key=api_key, model=str(model))
581
+
582
+ raise ValueError(f"unknown provider: {provider_kind!r}")
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: steerable-sidecar
3
+ Version: 0.1.0
4
+ Summary: Steerable agent runtime sidecar — JSON-RPC over stdio.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pydantic>=2.10.0
8
+ Requires-Dist: steerable-agent-protocol<1.0.0,>=0.1.0
9
+ Requires-Dist: steerable-agent-harness<1.0.0,>=0.1.0
10
+ Requires-Dist: steerable-agent-runtime<1.0.0,>=0.1.0
11
+
12
+ # steerable-sidecar
13
+
14
+ The portable Python entrypoint for the **steerable** framework. Designed to be
15
+ spawned by an Electron / desktop host (or directly from a CLI) and addressed
16
+ via stdio JSON-RPC 2.0.
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ pip install steerable-sidecar
22
+ python -m steerable_sidecar
23
+ ```
24
+
25
+ Or use the console script:
26
+
27
+ ```bash
28
+ steerable-sidecar
29
+ ```
30
+
31
+ The process:
32
+
33
+ 1. Initializes the in-memory storage and a default tool router.
34
+ 2. Prints the **ready signal** on **stderr** as a single line:
35
+ `__SIDECAR_READY__:<json>` matching `spec/sidecar/SidecarHealth`.
36
+ 3. Begins reading JSON-RPC frames from stdin (one frame per line).
37
+ 4. Writes responses and notifications to stdout.
38
+
39
+ See `spec/sidecar/README.md` for the full method/notification catalog.
40
+
41
+ ## Embedding
42
+
43
+ ```python
44
+ import asyncio
45
+ from steerable_sidecar import Sidecar
46
+
47
+ async def main() -> None:
48
+ sidecar = Sidecar()
49
+ sidecar.tools.register(my_tool)
50
+ await sidecar.serve()
51
+
52
+ asyncio.run(main())
53
+ ```
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/steerable_sidecar/__init__.py
4
+ src/steerable_sidecar/__main__.py
5
+ src/steerable_sidecar/sidecar.py
6
+ src/steerable_sidecar.egg-info/PKG-INFO
7
+ src/steerable_sidecar.egg-info/SOURCES.txt
8
+ src/steerable_sidecar.egg-info/dependency_links.txt
9
+ src/steerable_sidecar.egg-info/entry_points.txt
10
+ src/steerable_sidecar.egg-info/requires.txt
11
+ src/steerable_sidecar.egg-info/top_level.txt
12
+ tests/test_sidecar_methods.py
13
+ tests/test_sidecar_subprocess.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ steerable-sidecar = steerable_sidecar.__main__:main
@@ -0,0 +1,4 @@
1
+ pydantic>=2.10.0
2
+ steerable-agent-protocol<1.0.0,>=0.1.0
3
+ steerable-agent-harness<1.0.0,>=0.1.0
4
+ steerable-agent-runtime<1.0.0,>=0.1.0
@@ -0,0 +1 @@
1
+ steerable_sidecar
@@ -0,0 +1,327 @@
1
+ """Direct unit tests for the JSON-RPC method handlers (no real stdio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from steerable_sidecar import Sidecar
10
+
11
+
12
+ @pytest.fixture
13
+ def sidecar() -> Sidecar:
14
+ return Sidecar()
15
+
16
+
17
+ async def _call(sidecar: Sidecar, method: str, params: dict | None = None, request_id: int = 1):
18
+ raw = json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
19
+ return await sidecar.server.handle_frame(raw)
20
+
21
+
22
+ async def test_system_ping_returns_health(sidecar: Sidecar) -> None:
23
+ response = await _call(sidecar, "system.ping")
24
+ assert response["result"]["version"] == "0.1.0"
25
+ assert response["result"]["protocolVersion"] == "0.1.0"
26
+ assert response["result"]["loadedTools"] == 0
27
+
28
+
29
+ async def test_session_create_then_resume(sidecar: Sidecar) -> None:
30
+ create = await _call(
31
+ sidecar,
32
+ "agent.session.create",
33
+ {"chatId": "chat-1", "userId": "user-1"},
34
+ )
35
+ session_id = create["result"]["sessionId"]
36
+ resume = await _call(sidecar, "agent.session.resume", {"sessionId": session_id})
37
+ assert resume["result"]["sessionId"] == session_id
38
+ assert resume["result"]["chatId"] == "chat-1"
39
+
40
+
41
+ async def test_session_list_filters_by_user(sidecar: Sidecar) -> None:
42
+ await _call(sidecar, "agent.session.create", {"chatId": "c1", "userId": "u1"})
43
+ await _call(sidecar, "agent.session.create", {"chatId": "c2", "userId": "u2"})
44
+ listed = await _call(sidecar, "agent.session.list", {"userId": "u1"})
45
+ assert len(listed["result"]) == 1
46
+ assert listed["result"][0]["userId"] == "u1"
47
+
48
+
49
+ async def test_tool_invoke_runs_registered_handler(sidecar: Sidecar) -> None:
50
+ async def echo(value: str = "default") -> dict:
51
+ return {"echoed": value}
52
+
53
+ sidecar.tools.register(echo)
54
+ response = await _call(
55
+ sidecar,
56
+ "tool.invoke",
57
+ {"name": "echo", "arguments": {"value": "hi"}},
58
+ )
59
+ assert response["result"]["success"] is True
60
+ assert response["result"]["data"]["value"] == {"echoed": "hi"}
61
+
62
+
63
+ async def test_tool_invoke_missing_tool_returns_failure() -> None:
64
+ sidecar = Sidecar()
65
+ response = await _call(
66
+ sidecar,
67
+ "tool.invoke",
68
+ {"name": "missing"},
69
+ )
70
+ # Unknown tool is reported as ToolResult success=False, not as JSON-RPC error.
71
+ assert response["result"]["success"] is False
72
+
73
+
74
+ async def test_tool_invoke_destructive_requires_consent() -> None:
75
+ sidecar = Sidecar()
76
+
77
+ async def delete_thing() -> None:
78
+ return None
79
+
80
+ sidecar.tools.register(delete_thing)
81
+ denied = await _call(sidecar, "tool.invoke", {"name": "delete_thing"})
82
+ assert denied["error"]["kind"] == "policy_denied"
83
+ granted = await _call(
84
+ sidecar,
85
+ "tool.invoke",
86
+ {"name": "delete_thing", "consentGranted": True},
87
+ )
88
+ assert granted["result"]["success"] is True
89
+
90
+
91
+ async def test_unknown_method_returns_not_found(sidecar: Sidecar) -> None:
92
+ response = await _call(sidecar, "agent.nope")
93
+ assert response["error"]["kind"] == "method_not_found"
94
+
95
+
96
+ async def test_invalid_params_object_returns_invalid_params(sidecar: Sidecar) -> None:
97
+ response = await _call(sidecar, "agent.session.create", None)
98
+ assert response["error"]["kind"] == "invalid_params"
99
+
100
+
101
+ async def test_resume_unknown_session_returns_invalid_request(sidecar: Sidecar) -> None:
102
+ response = await _call(sidecar, "agent.session.resume", {"sessionId": "nope"})
103
+ assert response["error"]["kind"] == "invalid_request"
104
+
105
+
106
+ async def test_config_get_set_round_trip(sidecar: Sidecar) -> None:
107
+ initial = await _call(sidecar, "config.get")
108
+ assert initial["result"]["logLevel"] == "INFO"
109
+ await _call(sidecar, "config.set", {"logLevel": "DEBUG"})
110
+ after = await _call(sidecar, "config.get")
111
+ assert after["result"]["logLevel"] == "DEBUG"
112
+
113
+
114
+ async def test_health_snapshot_includes_pid_and_python(sidecar: Sidecar) -> None:
115
+ health = await sidecar.snapshot_health()
116
+ assert health.pid is not None
117
+ assert health.pythonVersion is not None
118
+ assert health.platform is not None
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # agent.chat.stream
123
+ # ---------------------------------------------------------------------------
124
+
125
+
126
+ class _FakeProvider:
127
+ """Pure-Python LLMProvider used by the chat-stream tests.
128
+
129
+ Implements just enough of the Protocol surface (``stream``) and feeds a
130
+ deterministic sequence of chunks so we can assert the sidecar transport
131
+ behavior without touching a real network."""
132
+
133
+ name = "fake"
134
+ model = "fake-model"
135
+
136
+ def __init__(self, *, chunks=None, raise_on=None):
137
+ from steerable_agent_runtime.llm import LLMStreamChunk, LLMUsage
138
+
139
+ self._chunks = chunks or [
140
+ LLMStreamChunk(content_delta="Hello "),
141
+ LLMStreamChunk(content_delta="world"),
142
+ LLMStreamChunk(
143
+ finish_reason="stop",
144
+ usage=LLMUsage(prompt_tokens=10, completion_tokens=2, total_tokens=12),
145
+ ),
146
+ ]
147
+ self._raise_on = raise_on
148
+
149
+ async def complete(self, *args, **kwargs):
150
+ raise NotImplementedError
151
+
152
+ def stream(self, messages, **kwargs):
153
+ async def _gen():
154
+ for i, chunk in enumerate(self._chunks):
155
+ if self._raise_on is not None and i == self._raise_on:
156
+ raise RuntimeError("upstream blew up")
157
+ yield chunk
158
+
159
+ return _gen()
160
+
161
+
162
+ class _CapturingTransport:
163
+ """Drop-in for StdioJsonRpcTransport that just buffers notifications."""
164
+
165
+ def __init__(self):
166
+ self.events: list[tuple[str, dict]] = []
167
+
168
+ async def emit_notification(self, method: str, params: dict | None = None) -> None:
169
+ self.events.append((method, params or {}))
170
+
171
+ async def aclose(self) -> None:
172
+ return None
173
+
174
+
175
+ @pytest.fixture
176
+ def sidecar_with_fake_llm():
177
+ sidecar = Sidecar(llm_provider_factory=lambda params: _FakeProvider())
178
+ transport = _CapturingTransport()
179
+ sidecar._transport = transport # type: ignore[attr-defined]
180
+ return sidecar, transport
181
+
182
+
183
+ async def test_chat_stream_emits_chunks_then_done(sidecar_with_fake_llm) -> None:
184
+ sidecar, transport = sidecar_with_fake_llm
185
+ response = await _call(
186
+ sidecar,
187
+ "agent.chat.stream",
188
+ {
189
+ "provider": "openai_compat",
190
+ "model": "fake-model",
191
+ "messages": [{"role": "user", "content": "hi"}],
192
+ },
193
+ )
194
+ stream_id = response["result"]["streamId"]
195
+ assert stream_id.startswith("str_")
196
+
197
+ # Wait for the stream task to finish so all notifications are flushed.
198
+ task = sidecar._streams.get(stream_id) or next(iter(sidecar._streams.values()), None)
199
+ if task is not None:
200
+ await task
201
+
202
+ methods = [name for name, _ in transport.events]
203
+ assert methods.count("stream.chunk") == 3
204
+ assert methods.count("stream.done") == 1
205
+ assert methods[-1] == "stream.done"
206
+ assert transport.events[-1][1] == {"streamId": stream_id, "ok": True}
207
+ # finish_reason / usage propagate on the last chunk.
208
+ last_chunk = transport.events[-2][1]
209
+ assert last_chunk["finishReason"] == "stop"
210
+ assert last_chunk["usage"]["totalTokens"] == 12
211
+
212
+
213
+ async def test_chat_stream_invalid_provider_returns_invalid_params() -> None:
214
+ def factory(_params):
215
+ raise ValueError("nope")
216
+
217
+ sidecar = Sidecar(llm_provider_factory=factory)
218
+ sidecar._transport = _CapturingTransport() # type: ignore[attr-defined]
219
+ response = await _call(
220
+ sidecar,
221
+ "agent.chat.stream",
222
+ {"provider": "bogus", "model": "x", "messages": []},
223
+ )
224
+ assert response["error"]["kind"] == "invalid_params"
225
+
226
+
227
+ async def test_chat_stream_invalid_message_role_rejected() -> None:
228
+ sidecar = Sidecar(llm_provider_factory=lambda params: _FakeProvider())
229
+ sidecar._transport = _CapturingTransport() # type: ignore[attr-defined]
230
+ response = await _call(
231
+ sidecar,
232
+ "agent.chat.stream",
233
+ {
234
+ "provider": "openai_compat",
235
+ "model": "x",
236
+ "messages": [{"role": "wizard", "content": "spell"}],
237
+ },
238
+ )
239
+ assert response["error"]["kind"] == "invalid_params"
240
+
241
+
242
+ async def test_chat_stream_emits_error_on_provider_failure() -> None:
243
+ sidecar = Sidecar(
244
+ llm_provider_factory=lambda params: _FakeProvider(raise_on=1),
245
+ )
246
+ transport = _CapturingTransport()
247
+ sidecar._transport = transport # type: ignore[attr-defined]
248
+ response = await _call(
249
+ sidecar,
250
+ "agent.chat.stream",
251
+ {
252
+ "provider": "openai_compat",
253
+ "model": "fake-model",
254
+ "messages": [{"role": "user", "content": "hi"}],
255
+ },
256
+ )
257
+ stream_id = response["result"]["streamId"]
258
+ task = sidecar._streams.get(stream_id) or next(iter(sidecar._streams.values()), None)
259
+ if task is not None:
260
+ await task
261
+
262
+ methods = [name for name, _ in transport.events]
263
+ assert "stream.error" in methods
264
+ err_payload = next(p for n, p in transport.events if n == "stream.error")
265
+ assert err_payload["streamId"] == stream_id
266
+ assert err_payload["kind"] == "RuntimeError"
267
+
268
+
269
+ async def test_chat_cancel_terminates_in_flight_stream() -> None:
270
+ import asyncio as _asyncio
271
+
272
+ from steerable_agent_runtime.llm import LLMStreamChunk
273
+
274
+ class _SlowProvider:
275
+ name = "slow"
276
+ model = "slow-model"
277
+
278
+ async def complete(self, *a, **k):
279
+ raise NotImplementedError
280
+
281
+ def stream(self, messages, **kwargs):
282
+ async def _gen():
283
+ yield LLMStreamChunk(content_delta="first")
284
+ # Block long enough that cancel can land.
285
+ await _asyncio.sleep(5.0)
286
+ yield LLMStreamChunk(content_delta="never")
287
+
288
+ return _gen()
289
+
290
+ sidecar = Sidecar(llm_provider_factory=lambda params: _SlowProvider())
291
+ transport = _CapturingTransport()
292
+ sidecar._transport = transport # type: ignore[attr-defined]
293
+
294
+ response = await _call(
295
+ sidecar,
296
+ "agent.chat.stream",
297
+ {
298
+ "provider": "openai_compat",
299
+ "model": "slow-model",
300
+ "messages": [{"role": "user", "content": "hi"}],
301
+ },
302
+ )
303
+ stream_id = response["result"]["streamId"]
304
+ # Give the task one tick to start streaming.
305
+ await _asyncio.sleep(0)
306
+
307
+ cancel_response = await _call(sidecar, "agent.chat.cancel", {"streamId": stream_id})
308
+ # Successful void calls drop the `result` key via model_dump(exclude_none=True).
309
+ assert "error" not in cancel_response
310
+
311
+ # Drain any leftover task (it should have been cancelled).
312
+ leftover = sidecar._streams.get(stream_id)
313
+ if leftover is not None:
314
+ with pytest.raises(_asyncio.CancelledError):
315
+ await leftover
316
+
317
+ cancel_done = next(
318
+ (p for n, p in transport.events if n == "stream.done" and p.get("cancelled")),
319
+ None,
320
+ )
321
+ # Either a cancelled-stream.done was emitted, or the task was cancelled
322
+ # before the finally block ran. Both are acceptable; we just ensure no
323
+ # `never` chunk leaked.
324
+ chunk_deltas = [p.get("delta") for n, p in transport.events if n == "stream.chunk"]
325
+ assert "never" not in chunk_deltas
326
+ if cancel_done is not None:
327
+ assert cancel_done["streamId"] == stream_id
@@ -0,0 +1,97 @@
1
+ """End-to-end smoke test: spawn the sidecar subprocess and round-trip a frame."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ import pytest
11
+
12
+
13
+ pytestmark = pytest.mark.skipif(
14
+ os.environ.get("CI_SKIP_SIDECAR_SUBPROCESS") == "1",
15
+ reason="explicitly disabled via CI_SKIP_SIDECAR_SUBPROCESS",
16
+ )
17
+
18
+
19
+ async def _spawn_sidecar() -> asyncio.subprocess.Process:
20
+ return await asyncio.create_subprocess_exec(
21
+ sys.executable,
22
+ "-m",
23
+ "steerable_sidecar",
24
+ "--log-level",
25
+ "ERROR",
26
+ stdin=asyncio.subprocess.PIPE,
27
+ stdout=asyncio.subprocess.PIPE,
28
+ stderr=asyncio.subprocess.PIPE,
29
+ )
30
+
31
+
32
+ async def _read_ready_marker(proc: asyncio.subprocess.Process) -> dict:
33
+ assert proc.stderr is not None
34
+ while True:
35
+ line = await asyncio.wait_for(proc.stderr.readline(), timeout=10.0)
36
+ if not line:
37
+ raise RuntimeError("sidecar exited before ready marker")
38
+ decoded = line.decode("utf-8").rstrip()
39
+ if decoded.startswith("__SIDECAR_READY__:"):
40
+ return json.loads(decoded.removeprefix("__SIDECAR_READY__:"))
41
+
42
+
43
+ async def _read_lifecycle_ready(proc: asyncio.subprocess.Process) -> dict:
44
+ assert proc.stdout is not None
45
+ while True:
46
+ line = await asyncio.wait_for(proc.stdout.readline(), timeout=10.0)
47
+ if not line:
48
+ raise RuntimeError("sidecar exited before lifecycle.ready notification")
49
+ payload = json.loads(line)
50
+ if payload.get("method") == "lifecycle.ready":
51
+ return payload
52
+
53
+
54
+ async def _round_trip(proc: asyncio.subprocess.Process, request: dict) -> dict:
55
+ assert proc.stdin is not None and proc.stdout is not None
56
+ proc.stdin.write((json.dumps(request) + "\n").encode("utf-8"))
57
+ await proc.stdin.drain()
58
+ while True:
59
+ line = await asyncio.wait_for(proc.stdout.readline(), timeout=10.0)
60
+ if not line:
61
+ raise RuntimeError("sidecar closed without responding")
62
+ payload = json.loads(line)
63
+ if payload.get("id") == request.get("id"):
64
+ return payload
65
+
66
+
67
+ async def test_subprocess_full_handshake_and_ping() -> None:
68
+ proc = await _spawn_sidecar()
69
+ try:
70
+ ready = await _read_ready_marker(proc)
71
+ assert ready["status"] in {"ok", "starting"}
72
+ assert ready["protocolVersion"] == "0.1.0"
73
+
74
+ lifecycle = await _read_lifecycle_ready(proc)
75
+ assert lifecycle["params"]["protocolVersion"] == "0.1.0"
76
+ assert lifecycle["params"]["pid"] > 0
77
+
78
+ response = await _round_trip(
79
+ proc,
80
+ {"jsonrpc": "2.0", "id": 1, "method": "system.ping"},
81
+ )
82
+ assert response["result"]["protocolVersion"] == "0.1.0"
83
+
84
+ # graceful shutdown
85
+ await _round_trip(
86
+ proc, {"jsonrpc": "2.0", "id": 2, "method": "system.shutdown"}
87
+ )
88
+ finally:
89
+ try:
90
+ proc.stdin.close()
91
+ except Exception:
92
+ pass
93
+ try:
94
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
95
+ except asyncio.TimeoutError:
96
+ proc.kill()
97
+ await proc.wait()