steerable-sidecar 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,8 @@
1
+ steerable_sidecar/__init__.py,sha256=GTo78MEAlIPo06S2_EkpgSH4flpg4GcgcKfBGtoRsnA,164
2
+ steerable_sidecar/__main__.py,sha256=1CodxnOZNJozcfW-PkMU8m8gr6zA5eysIyLiyO3n64o,1075
3
+ steerable_sidecar/sidecar.py,sha256=oJC5ywsWvNMxiB6wBcNLCNqSRLS1RCoPAwlfCC-oh64,22490
4
+ steerable_sidecar-0.1.0.dist-info/METADATA,sha256=NY0qmnOXGKreZcEwprnhkIlmlTDjxGZfMgeA5-scStM,1346
5
+ steerable_sidecar-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ steerable_sidecar-0.1.0.dist-info/entry_points.txt,sha256=BzdRleQ626HeL2akkZs197FkrwLA4vJoBj3GMceM-94,70
7
+ steerable_sidecar-0.1.0.dist-info/top_level.txt,sha256=0QLlBP9tgRT5PLQXFYLc_FTgTH87Qn6JGsgJj4ZvhDs,18
8
+ steerable_sidecar-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ steerable-sidecar = steerable_sidecar.__main__:main
@@ -0,0 +1 @@
1
+ steerable_sidecar