@whalent/agent 0.3.250 → 0.3.252
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.
- package/dist/core.cjs +7 -7
- package/dist/jupyter_sidecar.py +297 -0
- package/package.json +4 -3
|
@@ -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.
|
|
3
|
+
"version": "0.3.252",
|
|
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,11 +54,11 @@
|
|
|
53
54
|
"yaml": "^2.4.0"
|
|
54
55
|
},
|
|
55
56
|
"optionalDependencies": {
|
|
56
|
-
"@whalent/agent-core": "0.3.
|
|
57
|
+
"@whalent/agent-core": "0.3.252",
|
|
57
58
|
"node-pty": "^1.1.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
61
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
|
61
62
|
"@types/node": "^20.0.0",
|
|
62
63
|
"@types/ssh2": "^1.15.5",
|
|
63
64
|
"@types/ws": "^8.5.10",
|