@melaya/runner 1.1.46 → 1.1.48
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/assistantHost.py +34 -5
- package/dist/connection.js +209 -95
- package/dist/pythonEnv.js +101 -11
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -230,21 +230,47 @@ class _RedactingStdout:
|
|
|
230
230
|
def _scrub(self, s: str) -> str:
|
|
231
231
|
return self._B64.sub(lambda m: f"<redacted base64 {len(m.group(0))} bytes>", s)
|
|
232
232
|
|
|
233
|
+
def _flush_transcript(self):
|
|
234
|
+
# Send any buffered agentscope transcript to STDERR (redacted). Keeping it
|
|
235
|
+
# off stdout means the runner's stdout channel carries ONLY `MELASSIST `
|
|
236
|
+
# protocol frames: a transcript print can never glue onto a frame and be
|
|
237
|
+
# dropped (the missing-delta / garbled-fragment bug), and frame parsing on
|
|
238
|
+
# the runner stays cheap — no multi-KB base64 transcript lines flooding its
|
|
239
|
+
# event loop (which was spiking lag and dropping the socket heartbeat).
|
|
240
|
+
if self._buf:
|
|
241
|
+
try:
|
|
242
|
+
sys.stderr.write(self._scrub(self._buf))
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
self._buf = ""
|
|
246
|
+
|
|
233
247
|
def write(self, s):
|
|
234
248
|
try:
|
|
249
|
+
# A protocol frame is emitted in ONE write() call, whole and
|
|
250
|
+
# newline-terminated (see _emit). Route it to the REAL stdout byte for
|
|
251
|
+
# byte (no scrub — frames are short structured JSON), after flushing any
|
|
252
|
+
# pending partial transcript so nothing can glue onto it.
|
|
253
|
+
if s.startswith(_EVENT_PREFIX):
|
|
254
|
+
self._flush_transcript()
|
|
255
|
+
self._real.write(s)
|
|
256
|
+
self._real.flush()
|
|
257
|
+
return len(s)
|
|
258
|
+
# Everything else = agentscope's verbatim message transcript → stderr,
|
|
259
|
+
# line-buffered + base64-redacted, so it never pollutes the protocol.
|
|
235
260
|
self._buf += s
|
|
236
261
|
while "\n" in self._buf:
|
|
237
262
|
line, self._buf = self._buf.split("\n", 1)
|
|
238
|
-
|
|
263
|
+
try:
|
|
264
|
+
sys.stderr.write(self._scrub(line) + "\n")
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
239
267
|
return len(s)
|
|
240
268
|
except Exception:
|
|
241
269
|
return self._real.write(s)
|
|
242
270
|
|
|
243
271
|
def flush(self):
|
|
244
272
|
try:
|
|
245
|
-
|
|
246
|
-
self._real.write(self._scrub(self._buf))
|
|
247
|
-
self._buf = ""
|
|
273
|
+
self._flush_transcript()
|
|
248
274
|
self._real.flush()
|
|
249
275
|
except Exception:
|
|
250
276
|
pass
|
|
@@ -1177,6 +1203,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
|
1177
1203
|
|
|
1178
1204
|
|
|
1179
1205
|
def main() -> int:
|
|
1206
|
+
_boot_t0 = time.perf_counter()
|
|
1180
1207
|
_log(f"booting (provider={os.environ.get('MEL_ASSISTANT_PROVIDER')})")
|
|
1181
1208
|
# Wire OpenTelemetry → Melaya event relay so agentscope's @trace_llm actually
|
|
1182
1209
|
# exports token spans to agents.spans — the SAME thing pipelines get via
|
|
@@ -1195,7 +1222,9 @@ def main() -> int:
|
|
|
1195
1222
|
# in-process phone tools (phone.py._cmd) see it from turn one.
|
|
1196
1223
|
_apply_hitl_mode(None)
|
|
1197
1224
|
try:
|
|
1225
|
+
_t_agent = time.perf_counter()
|
|
1198
1226
|
agent = _build_agent()
|
|
1227
|
+
_log(f"[timing] _build_agent {time.perf_counter() - _t_agent:.2f}s")
|
|
1199
1228
|
except Exception as exc:
|
|
1200
1229
|
_log("boot failed:\n" + traceback.format_exc())
|
|
1201
1230
|
_emit("", "error", message="The assistant could not start on this runner. " + _turn_error_message(exc))
|
|
@@ -1218,7 +1247,7 @@ def main() -> int:
|
|
|
1218
1247
|
_log("compaction hooks unavailable (non-fatal)")
|
|
1219
1248
|
|
|
1220
1249
|
_emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
|
|
1221
|
-
_log("ready")
|
|
1250
|
+
_log(f"[timing] ready — {time.perf_counter() - _boot_t0:.2f}s (main start → ready). Import cost = gap from process spawn to the 'booting' line above.")
|
|
1222
1251
|
|
|
1223
1252
|
q: "queue.Queue[str]" = queue.Queue()
|
|
1224
1253
|
threading.Thread(target=_stdin_reader, args=(q,), daemon=True).start()
|
package/dist/connection.js
CHANGED
|
@@ -14,7 +14,7 @@ import { io } from "socket.io-client";
|
|
|
14
14
|
import chalk from "chalk";
|
|
15
15
|
import ora from "ora";
|
|
16
16
|
import { spawn } from "child_process";
|
|
17
|
-
import { writeFileSync, mkdirSync, existsSync, readFileSync } from "fs";
|
|
17
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
|
|
18
18
|
import { dirname, join } from "path";
|
|
19
19
|
import { fileURLToPath } from "url";
|
|
20
20
|
import { tmpdir, homedir } from "os";
|
|
@@ -55,6 +55,20 @@ try {
|
|
|
55
55
|
}
|
|
56
56
|
catch { /* dev tree layout differs — non-fatal */ }
|
|
57
57
|
const activeAssistants = new Map();
|
|
58
|
+
// Line prefix the assistant host uses to frame protocol events on stdout
|
|
59
|
+
// (mirror of assistantHost.py `_EVENT_PREFIX`). Everything else on stdout is
|
|
60
|
+
// raw agentscope transcript (now routed to stderr host-side, but we still parse
|
|
61
|
+
// defensively in case a frame arrives glued to a raw print).
|
|
62
|
+
const _EVENT_PREFIX = "MELASSIST ";
|
|
63
|
+
// Warm pool (size 1): a fully-booted host held in reserve, ready to be ADOPTED
|
|
64
|
+
// by the next new session whose config+generation match — skipping the ~7s cold
|
|
65
|
+
// boot (import + build_agent). Pure optimization: any mismatch or unhealthy
|
|
66
|
+
// spare falls through to a normal fresh boot, so it can never break a session.
|
|
67
|
+
let warmSpare = null;
|
|
68
|
+
// The (env, config, generation) to prewarm the next spare with — learned from
|
|
69
|
+
// the most recent real assistant_start so the spare matches the user's actual
|
|
70
|
+
// usage. null until the first real boot teaches us a config.
|
|
71
|
+
let warmSpareSeed = null;
|
|
58
72
|
// PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
|
|
59
73
|
// has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
|
|
60
74
|
// activeAssistants.has() check and the .set(). A second assistant_start in that
|
|
@@ -1293,6 +1307,172 @@ export async function connect(opts) {
|
|
|
1293
1307
|
// codex / ollama / lmstudio: assistant_start boots a long-lived host,
|
|
1294
1308
|
// assistant_turn feeds one user message, and the host streams events back as
|
|
1295
1309
|
// runner:assistant_event frames the server relays into the browser SSE.
|
|
1310
|
+
// ── Warm-pool: spawn + wire an assistant host ────────────────────────────
|
|
1311
|
+
// Used for a REAL session boot (forward=true) AND for a pooled spare
|
|
1312
|
+
// (forward=false → the host's frames are suppressed until adoption re-points
|
|
1313
|
+
// session.sid + flips forward). Reading session.sid/forward (not a captured
|
|
1314
|
+
// constant) is what lets one process be adopted by a later sid. The `error`
|
|
1315
|
+
// listener is registered UNCONDITIONALLY here — the old code nested it inside
|
|
1316
|
+
// `exit`, so a spawn 'error' (which never fires 'exit') had no listener and
|
|
1317
|
+
// took down the whole runner.
|
|
1318
|
+
const launchAssistantHost = (a) => {
|
|
1319
|
+
const proc = spawn(a.pythonPath, ["-u", a.stagedHost], { cwd: a.workDir, env: a.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
1320
|
+
const session = {
|
|
1321
|
+
proc, sid: a.sid, forward: a.forward, ready: false, lastActivity: Date.now(),
|
|
1322
|
+
stdoutBuf: "", generation: a.generation, memoryWatermark: 0, hostInstanceId: "", configHash: "", stderrTail: [],
|
|
1323
|
+
};
|
|
1324
|
+
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant ${a.forward ? "session" : "spare"} ${a.sid.slice(0, 12)}… (${a.providerLabel})`));
|
|
1325
|
+
proc.stdout?.on("data", (data) => {
|
|
1326
|
+
session.lastActivity = Date.now();
|
|
1327
|
+
session.stdoutBuf += data.toString();
|
|
1328
|
+
let idx;
|
|
1329
|
+
while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
|
|
1330
|
+
const line = session.stdoutBuf.slice(0, idx);
|
|
1331
|
+
session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
|
|
1332
|
+
const t = line.trim();
|
|
1333
|
+
// A protocol frame can arrive GLUED to a preceding raw print that had no
|
|
1334
|
+
// trailing newline (agentscope prints the agent transcript to the same
|
|
1335
|
+
// stdout), e.g. `Assistant: GotMELASSIST {"kind":"delta",…}`. Find the
|
|
1336
|
+
// prefix ANYWHERE in the line, not just at the start, or that delta is
|
|
1337
|
+
// dropped as raw output (the missing-deltas / garbled-fragment bug).
|
|
1338
|
+
const mi = t.indexOf(_EVENT_PREFIX);
|
|
1339
|
+
if (mi >= 0) {
|
|
1340
|
+
if (mi > 0 && opts.verbose)
|
|
1341
|
+
console.log(chalk.gray(` [assistant-stdout] ${t.slice(0, mi)}`));
|
|
1342
|
+
try {
|
|
1343
|
+
const parsed = JSON.parse(t.slice(mi + _EVENT_PREFIX.length));
|
|
1344
|
+
if (typeof parsed?.memoryWatermark === "number")
|
|
1345
|
+
session.memoryWatermark = parsed.memoryWatermark;
|
|
1346
|
+
if (parsed?.kind === "ready") {
|
|
1347
|
+
if (typeof parsed.hostInstanceId === "string")
|
|
1348
|
+
session.hostInstanceId = parsed.hostInstanceId;
|
|
1349
|
+
if (typeof parsed.configHash === "string")
|
|
1350
|
+
session.configHash = parsed.configHash;
|
|
1351
|
+
session.ready = true;
|
|
1352
|
+
parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
|
|
1353
|
+
}
|
|
1354
|
+
// A spare's frames are NOT forwarded (no session yet); adoption flips
|
|
1355
|
+
// session.forward and re-emits `ready` with the real sid.
|
|
1356
|
+
if (session.forward)
|
|
1357
|
+
socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
|
|
1358
|
+
}
|
|
1359
|
+
catch {
|
|
1360
|
+
// The literal "MELASSIST " appeared inside raw text (not a real frame)
|
|
1361
|
+
// — the JSON tail won't parse; treat the whole line as raw output.
|
|
1362
|
+
if (t && opts.verbose)
|
|
1363
|
+
console.log(chalk.gray(` [assistant-stdout] ${t}`));
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
else if (t && opts.verbose) {
|
|
1367
|
+
console.log(chalk.gray(` [assistant-stdout] ${t}`));
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
proc.stderr?.on("data", (data) => {
|
|
1372
|
+
for (const ln of data.toString().split("\n")) {
|
|
1373
|
+
const t = ln.trimEnd();
|
|
1374
|
+
if (!t)
|
|
1375
|
+
continue;
|
|
1376
|
+
if (opts.verbose)
|
|
1377
|
+
console.log(chalk.gray(` [assistant-stderr] ${t}`));
|
|
1378
|
+
session.stderrTail.push(t);
|
|
1379
|
+
if (session.stderrTail.length > 40)
|
|
1380
|
+
session.stderrTail.shift();
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
proc.on("error", (err) => {
|
|
1384
|
+
if (warmSpare === session)
|
|
1385
|
+
warmSpare = null;
|
|
1386
|
+
if (activeAssistants.get(session.sid) === session)
|
|
1387
|
+
activeAssistants.delete(session.sid);
|
|
1388
|
+
bootingAssistants.delete(session.sid);
|
|
1389
|
+
if (!session.forward) {
|
|
1390
|
+
if (opts.verbose)
|
|
1391
|
+
console.log(chalk.gray(` [prewarm] spare spawn error: ${err?.message || err}`));
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
browserBridge?.teardownRunsForSession(session.sid, "assistant_spawn_error").catch(() => { });
|
|
1395
|
+
socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: "", kind: "error", message: `assistant host spawn failed: ${err?.message || err}` });
|
|
1396
|
+
});
|
|
1397
|
+
proc.on("exit", (code) => {
|
|
1398
|
+
if (warmSpare === session)
|
|
1399
|
+
warmSpare = null;
|
|
1400
|
+
const weOwned = activeAssistants.get(session.sid) === session;
|
|
1401
|
+
if (weOwned)
|
|
1402
|
+
activeAssistants.delete(session.sid);
|
|
1403
|
+
if (weOwned)
|
|
1404
|
+
browserBridge?.teardownRunsForSession(session.sid, `assistant_exit:${code}`).catch(() => { });
|
|
1405
|
+
if (!session.forward) {
|
|
1406
|
+
if (opts.verbose)
|
|
1407
|
+
console.log(chalk.gray(` ■ Assistant spare exited (${code}) pre-adoption`));
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (session.killed) {
|
|
1411
|
+
console.log(chalk.gray(` ■ Assistant host ${session.sid.slice(0, 10)}… replaced (config drift, exit ${code})`));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
const detail = code !== 0 && session.stderrTail.length ? session.stderrTail.slice(-12).join("\n") : "";
|
|
1415
|
+
socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: "", kind: "session_closed", code, detail });
|
|
1416
|
+
console.log(chalk.gray(` ■ Assistant session ${session.sid.slice(0, 10)}… closed (exit ${code})`));
|
|
1417
|
+
});
|
|
1418
|
+
return session;
|
|
1419
|
+
};
|
|
1420
|
+
// Prewarm ONE spare from the most-recent real config so the next matching new
|
|
1421
|
+
// session adopts it and skips the ~7s cold boot. Best-effort, ≤1 spare, kills a
|
|
1422
|
+
// stale spare (config changed) first, self-destructs after 10 min if unused.
|
|
1423
|
+
// Off by default: the adoption MECHANISM is proven, but a CLI-session provider
|
|
1424
|
+
// (claude_code/codex keep a per-user CLI/OAuth session) can contend when a
|
|
1425
|
+
// spare runs a turn alongside a live host — needs per-provider validation
|
|
1426
|
+
// before it's on for everyone. Set MEL_ASSISTANT_WARM_POOL=1 to enable.
|
|
1427
|
+
const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL === "1";
|
|
1428
|
+
let _prewarmInFlight = false;
|
|
1429
|
+
const schedulePrewarm = () => {
|
|
1430
|
+
if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
|
|
1431
|
+
return;
|
|
1432
|
+
if (warmSpare) {
|
|
1433
|
+
if (warmSpare.configHash && warmSpare.configHash !== warmSpareSeed.configHash) {
|
|
1434
|
+
try {
|
|
1435
|
+
warmSpare.proc.kill();
|
|
1436
|
+
}
|
|
1437
|
+
catch { /* gone */ }
|
|
1438
|
+
warmSpare = null;
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
_prewarmInFlight = true;
|
|
1445
|
+
try {
|
|
1446
|
+
const seed = warmSpareSeed;
|
|
1447
|
+
const stamp = String(Date.now());
|
|
1448
|
+
const wd = join(seed.workDirBase, `melaya-assistant-spare-${stamp}`);
|
|
1449
|
+
mkdirSync(wd, { recursive: true });
|
|
1450
|
+
const staged = join(wd, "assistantHost.py");
|
|
1451
|
+
copyFileSync(seed.hostScript, staged);
|
|
1452
|
+
// Keep seed.env's MEL_RUN_ID (`assistant:<userId>`, per-user, shared by all
|
|
1453
|
+
// of a user's sessions) so an adopted spare's run context / tracing / phone
|
|
1454
|
+
// arm plumbing matches the session that adopts it — overriding it to a
|
|
1455
|
+
// spare-specific id broke turn execution on adoption.
|
|
1456
|
+
const spareEnv = { ...seed.env };
|
|
1457
|
+
const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
|
|
1458
|
+
warmSpare = spare;
|
|
1459
|
+
const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
|
|
1460
|
+
try {
|
|
1461
|
+
spare.proc.kill();
|
|
1462
|
+
}
|
|
1463
|
+
catch { /* gone */ }
|
|
1464
|
+
warmSpare = null;
|
|
1465
|
+
} }, 10 * 60_000);
|
|
1466
|
+
t.unref?.();
|
|
1467
|
+
}
|
|
1468
|
+
catch (e) {
|
|
1469
|
+
if (opts.verbose)
|
|
1470
|
+
console.log(chalk.gray(` [prewarm] launch failed: ${e?.message || e}`));
|
|
1471
|
+
}
|
|
1472
|
+
finally {
|
|
1473
|
+
_prewarmInFlight = false;
|
|
1474
|
+
}
|
|
1475
|
+
};
|
|
1296
1476
|
socket.on("runner:assistant_start", async (payload) => {
|
|
1297
1477
|
const sid = String(payload.sessionId || "");
|
|
1298
1478
|
if (!sid)
|
|
@@ -1328,6 +1508,25 @@ export async function connect(opts) {
|
|
|
1328
1508
|
return;
|
|
1329
1509
|
}
|
|
1330
1510
|
}
|
|
1511
|
+
// Warm-pool ADOPTION (fast path). A fully-booted spare with the SAME config
|
|
1512
|
+
// AND generation is waiting — re-point it to this sid and report ready
|
|
1513
|
+
// immediately, skipping the ~7s cold boot (import + build_agent). Fail-safe:
|
|
1514
|
+
// exact match of a READY, un-killed spare ONLY; anything else falls straight
|
|
1515
|
+
// through to the fresh-boot path below, so the pool can never break a session.
|
|
1516
|
+
if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
|
|
1517
|
+
&& warmSpare && warmSpare.ready && !warmSpare.killed && expectedConfig
|
|
1518
|
+
&& warmSpare.configHash === expectedConfig && warmSpare.generation === generation) {
|
|
1519
|
+
const spare = warmSpare;
|
|
1520
|
+
warmSpare = null;
|
|
1521
|
+
spare.sid = sid;
|
|
1522
|
+
spare.forward = true;
|
|
1523
|
+
spare.lastActivity = Date.now();
|
|
1524
|
+
activeAssistants.set(sid, spare);
|
|
1525
|
+
console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
|
|
1526
|
+
emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
|
|
1527
|
+
schedulePrewarm(); // refill the pool for the next session
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1331
1530
|
// A boot is already in flight for this sid (concurrent start / server restart
|
|
1332
1531
|
// mid-boot). Drop this duplicate — the in-flight boot will emit `ready`.
|
|
1333
1532
|
if (bootingAssistants.has(sid))
|
|
@@ -1454,101 +1653,16 @@ export async function connect(opts) {
|
|
|
1454
1653
|
// Per-user creds (incl. MELAYA_API_KEY + the selected connectors' env).
|
|
1455
1654
|
...(payload.credentials || {}),
|
|
1456
1655
|
};
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
// server-aligned. Start empty; the stdout parser captures them.
|
|
1461
|
-
const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation, memoryWatermark: 0, hostInstanceId: "", configHash: "" };
|
|
1462
|
-
activeAssistants.set(sid, session);
|
|
1463
|
-
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
|
|
1464
|
-
proc.stdout?.on("data", (data) => {
|
|
1465
|
-
// ANY host output means the turn is actively working — refresh the idle
|
|
1466
|
-
// clock. Without this, lastActivity was set ONLY at turn start (assistant_turn),
|
|
1467
|
-
// so a SINGLE long turn (a phone task is one turn with ~100 tool calls streaming
|
|
1468
|
-
// for >15 min) was reaped mid-action by the idle sweep despite continuous
|
|
1469
|
-
// activity ("idle-swept" while properly working). Now only a genuinely silent
|
|
1470
|
-
// host (no output for ASSISTANT_IDLE_MS) is swept.
|
|
1471
|
-
session.lastActivity = Date.now();
|
|
1472
|
-
session.stdoutBuf += data.toString();
|
|
1473
|
-
let idx;
|
|
1474
|
-
while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
|
|
1475
|
-
const line = session.stdoutBuf.slice(0, idx);
|
|
1476
|
-
session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
|
|
1477
|
-
const t = line.trim();
|
|
1478
|
-
if (t.startsWith("MELASSIST ")) {
|
|
1479
|
-
try {
|
|
1480
|
-
const parsed = JSON.parse(t.slice("MELASSIST ".length));
|
|
1481
|
-
// Track the host's latest memory watermark (ready / rehydrated
|
|
1482
|
-
// frames report it) so a re-`start` of the live host reports a
|
|
1483
|
-
// truthful watermark and the server skips a needless rehydrate.
|
|
1484
|
-
if (typeof parsed?.memoryWatermark === "number")
|
|
1485
|
-
session.memoryWatermark = parsed.memoryWatermark;
|
|
1486
|
-
// CAPTURE the host's authoritative identity off its `ready` frame
|
|
1487
|
-
// (_HOST_ID / _config_hash) so the live-host ready path can re-report
|
|
1488
|
-
// them without the host re-emitting; ADD only the wire protocol.
|
|
1489
|
-
if (parsed?.kind === "ready") {
|
|
1490
|
-
if (typeof parsed.hostInstanceId === "string")
|
|
1491
|
-
session.hostInstanceId = parsed.hostInstanceId;
|
|
1492
|
-
if (typeof parsed.configHash === "string")
|
|
1493
|
-
session.configHash = parsed.configHash;
|
|
1494
|
-
parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
|
|
1495
|
-
}
|
|
1496
|
-
socket.emit("runner:assistant_event", { sessionId: sid, ...parsed });
|
|
1497
|
-
}
|
|
1498
|
-
catch { /* skip malformed */ }
|
|
1499
|
-
}
|
|
1500
|
-
else if (t && opts.verbose) {
|
|
1501
|
-
console.log(chalk.gray(` [assistant-stdout] ${t}`));
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
});
|
|
1505
|
-
const stderrTail = [];
|
|
1506
|
-
proc.stderr?.on("data", (data) => {
|
|
1507
|
-
for (const ln of data.toString().split("\n")) {
|
|
1508
|
-
const t = ln.trimEnd();
|
|
1509
|
-
if (!t)
|
|
1510
|
-
continue;
|
|
1511
|
-
if (opts.verbose)
|
|
1512
|
-
console.log(chalk.gray(` [assistant-stderr] ${t}`));
|
|
1513
|
-
stderrTail.push(t);
|
|
1514
|
-
if (stderrTail.length > 40)
|
|
1515
|
-
stderrTail.shift();
|
|
1516
|
-
}
|
|
1517
|
-
});
|
|
1518
|
-
proc.on("exit", (code) => {
|
|
1519
|
-
// Only evict if WE are still the registered host. A config-drift reboot
|
|
1520
|
-
// (e.g. the user added a connector) kills this host and spawns a REPLACEMENT
|
|
1521
|
-
// under the same sid; if this late exit deleted the map entry blindly it
|
|
1522
|
-
// would orphan the newcomer and make the next turn "session_not_found".
|
|
1523
|
-
proc.on("error", (err) => {
|
|
1524
|
-
// A spawn that fails (python deleted post-check, EACCES/AV) emits "error"
|
|
1525
|
-
// and never "exit"; with no listener this took down the WHOLE runner.
|
|
1526
|
-
if (activeAssistants.get(sid) === session)
|
|
1527
|
-
activeAssistants.delete(sid);
|
|
1528
|
-
bootingAssistants.delete(sid);
|
|
1529
|
-
browserBridge?.teardownRunsForSession(sid, "assistant_spawn_error").catch(() => { });
|
|
1530
|
-
socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "error", message: `assistant host spawn failed: ${err?.message || err}` });
|
|
1531
|
-
});
|
|
1532
|
-
const weOwned = activeAssistants.get(sid) === session;
|
|
1533
|
-
if (weOwned)
|
|
1534
|
-
activeAssistants.delete(sid);
|
|
1535
|
-
// Tear down any per-turn bridge runs this host leaked (a turn that never
|
|
1536
|
-
// sent `done` before the host exited/crashed) so its lease + enforceOnPage
|
|
1537
|
-
// handlers don't linger on the user's open tab. Only if a replacement
|
|
1538
|
-
// host hasn't already taken over the sid.
|
|
1539
|
-
if (weOwned)
|
|
1540
|
-
browserBridge?.teardownRunsForSession(sid, `assistant_exit:${code}`).catch(() => { });
|
|
1541
|
-
// A host we INTENTIONALLY killed for a config-drift reboot must NOT emit
|
|
1542
|
-
// session_closed — that frame would fail the fresh boot's ready waiter
|
|
1543
|
-
// (the server can't pre-mark it superseded here, the kill is runner-side).
|
|
1544
|
-
if (session.killed) {
|
|
1545
|
-
console.log(chalk.gray(` ■ Assistant host ${sid.slice(0, 10)}… replaced (config drift, exit ${code})`));
|
|
1546
|
-
return;
|
|
1547
|
-
}
|
|
1548
|
-
const detail = code !== 0 && stderrTail.length ? stderrTail.slice(-12).join("\n") : "";
|
|
1549
|
-
socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "session_closed", code, detail });
|
|
1550
|
-
console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… closed (exit ${code})`));
|
|
1656
|
+
const session = launchAssistantHost({
|
|
1657
|
+
env, pythonPath: envResult.pythonPath, workDir, stagedHost,
|
|
1658
|
+
sid, generation, forward: true, providerLabel: String(payload.provider || ""),
|
|
1551
1659
|
});
|
|
1660
|
+
activeAssistants.set(sid, session);
|
|
1661
|
+
// Teach the pool this exact (env, config, generation) so it prewarms a
|
|
1662
|
+
// matching spare for the NEXT new session — which then adopts it and skips
|
|
1663
|
+
// the cold boot (see the ADOPTION fast path + schedulePrewarm above).
|
|
1664
|
+
warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript };
|
|
1665
|
+
schedulePrewarm();
|
|
1552
1666
|
}
|
|
1553
1667
|
catch (e) {
|
|
1554
1668
|
emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
|
package/dist/pythonEnv.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createHash } from "crypto";
|
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { homedir, platform } from "os";
|
|
17
17
|
import chalk from "chalk";
|
|
18
|
+
import { localUvPath } from "./uvBootstrap.js";
|
|
18
19
|
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
19
20
|
const VENV_DIR = join(CACHE_DIR, "venv");
|
|
20
21
|
const VENV_MARK = join(CACHE_DIR, "venv-version.txt");
|
|
@@ -151,13 +152,18 @@ const PIP_DEPS = [
|
|
|
151
152
|
// ── Template-validation sweep deps (2026-07-06) — tools that hard-error
|
|
152
153
|
// "pip install X" on the runner without these, observed in live runs:
|
|
153
154
|
// yt-dlp — youtube_search_channel / youtube_get_transcript (1221 YouTube Intel)
|
|
154
|
-
// sentence-transformers — build_knowledge_from_text embedder (1221)
|
|
155
155
|
// flights — flight_price_alert / flight_search live Google Flights ("fli" import; 1261/1265)
|
|
156
156
|
// pymupdf4llm — pdf_to_text markdown extraction (1227 PDF Contract Analyser)
|
|
157
157
|
"yt-dlp",
|
|
158
|
-
"sentence-transformers",
|
|
159
158
|
"flights",
|
|
160
159
|
"pymupdf4llm",
|
|
160
|
+
// sentence-transformers REMOVED 2026-09-09 — it pulls torch (~2GB), which
|
|
161
|
+
// dominated the venv install/reinstall for EVERY runner, yet only
|
|
162
|
+
// build_knowledge_from_text (+ guarded fallbacks in rag/memory_search/aiml)
|
|
163
|
+
// uses it, and DocsTab local RAG embedding runs on OLLAMA/LM-Studio
|
|
164
|
+
// (localRagIngest.py), not sentence-transformers. All import sites are
|
|
165
|
+
// try/except-guarded, so the niche tool degrades to a "pip install
|
|
166
|
+
// sentence-transformers" message instead of shipping 2GB to everyone.
|
|
161
167
|
// ── Vector RAG (retrieval mode) ────────────────────────────────────────
|
|
162
168
|
// ollama — Python client used by agentscope.embedding.OllamaTextEmbedding.
|
|
163
169
|
// Talks to localhost:11434 (the user's local Ollama daemon) to embed
|
|
@@ -215,12 +221,74 @@ const PIP_DEPS = [
|
|
|
215
221
|
"asyncpg",
|
|
216
222
|
"PyMySQL",
|
|
217
223
|
"cryptography",
|
|
224
|
+
// ── Cloud-infra connector SDKs (shared/tools/{aws,route53,alibaba,gcp}.py) ──
|
|
225
|
+
// These modules LAZY-import their SDK inside the tool call, so the toolkit
|
|
226
|
+
// builds + creds.test passes GREEN without them — then the FIRST real tool
|
|
227
|
+
// call ModuleNotFoundErrors on the runner (AWS/Alibaba incident 2026-08-16;
|
|
228
|
+
// Alibaba re-hit 2026-09-09). They ship in requirements.lock (the cloud image)
|
|
229
|
+
// but MUST be mirrored HERE too — the runner venv installs from THIS list, not
|
|
230
|
+
// the lock. Surfaced by the runner dep audit (packages/runner/scripts/
|
|
231
|
+
// audit_runner_deps.py); keep the pins in step with requirements.lock.
|
|
232
|
+
// PURE-PYTHON WHEELS ONLY here: never add a dep that needs a system binary
|
|
233
|
+
// (libzbar / cairo+pango / tesseract / …) — a wheel-less dep fails the WHOLE
|
|
234
|
+
// venv bootstrap and bricks the runner; leave those lazy-guarded in the tool.
|
|
235
|
+
"boto3==1.42.42", // aws.py + route53.py
|
|
236
|
+
"alibabacloud-tea-openapi==0.4.5", // alibaba.py — generic Tea OpenAPI client
|
|
237
|
+
"alibabacloud-tea-util==0.3.15", // alibaba.py
|
|
238
|
+
"alibabacloud-openapi-util==0.2.4", // alibaba.py (pip resolves the rest of the Tea closure)
|
|
239
|
+
"google-api-python-client==2.198.0", // gcp.py — googleapiclient.discovery
|
|
240
|
+
"google-auth-httplib2==0.4.1", // gcp.py — AuthorizedHttp (pulls httplib2 + google-auth)
|
|
241
|
+
// ── Additional shared-tool deps (SOFT gaps from audit_runner_deps.py 2026-09-09)
|
|
242
|
+
// All lazy-imported inside the tool AND pure-python wheels, so they do NOT slow
|
|
243
|
+
// spawn (module import stays light) — only the one-time venv install. Without
|
|
244
|
+
// them the tool degrades with a "pip install X" message; cheap + common enough
|
|
245
|
+
// to ship. DELIBERATELY EXCLUDED (stay lazy-guarded, never in PIP_DEPS): the
|
|
246
|
+
// system-binary deps opencv/cv2, weasyprint (cairo+pango), pytesseract
|
|
247
|
+
// (tesseract), pyzbar (libzbar) — a wheel-less dep fails the whole venv
|
|
248
|
+
// bootstrap; and web3 / fastapi (heavy tree / odd for a tool runner).
|
|
249
|
+
"redis", // crew_blackout_watcher / crew_halts / hitl_middleware
|
|
250
|
+
"youtube-transcript-api", // youtube_tools
|
|
251
|
+
"icalendar", // ics_tools
|
|
252
|
+
"Unidecode", // translation_tools (imports as `unidecode`)
|
|
253
|
+
"pdfplumber", // core (PDF text extraction)
|
|
254
|
+
"segno", // qr_tools (QR generation)
|
|
255
|
+
"PyGithub", // github tool (imports as `github`)
|
|
256
|
+
"python-gitlab", // github tool (imports as `gitlab`)
|
|
257
|
+
"pandas-ta", // alphavantage_tools (TA indicators; pandas already present)
|
|
258
|
+
"python-telegram-bot", // messaging (bot-API rich path; pulls httpx, already present)
|
|
259
|
+
"aiomysql", // database (async MySQL; sync PyMySQL already present)
|
|
218
260
|
];
|
|
219
261
|
export function venvPython() {
|
|
220
262
|
return platform() === "win32"
|
|
221
263
|
? join(VENV_DIR, "Scripts", "python.exe")
|
|
222
264
|
: join(VENV_DIR, "bin", "python");
|
|
223
265
|
}
|
|
266
|
+
/** Prefer the runner-managed uv (localUvPath), else uv on PATH, else null.
|
|
267
|
+
* uv installs far faster than pip (parallel downloads + a SAFE content-
|
|
268
|
+
* addressed, per-hash-validated cache), turning the "1-2 min" venv reinstall on
|
|
269
|
+
* a deps/version change into seconds. Pure lookup — never downloads uv here
|
|
270
|
+
* (the pip fallback covers uv's absence, so the venv build can't be blocked). */
|
|
271
|
+
function _resolveUvBin() {
|
|
272
|
+
try {
|
|
273
|
+
const p = localUvPath();
|
|
274
|
+
if (existsSync(p))
|
|
275
|
+
return p;
|
|
276
|
+
}
|
|
277
|
+
catch { /* ignore */ }
|
|
278
|
+
const bin = platform() === "win32" ? "uv.exe" : "uv";
|
|
279
|
+
const sep = platform() === "win32" ? ";" : ":";
|
|
280
|
+
for (const dir of (process.env.PATH || "").split(sep)) {
|
|
281
|
+
if (!dir)
|
|
282
|
+
continue;
|
|
283
|
+
try {
|
|
284
|
+
const cand = join(dir, bin);
|
|
285
|
+
if (existsSync(cand))
|
|
286
|
+
return cand;
|
|
287
|
+
}
|
|
288
|
+
catch { /* ignore */ }
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
224
292
|
// ── certifi CA-bundle path resolution + cache ────────────────────────────
|
|
225
293
|
// Set as SSL_CERT_FILE + REQUESTS_CA_BUNDLE when spawning pipeline Python
|
|
226
294
|
// so that ANY HTTPS-using tool (gmail_send, slack_post_text, anthropic /
|
|
@@ -488,15 +556,37 @@ async function _ensurePythonEnvImpl(systemPython, expectedVersion, onProgress =
|
|
|
488
556
|
// only *.py files (no pyproject.toml), so PYTHONPATH-based import is
|
|
489
557
|
// the only path. The cached agentscope source under
|
|
490
558
|
// ~/.melaya-runner/agentscope/ resolves via PYTHONPATH at spawn time.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
|
|
559
|
+
// Prefer uv when available (the runner ships it — localUvPath — or provisions
|
|
560
|
+
// it for managed CPython). uv is ~10-100x faster than pip: parallel downloads,
|
|
561
|
+
// a resolver written in Rust, and a content-addressed cache that is validated
|
|
562
|
+
// per wheel hash — so unlike pip's SHARED, cross-Python-version cache (the very
|
|
563
|
+
// reason the pip path below passes --no-cache-dir) uv's cache can never install
|
|
564
|
+
// a corrupt wheel. We therefore get caching AND safety, which turns the "1-2
|
|
565
|
+
// min" reinstall on a deps/version change into seconds. UV_SYSTEM_CERTS mirrors
|
|
566
|
+
// pip's --use-feature=truststore (verify TLS against the OS trust store — vital
|
|
567
|
+
// behind intercepting proxies / AV, else CERTIFICATE_VERIFY_FAILED). Any uv
|
|
568
|
+
// failure falls straight back to the proven pip path so the venv build is never
|
|
569
|
+
// blocked by a uv edge case.
|
|
570
|
+
const _uvBin = _resolveUvBin();
|
|
571
|
+
let acode;
|
|
572
|
+
if (_uvBin) {
|
|
573
|
+
onProgress(`installing ${PIP_DEPS.length} python deps via uv (fast; cached reinstalls take seconds)`);
|
|
574
|
+
acode = await runProc(_uvBin, ["pip", "install", "--python", venvPython(), "--quiet", ...PIP_DEPS], onProgress, { UV_SYSTEM_CERTS: "1" });
|
|
575
|
+
if (acode !== 0) {
|
|
576
|
+
onProgress("uv install failed — falling back to pip (--no-cache-dir)");
|
|
577
|
+
acode = await runProc(venvPython(), ["-m", "pip", "install", "--use-feature=truststore", "--no-cache-dir", "--quiet", "--disable-pip-version-check", ...PIP_DEPS], onProgress);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
onProgress(`installing ${PIP_DEPS.length} python deps (anthropic, openai, opentelemetry, …) — first run can take 1-2 min`);
|
|
582
|
+
// --no-cache-dir: skip pip's on-disk HTTP/wheel cache. It silences the
|
|
583
|
+
// "Cache entry deserialization failed, entry ignored" warning flood (stale
|
|
584
|
+
// entries left by other Python versions in the shared user cache) and, more
|
|
585
|
+
// importantly, guarantees a corrupt cached wheel can never install a subtly
|
|
586
|
+
// broken package into a fresh runner venv. Costs a re-download on rebuild,
|
|
587
|
+
// which is rare (only on a deps change or a manual clear).
|
|
588
|
+
acode = await runProc(venvPython(), ["-m", "pip", "install", "--use-feature=truststore", "--no-cache-dir", "--quiet", "--disable-pip-version-check", ...PIP_DEPS], onProgress);
|
|
589
|
+
}
|
|
500
590
|
if (acode !== 0) {
|
|
501
591
|
return {
|
|
502
592
|
ok: false,
|