steerable-agent-runtime 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,319 @@
1
+ """JSON-RPC 2.0 transport over stdio.
2
+
3
+ Powers the ``steerable-sidecar`` (a portable Python process spawned by an
4
+ Electron / desktop host). Each frame is a single-line UTF-8 JSON document
5
+ terminated by ``\n``, matching ``spec/sidecar/``.
6
+
7
+ The module ships two layers:
8
+
9
+ * ``StdioJsonRpcTransport`` — implements ``TransportAdapter.emit()`` so the
10
+ runtime can stream `SSEEvent` instances over JSON-RPC notifications.
11
+ * ``JsonRpcServer`` — minimal request/response/notification dispatcher used by
12
+ the sidecar entrypoint. Handlers are registered with
13
+ ``server.register("method.name", handler)``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import sys
22
+ from collections.abc import Awaitable, Callable
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ from steerable_agent_protocol.generated import (
27
+ SidecarError,
28
+ SidecarNotification,
29
+ SidecarRequest,
30
+ SidecarResponse,
31
+ SSEEvent,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ JsonRpcMethodHandler = Callable[[dict[str, Any] | None], Awaitable[Any]]
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Wire helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def encode_frame(payload: dict[str, Any]) -> bytes:
45
+ """Encode one JSON-RPC frame to a single-line UTF-8 bytes buffer."""
46
+
47
+ return (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
48
+
49
+
50
+ def decode_frame(line: str) -> dict[str, Any] | None:
51
+ """Decode a single JSON-RPC frame line. Returns None for blank lines."""
52
+
53
+ line = line.strip()
54
+ if not line:
55
+ return None
56
+ return json.loads(line)
57
+
58
+
59
+ def build_request(
60
+ *,
61
+ request_id: str | int,
62
+ method: str,
63
+ params: dict[str, Any] | None = None,
64
+ ) -> SidecarRequest:
65
+ return SidecarRequest(jsonrpc="2.0", id=request_id, method=method, params=params)
66
+
67
+
68
+ def build_notification(
69
+ *,
70
+ method: str,
71
+ params: dict[str, Any] | None = None,
72
+ ) -> SidecarNotification:
73
+ return SidecarNotification(jsonrpc="2.0", method=method, params=params)
74
+
75
+
76
+ def build_response(
77
+ *,
78
+ request_id: str | int | None,
79
+ result: Any | None = None,
80
+ error: SidecarError | None = None,
81
+ ) -> SidecarResponse:
82
+ return SidecarResponse(jsonrpc="2.0", id=request_id, result=result, error=error)
83
+
84
+
85
+ def make_error(
86
+ *,
87
+ code: int,
88
+ message: str,
89
+ kind: str | None = None,
90
+ data: Any | None = None,
91
+ ) -> SidecarError:
92
+ return SidecarError(code=code, message=message, kind=kind, data=data)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Transport
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ @dataclass(slots=True)
101
+ class _PendingRequest:
102
+ future: asyncio.Future
103
+
104
+
105
+ class StdioJsonRpcTransport:
106
+ """Runtime-side adapter that emits `SSEEvent` as JSON-RPC notifications.
107
+
108
+ Concrete writer is injected so this class is testable without touching real
109
+ stdio: pass any object supporting ``write(bytes) -> Awaitable[None]`` (or a
110
+ sync ``write(bytes) -> int``).
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ writer: Any,
116
+ *,
117
+ notification_method: str = "stream.chunk",
118
+ ) -> None:
119
+ self._writer = writer
120
+ self._notification_method = notification_method
121
+ self._closed = False
122
+ self._lock = asyncio.Lock()
123
+
124
+ async def emit(self, event: SSEEvent) -> None:
125
+ if self._closed:
126
+ raise RuntimeError("Transport is closed")
127
+ notification = build_notification(
128
+ method=self._notification_method,
129
+ params=event.model_dump(exclude_none=True),
130
+ )
131
+ await self._send(notification.model_dump(exclude_none=True))
132
+
133
+ async def emit_notification(self, method: str, params: dict[str, Any] | None = None) -> None:
134
+ if self._closed:
135
+ raise RuntimeError("Transport is closed")
136
+ notification = build_notification(method=method, params=params)
137
+ await self._send(notification.model_dump(exclude_none=True))
138
+
139
+ async def aclose(self) -> None:
140
+ self._closed = True
141
+
142
+ async def _send(self, payload: dict[str, Any]) -> None:
143
+ frame = encode_frame(payload)
144
+ async with self._lock:
145
+ result = self._writer.write(frame)
146
+ if asyncio.iscoroutine(result):
147
+ await result
148
+ drain = getattr(self._writer, "drain", None)
149
+ if drain is not None:
150
+ drained = drain()
151
+ if asyncio.iscoroutine(drained):
152
+ await drained
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Server
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ class JsonRpcServer:
161
+ """Minimal stdio JSON-RPC dispatcher.
162
+
163
+ Handlers are async callables ``handler(params: dict | None) -> Any``.
164
+
165
+ Returning ``None`` from a handler still produces a JSON-RPC ``result: null``
166
+ response (so the client knows the request succeeded). Raise to surface an
167
+ error; ``JsonRpcError`` instances pass their ``code/kind/data`` through.
168
+ """
169
+
170
+ def __init__(self) -> None:
171
+ self._handlers: dict[str, JsonRpcMethodHandler] = {}
172
+ self._notification_handlers: dict[str, JsonRpcMethodHandler] = {}
173
+
174
+ # ------------------------------------------------------------------
175
+ # Registration
176
+ # ------------------------------------------------------------------
177
+
178
+ def register(self, method: str, handler: JsonRpcMethodHandler) -> None:
179
+ if method in self._handlers:
180
+ raise ValueError(f"Method already registered: {method}")
181
+ self._handlers[method] = handler
182
+
183
+ def register_notification(self, method: str, handler: JsonRpcMethodHandler) -> None:
184
+ if method in self._notification_handlers:
185
+ raise ValueError(f"Notification handler already registered: {method}")
186
+ self._notification_handlers[method] = handler
187
+
188
+ # ------------------------------------------------------------------
189
+ # Dispatch
190
+ # ------------------------------------------------------------------
191
+
192
+ async def handle_frame(self, raw: str) -> dict[str, Any] | None:
193
+ try:
194
+ payload = decode_frame(raw)
195
+ except json.JSONDecodeError as exc:
196
+ return build_response(
197
+ request_id=None,
198
+ error=make_error(code=-32700, kind="parse", message=str(exc)),
199
+ ).model_dump(exclude_none=True)
200
+ if payload is None:
201
+ return None
202
+ if "id" not in payload: # notification
203
+ await self._dispatch_notification(payload)
204
+ return None
205
+ return await self._dispatch_request(payload)
206
+
207
+ async def _dispatch_notification(self, payload: dict[str, Any]) -> None:
208
+ method = payload.get("method")
209
+ handler = self._notification_handlers.get(method or "")
210
+ if handler is None:
211
+ logger.debug("Unhandled notification: %s", method)
212
+ return
213
+ try:
214
+ await handler(payload.get("params"))
215
+ except Exception: # noqa: BLE001
216
+ logger.exception("Notification handler %s raised", method)
217
+
218
+ async def _dispatch_request(self, payload: dict[str, Any]) -> dict[str, Any]:
219
+ request_id = payload.get("id")
220
+ method = payload.get("method")
221
+ if not isinstance(method, str) or not method:
222
+ return build_response(
223
+ request_id=request_id,
224
+ error=make_error(code=-32600, kind="invalid_request", message="missing method"),
225
+ ).model_dump(exclude_none=True)
226
+ handler = self._handlers.get(method)
227
+ if handler is None:
228
+ return build_response(
229
+ request_id=request_id,
230
+ error=make_error(
231
+ code=-32601, kind="method_not_found", message=f"unknown method '{method}'"
232
+ ),
233
+ ).model_dump(exclude_none=True)
234
+ params = payload.get("params")
235
+ try:
236
+ result = await handler(params)
237
+ except JsonRpcError as exc:
238
+ return build_response(
239
+ request_id=request_id,
240
+ error=make_error(code=exc.code, kind=exc.kind, message=exc.message, data=exc.data),
241
+ ).model_dump(exclude_none=True)
242
+ except Exception as exc: # noqa: BLE001
243
+ logger.exception("Method %s raised", method)
244
+ return build_response(
245
+ request_id=request_id,
246
+ error=make_error(code=-32603, kind="internal", message=str(exc)),
247
+ ).model_dump(exclude_none=True)
248
+ return build_response(request_id=request_id, result=result).model_dump(exclude_none=True)
249
+
250
+ # ------------------------------------------------------------------
251
+ # Pump
252
+ # ------------------------------------------------------------------
253
+
254
+ async def serve_stdio(
255
+ self,
256
+ *,
257
+ reader: Any | None = None,
258
+ writer: Any | None = None,
259
+ ) -> None:
260
+ """Drive the server using asyncio stdio streams.
261
+
262
+ When ``reader`` / ``writer`` are not provided, fall back to wrapping
263
+ ``sys.stdin`` and ``sys.stdout`` so the sidecar entrypoint can simply
264
+ call ``await server.serve_stdio()``.
265
+ """
266
+
267
+ if reader is None or writer is None:
268
+ reader, writer = await _connect_default_stdio()
269
+ try:
270
+ while True:
271
+ line = await reader.readline()
272
+ if not line:
273
+ return
274
+ response = await self.handle_frame(line.decode("utf-8"))
275
+ if response is None:
276
+ continue
277
+ writer.write(encode_frame(response))
278
+ drain = getattr(writer, "drain", None)
279
+ if drain is not None:
280
+ drained = drain()
281
+ if asyncio.iscoroutine(drained):
282
+ await drained
283
+ finally:
284
+ close = getattr(writer, "close", None)
285
+ if close is not None:
286
+ close()
287
+
288
+
289
+ class JsonRpcError(Exception):
290
+ """Raise from a handler to return a structured JSON-RPC error."""
291
+
292
+ def __init__(
293
+ self,
294
+ message: str,
295
+ *,
296
+ code: int = -32000,
297
+ kind: str | None = None,
298
+ data: Any | None = None,
299
+ ) -> None:
300
+ super().__init__(message)
301
+ self.code = code
302
+ self.kind = kind
303
+ self.data = data
304
+ self.message = message
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Default stdio adapter
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ async def _connect_default_stdio() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
313
+ loop = asyncio.get_running_loop()
314
+ reader = asyncio.StreamReader()
315
+ protocol = asyncio.StreamReaderProtocol(reader)
316
+ await loop.connect_read_pipe(lambda: protocol, sys.stdin)
317
+ transport, _ = await loop.connect_write_pipe(asyncio.streams.FlowControlMixin, sys.stdout)
318
+ writer = asyncio.StreamWriter(transport, protocol, reader, loop)
319
+ return reader, writer
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: steerable-agent-runtime
3
+ Version: 0.1.0
4
+ Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
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
+ Provides-Extra: sqlalchemy
11
+ Requires-Dist: sqlalchemy>=2.0; extra == "sqlalchemy"
12
+ Provides-Extra: fastapi
13
+ Requires-Dist: fastapi>=0.110; extra == "fastapi"
14
+ Requires-Dist: starlette>=0.37; extra == "fastapi"
15
+ Provides-Extra: openai
16
+ Requires-Dist: httpx>=0.27; extra == "openai"
17
+ Provides-Extra: anthropic
18
+ Requires-Dist: anthropic>=0.40; extra == "anthropic"
19
+ Provides-Extra: all
20
+ Requires-Dist: sqlalchemy>=2.0; extra == "all"
21
+ Requires-Dist: fastapi>=0.110; extra == "all"
22
+ Requires-Dist: starlette>=0.37; extra == "all"
23
+ Requires-Dist: httpx>=0.27; extra == "all"
24
+ Requires-Dist: anthropic>=0.40; extra == "all"
25
+
26
+ # steerable-agent-runtime
27
+
28
+ Tier 3 runtime for the Steerable framework.
29
+
30
+ Provides four orthogonal pluggable adapters:
31
+
32
+ - `LLMProvider` — chat-completion / streaming / tool-call abstraction, with
33
+ reference implementations for OpenAI-compatible servers (covers OpenAI,
34
+ Ollama, vLLM, SiliconFlow, etc.) and Anthropic native.
35
+ - `ToolRouter` — in-process tool registry. Auto-classifies tools into
36
+ `ToolMode`s using `steerable_agent_harness.policy`, supports per-tool
37
+ permission overrides, and dispatches `ToolCall` → `ToolResult`.
38
+ - `StorageAdapter` — persistence interface for `AgentSession`, `ChatMessage`,
39
+ `ChatAgent`, and `HarnessTrace + spans + events`. Reference impls: in-memory
40
+ (default for sidecar/dev) and SQLAlchemy (for hosted backends).
41
+ - `TransportAdapter` — wire format. `FastAPISseTransport` exports SSE for
42
+ hosted setups; `StdioJsonRpcTransport` powers the steerable-sidecar.
43
+
44
+ The runtime is **Python only** by design — frontends never depend on it
45
+ directly. Browsers/Electron consume runtime output via either the SSE transport
46
+ (over HTTP) or the stdio JSON-RPC transport (sidecar pattern).
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install steerable-agent-runtime[all]
52
+ ```
53
+
54
+ Selectively install just the bits you need:
55
+
56
+ ```bash
57
+ pip install "steerable-agent-runtime[openai]"
58
+ pip install "steerable-agent-runtime[anthropic]"
59
+ pip install "steerable-agent-runtime[sqlalchemy]"
60
+ pip install "steerable-agent-runtime[fastapi]"
61
+ ```
@@ -0,0 +1,16 @@
1
+ steerable_agent_runtime/__init__.py,sha256=4PQ1QigZnrPwP__civ1euCGBG3gBEBBJjAhP2o6N5fI,791
2
+ steerable_agent_runtime/errors.py,sha256=jNdYyCOCKnNDUAPNBZ-TLuSfRciedTgMrHq_Ep_lE3g,913
3
+ steerable_agent_runtime/tools.py,sha256=3fRyh-8JbS7VCAzLsIqtVAYEMDN5MeKWPy7EmxLzBn8,8469
4
+ steerable_agent_runtime/llm/__init__.py,sha256=J-PfiNPT-97LALtyPVPFAXA6aKJ19Nx_DIugnX8Vdqg,2940
5
+ steerable_agent_runtime/llm/anthropic_native.py,sha256=GOfZlLVEgvNl8wY5cH7qt9nwsn8RRrTbjVlSzGW_lk0,9680
6
+ steerable_agent_runtime/llm/openai_compat.py,sha256=Q9tc_tkOzQc19Z0G_yjUjT71KPY38Zqp_J1lGh1NrN8,8517
7
+ steerable_agent_runtime/storage/__init__.py,sha256=VfA-iqrQGRXhsZdLX-6HL40lTKs9v1uwu0IBBeOOgpU,2516
8
+ steerable_agent_runtime/storage/in_memory.py,sha256=hSxpnWKdm6gDbuPdryqrCUFJtfmnGqKlcKYz-OiWhgc,5772
9
+ steerable_agent_runtime/storage/sqlalchemy_store.py,sha256=AJBRp_R8KXAZUBYh77RaisONfY_Oac8zTYpoq4QiJNc,12843
10
+ steerable_agent_runtime/transport/__init__.py,sha256=jy2kvEiZ7VQQeZAV3AUoBhYAZrtulyqmp7eL1q1hy_Q,996
11
+ steerable_agent_runtime/transport/fastapi_sse.py,sha256=u6yuKdSFxYeRajyArvHTW_wrtFbQAdcFo8Ib3WWyKMg,3555
12
+ steerable_agent_runtime/transport/stdio_jsonrpc.py,sha256=NuMXX2GlcM2Lc98J-4z5rwqKcmVuTJ9tECEK_0Tjwbs,11132
13
+ steerable_agent_runtime-0.1.0.dist-info/METADATA,sha256=KdyQbS8ES5pG_kMNVJvF_DOp2ZZXCSw44fL18YrJnGk,2398
14
+ steerable_agent_runtime-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ steerable_agent_runtime-0.1.0.dist-info/top_level.txt,sha256=oH6AX0Sbye_DmFcu-JFEwuu-yXwyCkkdquPt8t0pFJE,24
16
+ steerable_agent_runtime-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 @@
1
+ steerable_agent_runtime