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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any