@melaya/runner 1.0.96 → 1.0.98
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 +112 -6
- package/dist/connection.js +57 -1
- package/package.json +42 -42
package/dist/assistantHost.py
CHANGED
|
@@ -37,6 +37,52 @@ import traceback
|
|
|
37
37
|
_EVENT_PREFIX = "MELASSIST "
|
|
38
38
|
_IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
|
|
39
39
|
|
|
40
|
+
# PR4 rehydration: this host instance id + the DB generation it booted under.
|
|
41
|
+
import uuid as _uuid
|
|
42
|
+
_HOST_ID = _uuid.uuid4().hex
|
|
43
|
+
try:
|
|
44
|
+
_GENERATION = int(os.environ.get("MEL_ASSISTANT_GENERATION", "0") or 0)
|
|
45
|
+
except Exception:
|
|
46
|
+
_GENERATION = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_async(coro):
|
|
50
|
+
"""Run a coroutine to completion from the synchronous main loop (no running
|
|
51
|
+
loop here — turns use their own asyncio.run)."""
|
|
52
|
+
import asyncio
|
|
53
|
+
return asyncio.run(coro)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _agent_memory(agent):
|
|
57
|
+
"""Best-effort handle to the agent's BoundedMemory (for watermark + restore)."""
|
|
58
|
+
try:
|
|
59
|
+
mem = getattr(agent, "memory", None)
|
|
60
|
+
if mem is not None and hasattr(mem, "memory_watermark"):
|
|
61
|
+
return mem
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _memory_watermark(agent) -> int:
|
|
68
|
+
mem = _agent_memory(agent)
|
|
69
|
+
try:
|
|
70
|
+
return int(mem.memory_watermark()) if mem is not None else 0
|
|
71
|
+
except Exception:
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _render_summary_text(summary) -> str:
|
|
76
|
+
"""Render a server AssistantSummary ({facts, provenance}) to a data-only text
|
|
77
|
+
block for the compressed-summary slot. Never policy — reference only."""
|
|
78
|
+
if not isinstance(summary, dict):
|
|
79
|
+
return ""
|
|
80
|
+
facts = summary.get("facts") or []
|
|
81
|
+
lines = [f"- {str(f)[:240]}" for f in facts if str(f).strip()]
|
|
82
|
+
if not lines:
|
|
83
|
+
return ""
|
|
84
|
+
return "[Earlier conversation summary — reference only]\n" + "\n".join(lines)
|
|
85
|
+
|
|
40
86
|
# Streaming state for the CURRENT turn — the pre_print / post_acting hooks read
|
|
41
87
|
# this to emit delta / tool events keyed to the turn in flight. `cancel` is the
|
|
42
88
|
# STOP flag: set by the stdin-reader thread, polled by the running turn.
|
|
@@ -177,10 +223,15 @@ def _build_agent():
|
|
|
177
223
|
model = os.environ.get("MEL_ASSISTANT_MODEL", "") or None
|
|
178
224
|
language = os.environ.get("MEL_ASSISTANT_LANGUAGE", "en")
|
|
179
225
|
|
|
180
|
-
# Phone control
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
|
|
226
|
+
# Phone control unlocks on the native mobile app surface OR whenever the user
|
|
227
|
+
# has a PAIRED phone (MEL_ASSISTANT_PHONE_READY, set by the server from
|
|
228
|
+
# getPhonePresence). A desktop / web chat drives the paired phone REMOTELY
|
|
229
|
+
# through the same Redis queue, so it must not be surface-gated — without this,
|
|
230
|
+
# desktop chats had zero phone_* tools and the model refused phone tasks.
|
|
231
|
+
phone_enabled = (
|
|
232
|
+
os.environ.get("MEL_ASSISTANT_SURFACE", "") == "mobile-native"
|
|
233
|
+
or os.environ.get("MEL_ASSISTANT_PHONE_READY", "") in ("1", "true", "True")
|
|
234
|
+
)
|
|
184
235
|
|
|
185
236
|
# Read-only platform tools (melaya_agent); phone control (Device Control) on
|
|
186
237
|
# mobile only. These POST to /api/v1/private/assistant-tool + /phone/command
|
|
@@ -201,10 +252,20 @@ def _build_agent():
|
|
|
201
252
|
try:
|
|
202
253
|
if connector_services:
|
|
203
254
|
from shared.orchestration.lazy_registry import build_lazy_toolkit
|
|
255
|
+
# Phone tools must ALWAYS be fully passed, never scoped: a phone task
|
|
256
|
+
# can't afford search_tools/activate_tool round-trips per tap (that is
|
|
257
|
+
# the "why did it restrict the tools + it's slow" regression). The lazy
|
|
258
|
+
# toolkit pins active_categories only UP TO `budget` (default 25), so
|
|
259
|
+
# phone (~23) + melaya_agent got bumped into the deferred pool. When
|
|
260
|
+
# phone is enabled, widen the budget so the WHOLE phone + base set stays
|
|
261
|
+
# active/pinned; the connectors still lazy-defer beyond that.
|
|
262
|
+
_budget = int(os.environ.get("MEL_LAZY_BUDGET", "25"))
|
|
263
|
+
if phone_enabled:
|
|
264
|
+
_budget = max(_budget, 64)
|
|
204
265
|
toolkit = build_lazy_toolkit(
|
|
205
266
|
active_categories=categories,
|
|
206
267
|
include_categories=categories + connector_services,
|
|
207
|
-
budget=
|
|
268
|
+
budget=_budget,
|
|
208
269
|
)
|
|
209
270
|
else:
|
|
210
271
|
toolkit = build_toolkit(categories=categories)
|
|
@@ -487,7 +548,20 @@ def main() -> int:
|
|
|
487
548
|
_emit("", "error", message=f"assistant host failed to start: {exc}")
|
|
488
549
|
return 1
|
|
489
550
|
|
|
490
|
-
|
|
551
|
+
# PR4: report memory watermark + host identity + generation so the server can
|
|
552
|
+
# decide whether to offer a generation-bound rehydrate snapshot.
|
|
553
|
+
# Surface context compaction to the chat UI on the RUNNER path too: the
|
|
554
|
+
# BoundedMemory fires pre_compact / post_compact around a summarise pass.
|
|
555
|
+
try:
|
|
556
|
+
from shared.orchestration.hooks import pipeline_hooks
|
|
557
|
+
pipeline_hooks.register("pre_compact", lambda ctx: _emit(str(_stream.get("turnId") or ""), "compacting"))
|
|
558
|
+
pipeline_hooks.register("post_compact", lambda ctx: _emit(
|
|
559
|
+
str(_stream.get("turnId") or ""), "compacted",
|
|
560
|
+
dropped=int((ctx or {}).get("messages_compressed") or 0)))
|
|
561
|
+
except Exception:
|
|
562
|
+
_log("compaction hooks unavailable (non-fatal)")
|
|
563
|
+
|
|
564
|
+
_emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION)
|
|
491
565
|
_log("ready")
|
|
492
566
|
|
|
493
567
|
q: "queue.Queue[str]" = queue.Queue()
|
|
@@ -510,6 +584,38 @@ def main() -> int:
|
|
|
510
584
|
except Exception:
|
|
511
585
|
_log(f"bad stdin line (not json): {line[:120]}")
|
|
512
586
|
continue
|
|
587
|
+
# PR4: generation-bound rehydrate. Seed a FRESH host's memory from the
|
|
588
|
+
# server snapshot (task pin + summary + recent tail) via restoreSnapshot —
|
|
589
|
+
# watermark-guarded so a re-offer never double-seeds. Never memory.add().
|
|
590
|
+
if isinstance(req, dict) and req.get("kind") == "restore":
|
|
591
|
+
try:
|
|
592
|
+
gen = int(req.get("generation") or 0)
|
|
593
|
+
except Exception:
|
|
594
|
+
gen = 0
|
|
595
|
+
if _GENERATION and gen and gen != _GENERATION:
|
|
596
|
+
_log(f"restore for stale generation {gen} (mine={_GENERATION}) — ignored")
|
|
597
|
+
continue
|
|
598
|
+
mem = _agent_memory(agent)
|
|
599
|
+
if mem is None:
|
|
600
|
+
continue
|
|
601
|
+
try:
|
|
602
|
+
from agentscope.message import Msg as _Msg
|
|
603
|
+
recent = req.get("recent") or []
|
|
604
|
+
recent_msgs = [
|
|
605
|
+
_Msg(str(m.get("role") or "user"), str(m.get("content") or ""), str(m.get("role") or "user"))
|
|
606
|
+
for m in recent if isinstance(m, dict) and str(m.get("content") or "").strip()
|
|
607
|
+
]
|
|
608
|
+
applied = _run_async(mem.restore_snapshot(
|
|
609
|
+
task_pin=str(req.get("taskPin") or ""),
|
|
610
|
+
summary_text=_render_summary_text(req.get("summary")),
|
|
611
|
+
recent_msgs=recent_msgs,
|
|
612
|
+
watermark=int(req.get("watermark") or 0),
|
|
613
|
+
))
|
|
614
|
+
_emit("", "rehydrated", applied=bool(applied), watermark=_memory_watermark(agent))
|
|
615
|
+
_log(f"restore applied={applied} watermark={_memory_watermark(agent)}")
|
|
616
|
+
except Exception:
|
|
617
|
+
_log("restore failed:\n" + traceback.format_exc())
|
|
618
|
+
continue
|
|
513
619
|
turn_id = str(req.get("turnId") or "")
|
|
514
620
|
message = str(req.get("message") or "")
|
|
515
621
|
# Per-turn autonomy mode: the TS side carries an updated `hitl_mode`
|
package/dist/connection.js
CHANGED
|
@@ -29,6 +29,13 @@ import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
|
29
29
|
const HEARTBEAT_INTERVAL = 30_000;
|
|
30
30
|
const activeProcesses = new Map();
|
|
31
31
|
const activeAssistants = new Map();
|
|
32
|
+
// PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
|
|
33
|
+
// has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
|
|
34
|
+
// activeAssistants.has() check and the .set(). A second assistant_start in that
|
|
35
|
+
// window — a concurrent SSE, or a RESTARTED server re-emitting start while the
|
|
36
|
+
// host is still booting — would double-spawn two python hosts. Reserving the sid
|
|
37
|
+
// SYNCHRONOUSLY here closes that window.
|
|
38
|
+
const bootingAssistants = new Set();
|
|
32
39
|
const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
|
|
33
40
|
export async function connect(opts) {
|
|
34
41
|
const spinner = ora("Connecting to Melaya...").start();
|
|
@@ -876,11 +883,19 @@ export async function connect(opts) {
|
|
|
876
883
|
const sid = String(payload.sessionId || "");
|
|
877
884
|
if (!sid)
|
|
878
885
|
return;
|
|
886
|
+
const generation = Number(payload.generation ?? 0) || 0;
|
|
879
887
|
const emitEv = (ev) => socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", ...ev });
|
|
888
|
+
// A live host already exists — report ready (with its watermark so the server
|
|
889
|
+
// can decide whether to re-offer a rehydrate). No second spawn.
|
|
880
890
|
if (activeAssistants.has(sid)) {
|
|
881
891
|
emitEv({ kind: "ready" });
|
|
882
892
|
return;
|
|
883
893
|
}
|
|
894
|
+
// A boot is already in flight for this sid (concurrent start / server restart
|
|
895
|
+
// mid-boot). Drop this duplicate — the in-flight boot will emit `ready`.
|
|
896
|
+
if (bootingAssistants.has(sid))
|
|
897
|
+
return;
|
|
898
|
+
bootingAssistants.add(sid); // SYNCHRONOUS reservation before any await
|
|
884
899
|
try {
|
|
885
900
|
const sharedVersion = String(payload.sharedVersion ?? "latest");
|
|
886
901
|
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
@@ -932,6 +947,10 @@ export async function connect(opts) {
|
|
|
932
947
|
MEL_ASSISTANT_MODEL: String(payload.model || ""),
|
|
933
948
|
MEL_ASSISTANT_LANGUAGE: String(payload.language || "en"),
|
|
934
949
|
MEL_ASSISTANT_SURFACE: String(payload.surface || ""),
|
|
950
|
+
// Phone toolkit unlocks when the user has a paired phone, on ANY surface
|
|
951
|
+
// (desktop drives the paired phone remotely). The host ORs this with the
|
|
952
|
+
// mobile-native surface check. Empty ⇒ not paired ⇒ no phone tools.
|
|
953
|
+
MEL_ASSISTANT_PHONE_READY: payload.phoneReady ? "1" : "",
|
|
935
954
|
// Connector tool sets the user enabled for this chat (comma-joined service
|
|
936
955
|
// ids). The host seeds a lazy toolkit from these so ANY connector's tools
|
|
937
956
|
// are reachable without exploding context.
|
|
@@ -942,6 +961,10 @@ export async function connect(opts) {
|
|
|
942
961
|
// reads this at start; each turn's frame can flip it (see assistant_turn).
|
|
943
962
|
// Fail-safe: unknown ⇒ "safe".
|
|
944
963
|
MEL_ASSISTANT_HITL_MODE: (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe",
|
|
964
|
+
// PR4: the DB-authoritative generation this host boots under. The host
|
|
965
|
+
// echoes it in `ready` and stamps events; an older boot self-terminates
|
|
966
|
+
// on a generation mismatch.
|
|
967
|
+
MEL_ASSISTANT_GENERATION: String(generation),
|
|
945
968
|
// Run context for phone-driving assistant turns. The assistant host has
|
|
946
969
|
// no pipeline MEL_RUN_ID, so without this phone.py._cmd omits `run_id`,
|
|
947
970
|
// the server's arm block (phone.ts, `if (runId)`) never fires, and the
|
|
@@ -964,7 +987,7 @@ export async function connect(opts) {
|
|
|
964
987
|
...(payload.credentials || {}),
|
|
965
988
|
};
|
|
966
989
|
const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
967
|
-
const session = { proc, lastActivity: Date.now(), stdoutBuf: "" };
|
|
990
|
+
const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation };
|
|
968
991
|
activeAssistants.set(sid, session);
|
|
969
992
|
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
|
|
970
993
|
proc.stdout?.on("data", (data) => {
|
|
@@ -1008,6 +1031,39 @@ export async function connect(opts) {
|
|
|
1008
1031
|
catch (e) {
|
|
1009
1032
|
emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
|
|
1010
1033
|
}
|
|
1034
|
+
finally {
|
|
1035
|
+
// Release the boot reservation on EVERY path (spawned, or errored/returned
|
|
1036
|
+
// early). By now the host is either in activeAssistants or dead.
|
|
1037
|
+
bootingAssistants.delete(sid);
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
// PR4: generation-bound rehydrate. The server sends this AFTER `ready`, only
|
|
1041
|
+
// when the DB watermark is newer than the host's. Forward it to the host as a
|
|
1042
|
+
// `restore` frame; the host applies it via restoreSnapshot ONLY if its own
|
|
1043
|
+
// memory watermark is older (idempotent — a re-offer never double-seeds).
|
|
1044
|
+
socket.on("runner:assistant_rehydrate", (payload) => {
|
|
1045
|
+
const sid = String(payload.sessionId || "");
|
|
1046
|
+
const s = activeAssistants.get(sid);
|
|
1047
|
+
if (!s)
|
|
1048
|
+
return;
|
|
1049
|
+
// Bind to the boot generation — a frame for an older/newer generation than the
|
|
1050
|
+
// live host is dropped (the host also re-checks).
|
|
1051
|
+
const gen = Number(payload.generation ?? 0) || 0;
|
|
1052
|
+
if (gen && s.generation && gen !== s.generation)
|
|
1053
|
+
return;
|
|
1054
|
+
s.lastActivity = Date.now();
|
|
1055
|
+
try {
|
|
1056
|
+
s.proc.stdin?.write(JSON.stringify({
|
|
1057
|
+
kind: "restore",
|
|
1058
|
+
generation: gen,
|
|
1059
|
+
watermark: Number(payload.watermark ?? 0) || 0,
|
|
1060
|
+
taskPin: payload.taskPin ?? null,
|
|
1061
|
+
summary: payload.summary ?? null,
|
|
1062
|
+
summaryUptoSeq: Number(payload.summaryUptoSeq ?? 0) || 0,
|
|
1063
|
+
recent: Array.isArray(payload.recent) ? payload.recent : [],
|
|
1064
|
+
}) + "\n");
|
|
1065
|
+
}
|
|
1066
|
+
catch { /* best-effort — host stays as-is on a write failure */ }
|
|
1011
1067
|
});
|
|
1012
1068
|
socket.on("runner:assistant_turn", (payload) => {
|
|
1013
1069
|
const s = activeAssistants.get(String(payload.sessionId || ""));
|
package/package.json
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"private": false,
|
|
7
|
-
"type": "module",
|
|
8
|
-
"bin": {
|
|
9
|
-
"melaya-runner": "dist/cli.js"
|
|
10
|
-
},
|
|
11
|
-
"main": "dist/index.js",
|
|
12
|
-
"files": [
|
|
13
|
-
"dist/**/*.js",
|
|
14
|
-
"dist/**/*.d.ts",
|
|
15
|
-
"dist/**/*.py",
|
|
16
|
-
"localRagIngest.py",
|
|
17
|
-
"localRagRetrieve.py",
|
|
18
|
-
"nltk_data/**",
|
|
19
|
-
"README.md"
|
|
20
|
-
],
|
|
21
|
-
"scripts": {
|
|
22
|
-
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
23
|
-
"prepublishOnly": "npm run build"
|
|
24
|
-
},
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"chalk": "^5.3.0",
|
|
27
|
-
"commander": "^12.0.0",
|
|
28
|
-
"ora": "^8.0.0",
|
|
29
|
-
"playwright": "^1.47.0",
|
|
30
|
-
"socket.io-client": "^4.8.0"
|
|
31
|
-
},
|
|
32
|
-
"devDependencies": {
|
|
33
|
-
"@types/node": "^20.0.0",
|
|
34
|
-
"typescript": "^5.5.0"
|
|
35
|
-
},
|
|
36
|
-
"engines": {
|
|
37
|
-
"node": ">=18"
|
|
38
|
-
},
|
|
39
|
-
"publishConfig": {
|
|
40
|
-
"access": "public"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@melaya/runner",
|
|
3
|
+
"version": "1.0.98",
|
|
4
|
+
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"melaya-runner": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/**/*.js",
|
|
14
|
+
"dist/**/*.d.ts",
|
|
15
|
+
"dist/**/*.py",
|
|
16
|
+
"localRagIngest.py",
|
|
17
|
+
"localRagRetrieve.py",
|
|
18
|
+
"nltk_data/**",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"chalk": "^5.3.0",
|
|
27
|
+
"commander": "^12.0.0",
|
|
28
|
+
"ora": "^8.0.0",
|
|
29
|
+
"playwright": "^1.47.0",
|
|
30
|
+
"socket.io-client": "^4.8.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^20.0.0",
|
|
34
|
+
"typescript": "^5.5.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|