lodestar-runtime-client 0.3.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,27 @@
|
|
|
1
|
+
"""lodestar-runtime-client — the shared Python RPC client for Lodestar runtime hooks.
|
|
2
|
+
|
|
3
|
+
`GateClient` spawns the TypeScript governance-gate sidecar (`lodestar runtime
|
|
4
|
+
gate`) and speaks newline-delimited JSON-RPC to it, remoting each native tool call
|
|
5
|
+
through the Lodestar Action Kernel (two-phase execution, policy gate,
|
|
6
|
+
cognitive-core ingestion, sentinel arbitration, signed-approval hold path) over a
|
|
7
|
+
thin bidirectional channel — ADR-0024.
|
|
8
|
+
|
|
9
|
+
It is pure stdlib and framework-agnostic: the per-framework hooks
|
|
10
|
+
(`lodestar-langgraph`, `lodestar-crewai`, `lodestar-autogen`) depend on it and add
|
|
11
|
+
only the thin framework binding in their own `adapter` module. Extracted from the
|
|
12
|
+
three hooks in #128 (ADR-0028) — it was previously a verbatim copy in each.
|
|
13
|
+
|
|
14
|
+
Quick start::
|
|
15
|
+
|
|
16
|
+
from lodestar_runtime_client import GateClient
|
|
17
|
+
|
|
18
|
+
with GateClient("runtime-gate.config.json") as gate:
|
|
19
|
+
gate.register_tool("my_tool", lambda args: {"output": ...})
|
|
20
|
+
result = gate.govern("my_tool", {"x": 1})
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .client import GateClient, GateError, ToolBody
|
|
24
|
+
|
|
25
|
+
__all__ = ["GateClient", "GateError", "ToolBody"]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""The runtime-hook side of the Lodestar runtime-adapter RPC (ADR-0024).
|
|
2
|
+
|
|
3
|
+
`GateClient` spawns the TypeScript governance-gate sidecar (`lodestar runtime
|
|
4
|
+
gate`) as a child process and speaks newline-delimited JSON-RPC to it over
|
|
5
|
+
stdin/stdout — the same framing MCP uses, zero extra dependencies. The channel is
|
|
6
|
+
**bidirectional**: the gate calls *back* with ``run_tool`` to run a real tool
|
|
7
|
+
body (the re-entrant remoted execute), so the tool body runs only inside the
|
|
8
|
+
gate's execute phase, never before approval.
|
|
9
|
+
|
|
10
|
+
A single reader thread dispatches every inbound line by id, so concurrent
|
|
11
|
+
``govern`` calls (a framework may issue parallel tool calls) are correlated
|
|
12
|
+
correctly — no positional or ordering assumption. This module is pure stdlib and
|
|
13
|
+
framework-agnostic — the shared spine of the Lodestar runtime-adapter hooks
|
|
14
|
+
(``lodestar-langgraph``, ``lodestar-crewai``, ``lodestar-autogen``); each
|
|
15
|
+
framework's integration lives in that hook's ``adapter`` module.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import queue
|
|
22
|
+
import subprocess
|
|
23
|
+
import threading
|
|
24
|
+
from typing import Any, Callable, Optional
|
|
25
|
+
|
|
26
|
+
# A tool body the gate remotes back: takes the validated args, returns
|
|
27
|
+
# ``{"output": <any>, "documents": [{"text": str, "source"?: str}, ...]}``.
|
|
28
|
+
ToolBody = Callable[[dict], dict]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GateError(RuntimeError):
|
|
32
|
+
"""A protocol-level error returned by the gate (e.g. a bad message)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GateClient:
|
|
36
|
+
"""Spawn and drive the Lodestar governance-gate sidecar.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
config_path:
|
|
41
|
+
Path to the ``RuntimeGateConfig`` JSON the gate loads.
|
|
42
|
+
launcher:
|
|
43
|
+
The argv prefix that runs the CLI. Defaults to ``["lodestar"]`` (the
|
|
44
|
+
published binary). Tests / monorepo callers pass e.g.
|
|
45
|
+
``["bun", "run", "<repo>/packages/cli/src/index.ts"]``.
|
|
46
|
+
ready_timeout_s:
|
|
47
|
+
How long to wait for the gate's ``ready`` handshake.
|
|
48
|
+
request_timeout_s:
|
|
49
|
+
Optional hard cap (seconds) on waiting for any single request's reply.
|
|
50
|
+
Defaults to ``None`` (wait until the gate replies or dies) — the gate
|
|
51
|
+
bounds tool execution itself, so a fixed cap is not needed for liveness and
|
|
52
|
+
a too-short one would drop a valid reply for a slow tool. If set, keep it
|
|
53
|
+
≥ the gate's ``tool_exec_timeout_ms`` (plus any ``resume`` wait).
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
config_path: str,
|
|
59
|
+
*,
|
|
60
|
+
launcher: Optional[list[str]] = None,
|
|
61
|
+
ready_timeout_s: float = 30.0,
|
|
62
|
+
request_timeout_s: Optional[float] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
argv = list(launcher or ["lodestar"]) + ["runtime", "gate", "--config", config_path]
|
|
65
|
+
self._proc = subprocess.Popen(
|
|
66
|
+
argv,
|
|
67
|
+
stdin=subprocess.PIPE,
|
|
68
|
+
stdout=subprocess.PIPE,
|
|
69
|
+
stderr=None, # inherit: the gate's [runtime-gate] diagnostics show through
|
|
70
|
+
text=True,
|
|
71
|
+
bufsize=1,
|
|
72
|
+
)
|
|
73
|
+
# Per-request reply cap. None (the default) waits indefinitely: the gate
|
|
74
|
+
# bounds tool execution itself (RuntimeGateConfig.tool_exec_timeout_ms) and
|
|
75
|
+
# always replies, and a gate that *dies* releases every waiter when its
|
|
76
|
+
# stdout closes (see _read_loop). A fixed client-side cap is therefore not
|
|
77
|
+
# needed for liveness — and a too-short one would wrongly drop a valid
|
|
78
|
+
# govern_result for a legitimately slow tool (or one whose exec timeout the
|
|
79
|
+
# operator raised above the cap). Set a number only as an extra hard cap,
|
|
80
|
+
# and keep it ≥ the gate's tool_exec_timeout_ms (+ any resume wait).
|
|
81
|
+
self._request_timeout_s = request_timeout_s
|
|
82
|
+
self._write_lock = threading.Lock()
|
|
83
|
+
self._state_lock = threading.Lock()
|
|
84
|
+
self._next = 0
|
|
85
|
+
self._futures: dict[int, "queue.Queue[dict]"] = {}
|
|
86
|
+
self._bodies: dict[str, ToolBody] = {}
|
|
87
|
+
self._ready = threading.Event()
|
|
88
|
+
# Set (with `_ready`) when the gate's stdout closes before the handshake —
|
|
89
|
+
# e.g. an invalid config/policy makes the CLI exit. Lets construction fail
|
|
90
|
+
# fast with a useful message instead of blocking the full ready timeout.
|
|
91
|
+
self._startup_error: Optional[str] = None
|
|
92
|
+
self._closed = False
|
|
93
|
+
self.session_id: Optional[str] = None
|
|
94
|
+
self.project_id: Optional[str] = None
|
|
95
|
+
self._reader = threading.Thread(target=self._read_loop, name="lodestar-gate-reader", daemon=True)
|
|
96
|
+
self._reader.start()
|
|
97
|
+
if not self._ready.wait(timeout=ready_timeout_s):
|
|
98
|
+
self.close()
|
|
99
|
+
raise GateError("gate did not signal ready in time")
|
|
100
|
+
if self._startup_error is not None:
|
|
101
|
+
self.close()
|
|
102
|
+
raise GateError(self._startup_error)
|
|
103
|
+
|
|
104
|
+
# ── public API ──────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def register_tool(self, name: str, body: ToolBody) -> dict:
|
|
107
|
+
"""Register a governed tool. The gate compiles the operator's contract
|
|
108
|
+
for ``name``; this only declares the tool exists and binds its body."""
|
|
109
|
+
with self._state_lock:
|
|
110
|
+
self._bodies[name] = body
|
|
111
|
+
return self._request({"type": "register_tool", "name": name})
|
|
112
|
+
|
|
113
|
+
def govern(self, tool: str, args: dict) -> dict:
|
|
114
|
+
"""Propose a tool call. Returns the ``govern_result``: ``completed``
|
|
115
|
+
(with ``output``), ``pending_approval`` (with ``action_id`` /
|
|
116
|
+
``request_id`` / ``deadline``), ``rejected``, or ``failed``."""
|
|
117
|
+
return self._request({"type": "govern", "tool": tool, "args": args})
|
|
118
|
+
|
|
119
|
+
def resume(self, action_id: str, request_id: str, *, wait_ms: Optional[int] = None) -> dict:
|
|
120
|
+
"""Re-present a held action. With ``wait_ms`` the gate block-polls up to
|
|
121
|
+
that long (bounded by the deadline) for a signed resolution; without it,
|
|
122
|
+
a single check. Idempotent — a duplicate resume returns the recorded
|
|
123
|
+
outcome and never re-executes."""
|
|
124
|
+
msg: dict[str, Any] = {"type": "resume", "action_id": action_id, "request_id": request_id}
|
|
125
|
+
if wait_ms is not None:
|
|
126
|
+
msg["wait_ms"] = wait_ms
|
|
127
|
+
return self._request(msg)
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
if self._closed:
|
|
131
|
+
return
|
|
132
|
+
self._closed = True
|
|
133
|
+
try:
|
|
134
|
+
self._send({"type": "shutdown"})
|
|
135
|
+
self._proc.wait(timeout=5)
|
|
136
|
+
except Exception:
|
|
137
|
+
self._proc.kill()
|
|
138
|
+
|
|
139
|
+
def __enter__(self) -> "GateClient":
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def __exit__(self, *_exc: object) -> None:
|
|
143
|
+
self.close()
|
|
144
|
+
|
|
145
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def _send(self, obj: dict) -> None:
|
|
148
|
+
assert self._proc.stdin is not None
|
|
149
|
+
# `allow_nan=False` rejects NaN/Infinity: Python's default emits the
|
|
150
|
+
# literals `NaN`/`Infinity`, which are NOT valid JSON, so the TS gate's
|
|
151
|
+
# `JSON.parse` drops the line — and with unbounded request waits the caller
|
|
152
|
+
# would hang. Fail loudly here instead (the callers turn this into a
|
|
153
|
+
# GateError for a request, or a tool_error for a tool body).
|
|
154
|
+
try:
|
|
155
|
+
line = json.dumps(obj, allow_nan=False)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise GateError(f"cannot serialise RPC message (non-finite number?): {exc}") from exc
|
|
158
|
+
with self._write_lock:
|
|
159
|
+
self._proc.stdin.write(line + "\n")
|
|
160
|
+
self._proc.stdin.flush()
|
|
161
|
+
|
|
162
|
+
def _request(self, obj: dict) -> dict:
|
|
163
|
+
with self._state_lock:
|
|
164
|
+
self._next += 1
|
|
165
|
+
rid = self._next
|
|
166
|
+
box: "queue.Queue[dict]" = queue.Queue(maxsize=1)
|
|
167
|
+
self._futures[rid] = box
|
|
168
|
+
try:
|
|
169
|
+
self._send({**obj, "id": rid})
|
|
170
|
+
except Exception:
|
|
171
|
+
# Serialisation/transport failed — don't leave an orphaned waiter.
|
|
172
|
+
with self._state_lock:
|
|
173
|
+
self._futures.pop(rid, None)
|
|
174
|
+
raise
|
|
175
|
+
try:
|
|
176
|
+
# timeout=None waits until the gate replies or (on gate death) the
|
|
177
|
+
# reader injects a closed-connection error into the box.
|
|
178
|
+
reply = box.get(timeout=self._request_timeout_s)
|
|
179
|
+
except queue.Empty as exc:
|
|
180
|
+
with self._state_lock:
|
|
181
|
+
self._futures.pop(rid, None)
|
|
182
|
+
raise GateError(f"gate did not reply to {obj.get('type')} in time") from exc
|
|
183
|
+
if reply.get("type") == "error":
|
|
184
|
+
raise GateError(str(reply.get("message")))
|
|
185
|
+
return reply
|
|
186
|
+
|
|
187
|
+
def _read_loop(self) -> None:
|
|
188
|
+
assert self._proc.stdout is not None
|
|
189
|
+
for line in self._proc.stdout:
|
|
190
|
+
line = line.strip()
|
|
191
|
+
if not line:
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
msg = json.loads(line)
|
|
195
|
+
except json.JSONDecodeError:
|
|
196
|
+
continue
|
|
197
|
+
kind = msg.get("type")
|
|
198
|
+
if kind == "ready":
|
|
199
|
+
self.session_id = msg.get("session_id")
|
|
200
|
+
self.project_id = msg.get("project_id")
|
|
201
|
+
self._ready.set()
|
|
202
|
+
elif kind == "run_tool":
|
|
203
|
+
# Run the real tool body off the reader thread so a slow body
|
|
204
|
+
# does not stall correlation of other in-flight replies.
|
|
205
|
+
threading.Thread(target=self._handle_run_tool, args=(msg,), daemon=True).start()
|
|
206
|
+
else:
|
|
207
|
+
rid = msg.get("id")
|
|
208
|
+
if isinstance(rid, int):
|
|
209
|
+
with self._state_lock:
|
|
210
|
+
box = self._futures.pop(rid, None)
|
|
211
|
+
if box is not None:
|
|
212
|
+
box.put(msg)
|
|
213
|
+
# stdout closed. If it closed BEFORE the ready handshake, the gate exited
|
|
214
|
+
# at startup (bad config/policy, etc.) — record a fail-fast startup error
|
|
215
|
+
# and unblock construction's ready wait so it raises immediately with a
|
|
216
|
+
# useful message instead of blocking the full ready timeout.
|
|
217
|
+
if not self._ready.is_set():
|
|
218
|
+
code = self._proc.poll()
|
|
219
|
+
if code is None:
|
|
220
|
+
try:
|
|
221
|
+
code = self._proc.wait(timeout=2)
|
|
222
|
+
except Exception:
|
|
223
|
+
code = None
|
|
224
|
+
self._startup_error = (
|
|
225
|
+
f"gate exited before signalling ready (exit code {code}); "
|
|
226
|
+
"check the config/policy — see the gate's stderr above"
|
|
227
|
+
)
|
|
228
|
+
self._ready.set()
|
|
229
|
+
# Fail any waiters so callers don't hang.
|
|
230
|
+
with self._state_lock:
|
|
231
|
+
waiters = list(self._futures.values())
|
|
232
|
+
self._futures.clear()
|
|
233
|
+
for box in waiters:
|
|
234
|
+
try:
|
|
235
|
+
box.put({"type": "error", "message": "gate closed the connection"})
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
def _handle_run_tool(self, msg: dict) -> None:
|
|
240
|
+
name = msg.get("tool")
|
|
241
|
+
corr = msg.get("id")
|
|
242
|
+
with self._state_lock:
|
|
243
|
+
body = self._bodies.get(name)
|
|
244
|
+
try:
|
|
245
|
+
if body is None:
|
|
246
|
+
raise GateError(f"no body registered for tool '{name}'")
|
|
247
|
+
out = body(msg.get("args") or {})
|
|
248
|
+
self._send(
|
|
249
|
+
{
|
|
250
|
+
"type": "tool_result",
|
|
251
|
+
"id": corr,
|
|
252
|
+
"output": out.get("output"),
|
|
253
|
+
"documents": out.get("documents", []),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
except Exception as exc: # noqa: BLE001 — surface any body failure to the gate
|
|
257
|
+
self._send({"type": "tool_error", "id": corr, "message": str(exc)})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lodestar-runtime-client
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: The shared pure-stdlib Python RPC client for Lodestar runtime hooks — spawns the `lodestar runtime gate` sidecar and remotes native tool calls through the Action Kernel over NDJSON-RPC (ADR-0024 / ADR-0028).
|
|
5
|
+
Project-URL: Homepage, https://qmilab.com/lodestar
|
|
6
|
+
Project-URL: Repository, https://github.com/qmilab/lodestar
|
|
7
|
+
Project-URL: Issues, https://github.com/qmilab/lodestar/issues
|
|
8
|
+
Author-email: QMI Lab <hello@qmilab.com>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: agents,ai-agents,governance,lodestar,rpc,trust
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# lodestar-runtime-client
|
|
18
|
+
|
|
19
|
+
The shared **pure-stdlib Python RPC client** for the
|
|
20
|
+
[Lodestar](https://qmilab.com/lodestar) runtime hooks — the open
|
|
21
|
+
epistemic-governance framework for AI agents.
|
|
22
|
+
|
|
23
|
+
`GateClient` spawns the TypeScript **governance-gate sidecar**
|
|
24
|
+
(`lodestar runtime gate`) as a child process and speaks newline-delimited
|
|
25
|
+
JSON-RPC to it over stdin/stdout — the same framing MCP uses, with **zero runtime
|
|
26
|
+
dependencies**. Each native tool call is remoted through the Lodestar Action
|
|
27
|
+
Kernel: two-phase `propose → arbitrate → execute`, the signed policy gate,
|
|
28
|
+
cognitive-core ingestion, sentinel arbitration, and the signed-approval L4 hold
|
|
29
|
+
path (ADR-0024). The channel is bidirectional — the gate calls *back* to run a
|
|
30
|
+
tool body, so the body runs **only** inside the gate's execute phase, never before
|
|
31
|
+
approval.
|
|
32
|
+
|
|
33
|
+
This is the framework-agnostic **spine** the per-framework hooks build on. You
|
|
34
|
+
usually don't depend on it directly — install the hook for your framework instead:
|
|
35
|
+
|
|
36
|
+
- [`lodestar-langgraph`](https://pypi.org/project/lodestar-langgraph/) — LangGraph (ADR-0024)
|
|
37
|
+
- [`lodestar-crewai`](https://pypi.org/project/lodestar-crewai/) — CrewAI (ADR-0026)
|
|
38
|
+
- [`lodestar-autogen`](https://pypi.org/project/lodestar-autogen/) — AutoGen (ADR-0027)
|
|
39
|
+
|
|
40
|
+
It was extracted from those three hooks in #128 (ADR-0028); before that it was a
|
|
41
|
+
verbatim copy vendored inside each.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install lodestar-runtime-client
|
|
47
|
+
# and the Lodestar CLI (Bun/npm), which provides `lodestar runtime gate`:
|
|
48
|
+
npm install -g @qmilab/lodestar-cli # or: bun add -g @qmilab/lodestar-cli
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Use (low-level)
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from lodestar_runtime_client import GateClient
|
|
55
|
+
|
|
56
|
+
with GateClient("runtime-gate.config.json") as gate:
|
|
57
|
+
gate.register_tool("search_web", lambda args: {"output": do_search(args["q"])})
|
|
58
|
+
result = gate.govern("search_web", {"q": "lodestar"})
|
|
59
|
+
# result is the govern_result: completed | pending_approval | rejected | failed
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For a real framework, reach for the matching hook above — it registers and wraps
|
|
63
|
+
the framework's toolset for you (`govern_tools`) and surfaces holds idiomatically.
|
|
64
|
+
|
|
65
|
+
## Scope (honest, ADR-0004 lineage)
|
|
66
|
+
|
|
67
|
+
This is **governance over declared actions, not OS containment of the process.** A
|
|
68
|
+
call for an unregistered tool is **denied** (fail closed). Pair with
|
|
69
|
+
network/filesystem controls for defense in depth.
|
|
70
|
+
|
|
71
|
+
Apache-2.0. Part of the Lodestar monorepo (`runtimes/runtime-client/`).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
lodestar_runtime_client/__init__.py,sha256=ODsX1Xc-G-aFoJrHmiRUOxXdFxkttn8bZHXQYLPe0to,1127
|
|
2
|
+
lodestar_runtime_client/client.py,sha256=OQ9giYnAAYhJx-IzB92viDyjJxT9lj2A2gNSGQmYzOQ,11602
|
|
3
|
+
lodestar_runtime_client-0.3.0.dist-info/METADATA,sha256=23AJMhXuGlRNgU_JC_VzdeUyy_4OZDTgUwfK86Tgk4U,3233
|
|
4
|
+
lodestar_runtime_client-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
lodestar_runtime_client-0.3.0.dist-info/RECORD,,
|