@melaya/runner 1.0.82 → 1.0.84
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 +47 -4
- package/dist/connection.js +47 -0
- package/dist/pythonEnv.js +13 -5
- package/dist/sharedVendor.d.ts +7 -0
- package/dist/sharedVendor.js +16 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -38,8 +38,12 @@ _EVENT_PREFIX = "MELASSIST "
|
|
|
38
38
|
_IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
|
|
39
39
|
|
|
40
40
|
# Streaming state for the CURRENT turn — the pre_print / post_acting hooks read
|
|
41
|
-
# this to emit delta / tool events keyed to the turn in flight.
|
|
42
|
-
|
|
41
|
+
# this to emit delta / tool events keyed to the turn in flight. `cancel` is the
|
|
42
|
+
# STOP flag: set by the stdin-reader thread, polled by the running turn.
|
|
43
|
+
_stream = {"turnId": "", "lens": {}, "cancel": False}
|
|
44
|
+
|
|
45
|
+
# Sentinel returned by a turn's coroutine when the user pressed STOP.
|
|
46
|
+
_CANCELLED = object()
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
def _emit(turn_id: str, kind: str, **fields) -> None:
|
|
@@ -219,9 +223,24 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
|
|
|
219
223
|
_emit(turn_id, "round", n=1)
|
|
220
224
|
_stream["turnId"] = turn_id
|
|
221
225
|
_stream["lens"] = {}
|
|
226
|
+
_stream["cancel"] = False # fresh turn — clear any stale STOP
|
|
222
227
|
|
|
228
|
+
# Run the agent as a cancellable task and poll the STOP flag (flipped by the
|
|
229
|
+
# stdin-reader thread when the user presses STOP in chat). asyncio.cancel()
|
|
230
|
+
# unwinds the agent loop wherever it is awaiting — including mid phone-command
|
|
231
|
+
# HTTP wait — so the run stops promptly instead of finishing the step first.
|
|
223
232
|
async def _go():
|
|
224
|
-
|
|
233
|
+
task = asyncio.ensure_future(agent(Msg("user", message, "user")))
|
|
234
|
+
while not task.done():
|
|
235
|
+
if _stream.get("cancel"):
|
|
236
|
+
task.cancel()
|
|
237
|
+
try:
|
|
238
|
+
await task
|
|
239
|
+
except BaseException: # CancelledError + any teardown error
|
|
240
|
+
pass
|
|
241
|
+
return _CANCELLED
|
|
242
|
+
await asyncio.sleep(0.12)
|
|
243
|
+
return task.result()
|
|
225
244
|
|
|
226
245
|
try:
|
|
227
246
|
result = asyncio.run(_go())
|
|
@@ -232,6 +251,13 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
|
|
|
232
251
|
_emit(turn_id, "done")
|
|
233
252
|
return
|
|
234
253
|
|
|
254
|
+
if result is _CANCELLED:
|
|
255
|
+
_stream["turnId"] = ""
|
|
256
|
+
_log("turn cancelled by user STOP")
|
|
257
|
+
_emit(turn_id, "text", content="Stopped.")
|
|
258
|
+
_emit(turn_id, "done")
|
|
259
|
+
return
|
|
260
|
+
|
|
235
261
|
_stream["turnId"] = ""
|
|
236
262
|
# Authoritative final answer — the client swaps the streamed plain text for
|
|
237
263
|
# this markdown-rendered version.
|
|
@@ -242,9 +268,26 @@ def _run_turn(agent, turn_id: str, message: str) -> None:
|
|
|
242
268
|
|
|
243
269
|
def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
244
270
|
"""Feed stdin lines into a queue so the main loop can apply an idle timeout
|
|
245
|
-
(blocking readline can't be interrupted portably).
|
|
271
|
+
(blocking readline can't be interrupted portably).
|
|
272
|
+
|
|
273
|
+
STOP is special: a `{"cancel": true}` line must take effect WHILE a turn is
|
|
274
|
+
running, but the main thread is blocked in _run_turn and won't drain the
|
|
275
|
+
queue until it finishes. So handle cancel HERE, on the reader thread — flip
|
|
276
|
+
the shared flag the running turn polls — and never enqueue it as a new turn.
|
|
277
|
+
"""
|
|
246
278
|
try:
|
|
247
279
|
for line in sys.stdin:
|
|
280
|
+
s = line.strip()
|
|
281
|
+
if s:
|
|
282
|
+
try:
|
|
283
|
+
req = json.loads(s)
|
|
284
|
+
except Exception:
|
|
285
|
+
req = None
|
|
286
|
+
if isinstance(req, dict) and req.get("cancel"):
|
|
287
|
+
tid = str(req.get("turnId") or "")
|
|
288
|
+
if not tid or tid == _stream.get("turnId"):
|
|
289
|
+
_stream["cancel"] = True
|
|
290
|
+
continue # consumed — do not queue as a turn
|
|
248
291
|
q.put(line)
|
|
249
292
|
except Exception:
|
|
250
293
|
pass
|
package/dist/connection.js
CHANGED
|
@@ -64,6 +64,30 @@ export async function connect(opts) {
|
|
|
64
64
|
console.log(chalk.yellow(` ⚠ Luma browser bridge failed to start: ${e?.message || e}`));
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
|
+
// Background warm-up of the assistant Python env, once per process, so the
|
|
68
|
+
// first assistant chat spawns near-instantly. No-op on a fresh runner (no
|
|
69
|
+
// shared bundle on disk yet) — the real assistant_start installs it then.
|
|
70
|
+
let _assistantPrewarmed = false;
|
|
71
|
+
const _prewarmAssistantEnv = async () => {
|
|
72
|
+
if (_assistantPrewarmed)
|
|
73
|
+
return;
|
|
74
|
+
_assistantPrewarmed = true;
|
|
75
|
+
try {
|
|
76
|
+
const { getLocalSharedVersion } = await import("./sharedVendor.js");
|
|
77
|
+
const localVersion = getLocalSharedVersion();
|
|
78
|
+
if (!localVersion)
|
|
79
|
+
return; // nothing cached yet — let assistant_start do it
|
|
80
|
+
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
81
|
+
await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
|
|
82
|
+
console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
|
|
83
|
+
if (opts.verbose)
|
|
84
|
+
console.log(chalk.gray(" [assistant prewarm] ready"));
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (opts.verbose)
|
|
88
|
+
console.log(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
|
|
89
|
+
}
|
|
90
|
+
};
|
|
67
91
|
socket.on("connect", async () => {
|
|
68
92
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
69
93
|
// Report detected models
|
|
@@ -83,6 +107,12 @@ export async function connect(opts) {
|
|
|
83
107
|
// effort — failure here means Luma POSTs fall back to aiohttp
|
|
84
108
|
// (which 403s under the bot rule) but reads still work.
|
|
85
109
|
await _ensureLumaBridge();
|
|
110
|
+
// Pre-warm the assistant Python env in the BACKGROUND so the first chat
|
|
111
|
+
// message doesn't pay the venv check + one-time NLTK/cert self-heal inline
|
|
112
|
+
// (the biggest chunk of "◆ Assistant session … takes forever to spawn").
|
|
113
|
+
// We warm with the version already on disk so it hits the cache instead of
|
|
114
|
+
// reinstalling; skipped on a fresh runner (no bundle yet) to avoid churn.
|
|
115
|
+
void _prewarmAssistantEnv();
|
|
86
116
|
console.log(chalk.gray(" Waiting for pipeline runs...\n"));
|
|
87
117
|
});
|
|
88
118
|
socket.on("connect_error", (err) => {
|
|
@@ -936,6 +966,23 @@ export async function connect(opts) {
|
|
|
936
966
|
activeAssistants.delete(String(payload.sessionId));
|
|
937
967
|
}
|
|
938
968
|
});
|
|
969
|
+
// Cooperative cancel: the user pressed STOP in chat. Write a cancel line to the
|
|
970
|
+
// host's stdin — its stdin reader thread flips a flag the running turn's
|
|
971
|
+
// hooks poll, so the agent loop aborts BETWEEN steps (right where the next
|
|
972
|
+
// phone action would fire) WITHOUT killing the warm session. The server-side
|
|
973
|
+
// phone:kill gate independently halts any in-flight phone action, so even a
|
|
974
|
+
// host that predates this line stops driving the phone; this just also stops
|
|
975
|
+
// it burning model tokens and keeps the session warm for the next message.
|
|
976
|
+
socket.on("runner:assistant_cancel", (payload) => {
|
|
977
|
+
const s = activeAssistants.get(String(payload.sessionId || ""));
|
|
978
|
+
if (!s)
|
|
979
|
+
return;
|
|
980
|
+
s.lastActivity = Date.now();
|
|
981
|
+
try {
|
|
982
|
+
s.proc.stdin?.write(JSON.stringify({ turnId: payload.turnId, cancel: true }) + "\n");
|
|
983
|
+
}
|
|
984
|
+
catch { /* best-effort */ }
|
|
985
|
+
});
|
|
939
986
|
// Idle sweep — reap assistant hosts with no turn in ASSISTANT_IDLE_MS.
|
|
940
987
|
setInterval(() => {
|
|
941
988
|
const now = Date.now();
|
package/dist/pythonEnv.js
CHANGED
|
@@ -320,13 +320,21 @@ async function ensureNltkData(onProgress) {
|
|
|
320
320
|
onProgress(`(nltk check exited ${code} — Mode B vector RAG ingest may fail with "Resource 'punkt_tab' not found")`);
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
|
+
// One-time-per-process guard for the cache-hit self-heal (NLTK + cert bundle).
|
|
324
|
+
let _selfHealedThisProcess = false;
|
|
323
325
|
export async function ensurePythonEnv(systemPython, expectedVersion, onProgress = (m) => console.log(chalk.gray(` [venv] ${m}`))) {
|
|
324
326
|
if (venvIsValid(expectedVersion)) {
|
|
325
|
-
// Self-heal: ensure NLTK data
|
|
326
|
-
// says we're up to date
|
|
327
|
-
// that upgraded the runner without invalidating the marker.
|
|
328
|
-
|
|
329
|
-
|
|
327
|
+
// Self-heal: ensure NLTK data + the cert bundle are present even when the
|
|
328
|
+
// venv marker says we're up to date (this is what previously broke for
|
|
329
|
+
// boxes that upgraded the runner without invalidating the marker). Both are
|
|
330
|
+
// stable for the process lifetime, so run them ONCE per runner process — the
|
|
331
|
+
// old code spawned two extra Python processes on EVERY assistant/pipeline
|
|
332
|
+
// start (~0.5-1.5s of pure latency on the assistant-spawn critical path).
|
|
333
|
+
if (!_selfHealedThisProcess) {
|
|
334
|
+
_selfHealedThisProcess = true;
|
|
335
|
+
await ensureNltkData(onProgress);
|
|
336
|
+
await _resolveAndCacheCertBundle(onProgress);
|
|
337
|
+
}
|
|
330
338
|
return { ok: true, pythonPath: venvPython() };
|
|
331
339
|
}
|
|
332
340
|
if (!existsSync(AGENTSCOPE)) {
|
package/dist/sharedVendor.d.ts
CHANGED
|
@@ -7,5 +7,12 @@
|
|
|
7
7
|
* runtime from the server's /api/v1/agents/shared-bundle endpoint,
|
|
8
8
|
* integrity-verified via SHA-256, and cached at ~/.melaya-runner/shared/.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* The shared-bundle version currently installed on disk (from shared-version.txt),
|
|
12
|
+
* or null if none/incomplete. Used to pre-warm the Python env on connect with the
|
|
13
|
+
* SAME version the cache already holds, so the warm-up hits the cache instead of
|
|
14
|
+
* triggering a reinstall.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getLocalSharedVersion(): string | null;
|
|
10
17
|
export declare function getSharedDir(): string;
|
|
11
18
|
export declare function ensureSharedModules(serverUrl: string, expectedVersion: string, runnerToken: string): Promise<void>;
|
package/dist/sharedVendor.js
CHANGED
|
@@ -14,6 +14,22 @@ import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
|
14
14
|
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
15
15
|
const SHARED_DIR = join(CACHE_DIR, "shared");
|
|
16
16
|
const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
|
|
17
|
+
/**
|
|
18
|
+
* The shared-bundle version currently installed on disk (from shared-version.txt),
|
|
19
|
+
* or null if none/incomplete. Used to pre-warm the Python env on connect with the
|
|
20
|
+
* SAME version the cache already holds, so the warm-up hits the cache instead of
|
|
21
|
+
* triggering a reinstall.
|
|
22
|
+
*/
|
|
23
|
+
export function getLocalSharedVersion() {
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(VERSION_FILE) && cacheLooksValid()) {
|
|
26
|
+
const v = readFileSync(VERSION_FILE, "utf-8").trim();
|
|
27
|
+
return v || null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch { /* best-effort */ }
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
17
33
|
export function getSharedDir() {
|
|
18
34
|
// Return the CACHE_DIR root (not shared/ subdir) because the bundle
|
|
19
35
|
// now includes agentscope/ and crews/ alongside shared/. PYTHONPATH
|