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.
- steerable_sidecar-0.1.0/PKG-INFO +53 -0
- steerable_sidecar-0.1.0/README.md +42 -0
- steerable_sidecar-0.1.0/pyproject.toml +36 -0
- steerable_sidecar-0.1.0/setup.cfg +4 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar/__init__.py +6 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar/__main__.py +41 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar/sidecar.py +582 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar.egg-info/PKG-INFO +53 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar.egg-info/SOURCES.txt +13 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar.egg-info/dependency_links.txt +1 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar.egg-info/entry_points.txt +2 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar.egg-info/requires.txt +4 -0
- steerable_sidecar-0.1.0/src/steerable_sidecar.egg-info/top_level.txt +1 -0
- steerable_sidecar-0.1.0/tests/test_sidecar_methods.py +327 -0
- steerable_sidecar-0.1.0/tests/test_sidecar_subprocess.py +97 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|