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.
- steerable_sidecar/__init__.py +6 -0
- steerable_sidecar/__main__.py +41 -0
- steerable_sidecar/sidecar.py +582 -0
- steerable_sidecar-0.1.0.dist-info/METADATA +53 -0
- steerable_sidecar-0.1.0.dist-info/RECORD +8 -0
- steerable_sidecar-0.1.0.dist-info/WHEEL +5 -0
- steerable_sidecar-0.1.0.dist-info/entry_points.txt +2 -0
- steerable_sidecar-0.1.0.dist-info/top_level.txt +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 @@
|
|
|
1
|
+
steerable_sidecar
|