@melaya/runner 1.0.98 → 1.0.101
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 +25 -2
- package/dist/connection.js +31 -9
- package/dist/modelLoader.js +21 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -72,6 +72,28 @@ def _memory_watermark(agent) -> int:
|
|
|
72
72
|
return 0
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def _config_hash() -> str:
|
|
76
|
+
"""PR4 config-drift: a stable hash over the config env that determines host
|
|
77
|
+
behaviour, canonicalized IDENTICALLY to the server (runnerNamespace.ts
|
|
78
|
+
_assistantConfigHash): pipe-delimited provider|model|language|connectors|
|
|
79
|
+
hitl|phoneReady, language default 'en', connectors lowercased+sorted,
|
|
80
|
+
phoneReady 1/0. Reported in `ready` so the server can detect env divergence."""
|
|
81
|
+
import hashlib
|
|
82
|
+
raw_conn = os.environ.get("MEL_ASSISTANT_CONNECTORS", "") or ""
|
|
83
|
+
connectors = ",".join(sorted(c.lower() for c in raw_conn.split(",") if c.strip()))
|
|
84
|
+
hitl = os.environ.get("MEL_ASSISTANT_HITL_MODE", "safe") or "safe"
|
|
85
|
+
if hitl not in ("safe", "autonomous", "payments_only"):
|
|
86
|
+
hitl = "safe"
|
|
87
|
+
phone = "1" if (os.environ.get("MEL_ASSISTANT_PHONE_READY", "") or "") else "0"
|
|
88
|
+
canon = "|".join([
|
|
89
|
+
os.environ.get("MEL_ASSISTANT_PROVIDER", "") or "",
|
|
90
|
+
os.environ.get("MEL_ASSISTANT_MODEL", "") or "",
|
|
91
|
+
os.environ.get("MEL_ASSISTANT_LANGUAGE", "en") or "en",
|
|
92
|
+
connectors, hitl, phone,
|
|
93
|
+
])
|
|
94
|
+
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
|
|
95
|
+
|
|
96
|
+
|
|
75
97
|
def _render_summary_text(summary) -> str:
|
|
76
98
|
"""Render a server AssistantSummary ({facts, provenance}) to a data-only text
|
|
77
99
|
block for the compressed-summary slot. Never policy — reference only."""
|
|
@@ -561,7 +583,7 @@ def main() -> int:
|
|
|
561
583
|
except Exception:
|
|
562
584
|
_log("compaction hooks unavailable (non-fatal)")
|
|
563
585
|
|
|
564
|
-
_emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION)
|
|
586
|
+
_emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
|
|
565
587
|
_log("ready")
|
|
566
588
|
|
|
567
589
|
q: "queue.Queue[str]" = queue.Queue()
|
|
@@ -592,7 +614,8 @@ def main() -> int:
|
|
|
592
614
|
gen = int(req.get("generation") or 0)
|
|
593
615
|
except Exception:
|
|
594
616
|
gen = 0
|
|
595
|
-
|
|
617
|
+
# Strict: gen 0 is a real generation (pre-migration), not a wildcard.
|
|
618
|
+
if gen != _GENERATION:
|
|
596
619
|
_log(f"restore for stale generation {gen} (mine={_GENERATION}) — ignored")
|
|
597
620
|
continue
|
|
598
621
|
mem = _agent_memory(agent)
|
package/dist/connection.js
CHANGED
|
@@ -885,10 +885,16 @@ export async function connect(opts) {
|
|
|
885
885
|
return;
|
|
886
886
|
const generation = Number(payload.generation ?? 0) || 0;
|
|
887
887
|
const emitEv = (ev) => socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", ...ev });
|
|
888
|
-
// A live host already exists — report ready
|
|
889
|
-
//
|
|
890
|
-
|
|
891
|
-
|
|
888
|
+
// A live host already exists — report ready WITH its last-known memory
|
|
889
|
+
// watermark so the server doesn't needlessly re-offer a rehydrate (a bare
|
|
890
|
+
// ready would read as watermark 0). No second spawn.
|
|
891
|
+
const live = activeAssistants.get(sid);
|
|
892
|
+
// Report the live host's ACTUAL boot generation (not just its watermark) so a
|
|
893
|
+
// server that restarted (its in-memory session map wiped) can DETECT a host
|
|
894
|
+
// that booted under an older generation and reboot it instead of serving the
|
|
895
|
+
// next turn from stale memory/config. A bare ready hid this.
|
|
896
|
+
if (live) {
|
|
897
|
+
emitEv({ kind: "ready", memoryWatermark: live.memoryWatermark || 0, generation: live.generation });
|
|
892
898
|
return;
|
|
893
899
|
}
|
|
894
900
|
// A boot is already in flight for this sid (concurrent start / server restart
|
|
@@ -987,7 +993,7 @@ export async function connect(opts) {
|
|
|
987
993
|
...(payload.credentials || {}),
|
|
988
994
|
};
|
|
989
995
|
const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
990
|
-
const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation };
|
|
996
|
+
const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation, memoryWatermark: 0 };
|
|
991
997
|
activeAssistants.set(sid, session);
|
|
992
998
|
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
|
|
993
999
|
proc.stdout?.on("data", (data) => {
|
|
@@ -999,7 +1005,13 @@ export async function connect(opts) {
|
|
|
999
1005
|
const t = line.trim();
|
|
1000
1006
|
if (t.startsWith("MELASSIST ")) {
|
|
1001
1007
|
try {
|
|
1002
|
-
|
|
1008
|
+
const parsed = JSON.parse(t.slice("MELASSIST ".length));
|
|
1009
|
+
// Track the host's latest memory watermark (ready / rehydrated
|
|
1010
|
+
// frames report it) so a re-`start` of the live host reports a
|
|
1011
|
+
// truthful watermark and the server skips a needless rehydrate.
|
|
1012
|
+
if (typeof parsed?.memoryWatermark === "number")
|
|
1013
|
+
session.memoryWatermark = parsed.memoryWatermark;
|
|
1014
|
+
socket.emit("runner:assistant_event", { sessionId: sid, ...parsed });
|
|
1003
1015
|
}
|
|
1004
1016
|
catch { /* skip malformed */ }
|
|
1005
1017
|
}
|
|
@@ -1046,10 +1058,11 @@ export async function connect(opts) {
|
|
|
1046
1058
|
const s = activeAssistants.get(sid);
|
|
1047
1059
|
if (!s)
|
|
1048
1060
|
return;
|
|
1049
|
-
// Bind to the boot generation — a frame
|
|
1050
|
-
// live host is dropped (the host also re-checks).
|
|
1061
|
+
// Bind to the boot generation — a frame whose generation differs from the
|
|
1062
|
+
// live host's is dropped (the host also re-checks). Strict equality: gen 0 is
|
|
1063
|
+
// a real generation (pre-migration conversations), not a wildcard.
|
|
1051
1064
|
const gen = Number(payload.generation ?? 0) || 0;
|
|
1052
|
-
if (gen
|
|
1065
|
+
if (gen !== s.generation)
|
|
1053
1066
|
return;
|
|
1054
1067
|
s.lastActivity = Date.now();
|
|
1055
1068
|
try {
|
|
@@ -1071,6 +1084,15 @@ export async function connect(opts) {
|
|
|
1071
1084
|
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: "session_not_found" });
|
|
1072
1085
|
return;
|
|
1073
1086
|
}
|
|
1087
|
+
// Generation gate (defense-in-depth): refuse to serve a turn stamped with a
|
|
1088
|
+
// generation that doesn't match this host's boot generation — the server has
|
|
1089
|
+
// moved on (restart / takeover) and this host is stale. session_not_found
|
|
1090
|
+
// triggers the server's clean re-boot path. Backward-compatible: a turn with
|
|
1091
|
+
// no generation (older server) is served as before.
|
|
1092
|
+
if (payload.generation != null && Number(payload.generation) !== s.generation) {
|
|
1093
|
+
socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: "session_not_found" });
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1074
1096
|
s.lastActivity = Date.now();
|
|
1075
1097
|
// Per-turn hitl_mode: the host applies it to os.environ["MEL_ASSISTANT_HITL_MODE"]
|
|
1076
1098
|
// (mirrored to MEL_HITL_MODE) BEFORE running the turn, so a mid-session flip
|
package/dist/modelLoader.js
CHANGED
|
@@ -378,6 +378,27 @@ export async function preflightOllama(modelName, onProgress = () => { }) {
|
|
|
378
378
|
};
|
|
379
379
|
}
|
|
380
380
|
const profile = classifyModel(modelName);
|
|
381
|
+
// PR5: read the REAL trained context via /api/show and expose it as
|
|
382
|
+
// contextTokens (clamped to the operator cap, mirroring the Python
|
|
383
|
+
// detect_ollama_context). connection.ts forwards this as
|
|
384
|
+
// MEL_MODEL_CONTEXT_TOKENS so the TS profile matches the Python-detected
|
|
385
|
+
// window (the Python side self-detects too, so this is belt-and-suspenders).
|
|
386
|
+
try {
|
|
387
|
+
const cap = Number(process.env.MEL_OLLAMA_MAX_CTX || 16384) || 16384;
|
|
388
|
+
const show = await fetchWithTimeout(`${OLLAMA_BASE}/api/show`, {
|
|
389
|
+
timeoutMs: 3_000, method: "POST", body: JSON.stringify({ model: modelName }),
|
|
390
|
+
headers: { "content-type": "application/json" },
|
|
391
|
+
});
|
|
392
|
+
if (show.ok) {
|
|
393
|
+
const sj = await show.json();
|
|
394
|
+
const info = sj.model_info || {};
|
|
395
|
+
const key = Object.keys(info).find((k) => k.endsWith(".context_length"));
|
|
396
|
+
const trained = key ? Number(info[key]) : 0;
|
|
397
|
+
if (trained > 0)
|
|
398
|
+
profile.contextTokens = Math.max(2048, Math.min(trained, cap));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch { /* best-effort — Python self-detects if this is missing */ }
|
|
381
402
|
if (profile.tier !== "agentic-capable") {
|
|
382
403
|
onProgress(`⚠ ${modelName} classified ${profile.tier} (${profile.reason})`);
|
|
383
404
|
}
|