@whalent/agent 0.3.249 → 0.3.251

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,297 @@
1
+ #!/usr/bin/env python3
2
+ """Whalent-owned Jupyter kernel sidecar.
3
+
4
+ Stdin/stdout are NDJSON. Stdout is protocol-only; diagnostics go to stderr.
5
+ The process is launched from the selected Python environment so imports and the
6
+ kernel executable are guaranteed to belong to that environment.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import queue
14
+ import signal
15
+ import sys
16
+ import threading
17
+ import time
18
+ import traceback
19
+ from typing import Any
20
+
21
+ from jupyter_client import KernelManager
22
+
23
+
24
+ WRITE_LOCK = threading.Lock()
25
+ KERNEL_LOCK = threading.RLock()
26
+ KM: KernelManager | None = None
27
+ KC: Any = None
28
+ GENERATION = 0
29
+ STATE = "dead"
30
+ CURRENT_CELL: str | None = None
31
+ SHUTTING_DOWN = False
32
+ MAX_FRAME_BYTES = 8 * 1024 * 1024
33
+
34
+
35
+ def emit(payload: dict[str, Any]) -> None:
36
+ encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
37
+ if len(encoded.encode("utf-8")) > MAX_FRAME_BYTES:
38
+ encoded = json.dumps(
39
+ {"type": "protocol.error", "error": "output frame exceeds 8 MiB"},
40
+ ensure_ascii=False,
41
+ separators=(",", ":"),
42
+ )
43
+ with WRITE_LOCK:
44
+ sys.stdout.write(encoded + "\n")
45
+ sys.stdout.flush()
46
+
47
+
48
+ def reply(request_id: str, ok: bool, result: Any = None, error: str = "") -> None:
49
+ payload: dict[str, Any] = {"type": "rpc.reply", "request_id": request_id, "ok": ok}
50
+ if ok:
51
+ payload["result"] = result
52
+ else:
53
+ payload["error"] = {"message": error or "sidecar request failed"}
54
+ emit(payload)
55
+
56
+
57
+ def safe_status() -> dict[str, Any]:
58
+ pid = None
59
+ with KERNEL_LOCK:
60
+ if KM is not None:
61
+ provisioner = getattr(KM, "provisioner", None)
62
+ pid = getattr(provisioner, "pid", None)
63
+ return {
64
+ "state": STATE,
65
+ "generation": GENERATION,
66
+ "current_cell": CURRENT_CELL,
67
+ "pid": int(pid) if isinstance(pid, int) else None,
68
+ }
69
+
70
+
71
+ def start_kernel() -> dict[str, Any]:
72
+ global KM, KC, GENERATION, STATE
73
+ with KERNEL_LOCK:
74
+ if KM is not None and KM.is_alive():
75
+ return safe_status()
76
+ STATE = "starting"
77
+ manager = KernelManager()
78
+ # The sidecar itself runs under the selected interpreter. Explicitly
79
+ # pin ipykernel to the same executable instead of trusting a global spec.
80
+ manager.kernel_cmd = [sys.executable, "-m", "ipykernel_launcher", "-f", "{connection_file}"]
81
+ manager.start_kernel()
82
+ client = manager.client()
83
+ client.start_channels()
84
+ client.wait_for_ready(timeout=30)
85
+ KM = manager
86
+ KC = client
87
+ GENERATION += 1
88
+ STATE = "idle"
89
+ emit({"type": "kernel.status", **safe_status()})
90
+ return safe_status()
91
+
92
+
93
+ def output_from_message(message: dict[str, Any]) -> dict[str, Any] | None:
94
+ msg_type = str(message.get("header", {}).get("msg_type", ""))
95
+ content = message.get("content", {})
96
+ if msg_type == "stream":
97
+ return {
98
+ "outputType": "stream",
99
+ "name": str(content.get("name", "stdout")),
100
+ "text": str(content.get("text", "")),
101
+ }
102
+ if msg_type in ("display_data", "execute_result"):
103
+ data = content.get("data", {})
104
+ safe_data = {
105
+ str(key): value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
106
+ for key, value in data.items()
107
+ if isinstance(key, str)
108
+ }
109
+ transient = content.get("transient", {})
110
+ return {
111
+ "outputType": msg_type,
112
+ "data": safe_data,
113
+ "executionCount": content.get("execution_count"),
114
+ "displayId": transient.get("display_id") if isinstance(transient, dict) else None,
115
+ }
116
+ if msg_type == "error":
117
+ return {
118
+ "outputType": "error",
119
+ "name": str(content.get("ename", "Error")),
120
+ "value": str(content.get("evalue", "")),
121
+ "traceback": [str(line) for line in content.get("traceback", [])],
122
+ }
123
+ if msg_type == "clear_output":
124
+ return {"outputType": "clear_output", "wait": bool(content.get("wait"))}
125
+ return None
126
+
127
+
128
+ def execute_worker(request_id: str, cell_id: str, code: str, timeout_seconds: float) -> None:
129
+ global STATE, CURRENT_CELL
130
+ try:
131
+ start_kernel()
132
+ with KERNEL_LOCK:
133
+ client = KC
134
+ if client is None:
135
+ raise RuntimeError("kernel client unavailable")
136
+ msg_id = client.execute(code, stop_on_error=True, allow_stdin=False)
137
+ STATE = "busy"
138
+ CURRENT_CELL = cell_id
139
+ emit({"type": "execution.started", "request_id": request_id, "cell_id": cell_id, **safe_status()})
140
+ deadline = time.monotonic() + timeout_seconds
141
+ execution_count = None
142
+ shell_done = False
143
+ idle = False
144
+ while time.monotonic() < deadline and not (shell_done and idle):
145
+ try:
146
+ message = client.get_iopub_msg(timeout=0.2)
147
+ if message.get("parent_header", {}).get("msg_id") == msg_id:
148
+ msg_type = message.get("header", {}).get("msg_type")
149
+ if msg_type == "status" and message.get("content", {}).get("execution_state") == "idle":
150
+ idle = True
151
+ output = output_from_message(message)
152
+ if output:
153
+ if output.get("outputType") == "clear_output":
154
+ emit({"type": "output.clear", "cell_id": cell_id, "wait": output.get("wait", False)})
155
+ else:
156
+ emit({"type": "output", "cell_id": cell_id, "output": output})
157
+ except queue.Empty:
158
+ pass
159
+ if not shell_done:
160
+ try:
161
+ shell = client.get_shell_msg(timeout=0.01)
162
+ if shell.get("parent_header", {}).get("msg_id") == msg_id:
163
+ execution_count = shell.get("content", {}).get("execution_count")
164
+ shell_done = True
165
+ except queue.Empty:
166
+ pass
167
+ if not (shell_done and idle):
168
+ try:
169
+ with KERNEL_LOCK:
170
+ if KM is not None:
171
+ KM.interrupt_kernel()
172
+ except Exception:
173
+ pass
174
+ raise TimeoutError(f"cell execution exceeded {int(timeout_seconds)} seconds")
175
+ STATE = "idle"
176
+ CURRENT_CELL = None
177
+ emit({
178
+ "type": "execution.reply",
179
+ "request_id": request_id,
180
+ "ok": True,
181
+ "cell_id": cell_id,
182
+ "execution_count": execution_count,
183
+ **safe_status(),
184
+ })
185
+ except Exception as exc:
186
+ STATE = "idle" if KM is not None and KM.is_alive() else "dead"
187
+ CURRENT_CELL = None
188
+ emit({
189
+ "type": "execution.reply",
190
+ "request_id": request_id,
191
+ "ok": False,
192
+ "cell_id": cell_id,
193
+ "error": {"message": str(exc)},
194
+ **safe_status(),
195
+ })
196
+ finally:
197
+ CURRENT_CELL = None
198
+
199
+
200
+ def interrupt_kernel() -> dict[str, Any]:
201
+ global STATE
202
+ with KERNEL_LOCK:
203
+ if KM is None or not KM.is_alive():
204
+ raise RuntimeError("kernel is not running")
205
+ KM.interrupt_kernel()
206
+ STATE = "idle"
207
+ emit({"type": "execution.interrupted", **safe_status()})
208
+ return safe_status()
209
+
210
+
211
+ def shutdown_kernel() -> dict[str, Any]:
212
+ global KM, KC, STATE, CURRENT_CELL
213
+ with KERNEL_LOCK:
214
+ client, manager = KC, KM
215
+ KC = None
216
+ KM = None
217
+ if client is not None:
218
+ try:
219
+ client.stop_channels()
220
+ except Exception:
221
+ pass
222
+ if manager is not None:
223
+ try:
224
+ manager.shutdown_kernel(now=True)
225
+ except Exception:
226
+ try:
227
+ manager.cleanup_resources(restart=False)
228
+ except Exception:
229
+ pass
230
+ STATE = "dead"
231
+ CURRENT_CELL = None
232
+ emit({"type": "kernel.status", **safe_status()})
233
+ return safe_status()
234
+
235
+
236
+ def handle(message: dict[str, Any]) -> None:
237
+ global GENERATION
238
+ request_id = str(message.get("id", ""))
239
+ method = str(message.get("method", ""))
240
+ params = message.get("params") if isinstance(message.get("params"), dict) else {}
241
+ try:
242
+ if method == "start":
243
+ reply(request_id, True, start_kernel())
244
+ elif method == "execute":
245
+ cell_id = str(params.get("cell_id", ""))
246
+ code = str(params.get("code", ""))
247
+ if not cell_id:
248
+ raise ValueError("cell_id required")
249
+ threading.Thread(
250
+ target=execute_worker,
251
+ args=(request_id, cell_id, code, max(1.0, min(float(params.get("timeout_seconds", 1800)), 3600.0))),
252
+ daemon=True,
253
+ ).start()
254
+ elif method == "interrupt":
255
+ reply(request_id, True, interrupt_kernel())
256
+ elif method == "restart":
257
+ shutdown_kernel()
258
+ reply(request_id, True, start_kernel())
259
+ elif method == "shutdown":
260
+ reply(request_id, True, shutdown_kernel())
261
+ elif method == "status":
262
+ reply(request_id, True, safe_status())
263
+ else:
264
+ raise ValueError(f"unsupported method: {method}")
265
+ except Exception as exc:
266
+ reply(request_id, False, error=str(exc))
267
+
268
+
269
+ def cleanup(*_: Any) -> None:
270
+ global SHUTTING_DOWN
271
+ if SHUTTING_DOWN:
272
+ return
273
+ SHUTTING_DOWN = True
274
+ try:
275
+ shutdown_kernel()
276
+ finally:
277
+ raise SystemExit(0)
278
+
279
+
280
+ def main() -> None:
281
+ signal.signal(signal.SIGTERM, cleanup)
282
+ if hasattr(signal, "SIGINT"):
283
+ signal.signal(signal.SIGINT, cleanup)
284
+ emit({"type": "sidecar.ready", "pid": os.getpid(), "protocol": 1})
285
+ for line in sys.stdin:
286
+ try:
287
+ message = json.loads(line)
288
+ if not isinstance(message, dict):
289
+ raise ValueError("request must be an object")
290
+ handle(message)
291
+ except Exception as exc:
292
+ print(f"[notebook-sidecar] {exc}", file=sys.stderr, flush=True)
293
+ cleanup()
294
+
295
+
296
+ if __name__ == "__main__":
297
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whalent/agent",
3
- "version": "0.3.249",
3
+ "version": "0.3.251",
4
4
  "description": "Stable wrapper for Whalent Agent core runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "dist/index.cjs",
12
12
  "dist/core.cjs",
13
+ "dist/jupyter_sidecar.py",
13
14
  "scripts/preinstall-check.cjs",
14
15
  "package.json",
15
16
  "README.md"
@@ -53,7 +54,7 @@
53
54
  "yaml": "^2.4.0"
54
55
  },
55
56
  "optionalDependencies": {
56
- "@whalent/agent-core": "0.3.249",
57
+ "@whalent/agent-core": "0.3.251",
57
58
  "node-pty": "^1.1.0"
58
59
  },
59
60
  "devDependencies": {