@melaya/runner 1.0.104 → 1.0.106
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 +66 -13
- package/dist/connection.js +33 -9
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -72,24 +72,49 @@ def _memory_watermark(agent) -> int:
|
|
|
72
72
|
return 0
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def _sync_ollama_memory_budget(agent) -> None:
|
|
76
|
+
"""P2-6 (persistent host): the Assistant builds ONE agent + BoundedMemory, so an
|
|
77
|
+
OOM num_ctx downgrade that shrinks the ollama context cache would otherwise leave
|
|
78
|
+
the live memory budgeting against the OLD (too-large) window — packing more than
|
|
79
|
+
the GPU can hold and re-OOMing every turn. Re-resolve the budget from the (now
|
|
80
|
+
downgraded) cache each turn and LOWER max_tokens to match. Ratchets DOWN only;
|
|
81
|
+
best-effort + ollama-only (a cloud/CLI provider never touches this)."""
|
|
82
|
+
if (os.environ.get("MEL_ASSISTANT_PROVIDER", "") or "").lower() != "ollama":
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
from shared.runtime.agent_factory import _resolve_memory_budget
|
|
86
|
+
budget = _resolve_memory_budget("ollama", os.environ.get("MEL_ASSISTANT_MODEL", "") or "")
|
|
87
|
+
mem = _agent_memory(agent)
|
|
88
|
+
if mem is not None and hasattr(mem, "max_tokens") and budget > 0:
|
|
89
|
+
cur = int(getattr(mem, "max_tokens", 0) or 0)
|
|
90
|
+
if cur <= 0 or budget < cur:
|
|
91
|
+
mem.max_tokens = budget
|
|
92
|
+
_log(f"ollama memory budget re-synced {cur}->{budget} (post-OOM downgrade)")
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
75
97
|
def _config_hash() -> str:
|
|
76
98
|
"""PR4 config-drift: a stable hash over the config env that determines host
|
|
77
99
|
behaviour, canonicalized IDENTICALLY to the server (runnerNamespace.ts
|
|
78
100
|
_assistantConfigHash): pipe-delimited provider|model|language|connectors|
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
phoneReady, language default 'en', connectors lowercased+sorted, phoneReady
|
|
102
|
+
1/0. Reported in `ready` so the server can detect env divergence.
|
|
103
|
+
|
|
104
|
+
NOTE: hitlMode is DELIBERATELY EXCLUDED (it must match the server byte-for-byte,
|
|
105
|
+
and the server dropped it — it is a PER-TURN parameter carried on every
|
|
106
|
+
assistant_turn frame, so a safe↔autonomous flip takes effect without a reboot).
|
|
107
|
+
Including it here made the host hash NEVER match the server's, so config-drift
|
|
108
|
+
detection was permanently fail-open (a stale-connector host was accepted)."""
|
|
81
109
|
import hashlib
|
|
82
110
|
raw_conn = os.environ.get("MEL_ASSISTANT_CONNECTORS", "") or ""
|
|
83
111
|
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
112
|
phone = "1" if (os.environ.get("MEL_ASSISTANT_PHONE_READY", "") or "") else "0"
|
|
88
113
|
canon = "|".join([
|
|
89
114
|
os.environ.get("MEL_ASSISTANT_PROVIDER", "") or "",
|
|
90
115
|
os.environ.get("MEL_ASSISTANT_MODEL", "") or "",
|
|
91
116
|
os.environ.get("MEL_ASSISTANT_LANGUAGE", "en") or "en",
|
|
92
|
-
connectors,
|
|
117
|
+
connectors, phone,
|
|
93
118
|
])
|
|
94
119
|
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
|
|
95
120
|
|
|
@@ -271,13 +296,25 @@ def _build_agent():
|
|
|
271
296
|
# then ALWAYS require the in-chat approval card (fail-safe).
|
|
272
297
|
if connector_services and not os.environ.get("MEL_ASSISTANT_CONNECTOR_HITL"):
|
|
273
298
|
os.environ["MEL_ASSISTANT_CONNECTOR_HITL"] = "1"
|
|
274
|
-
# Core primitives
|
|
275
|
-
#
|
|
276
|
-
#
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
|
|
299
|
+
# Core primitives: web_search / web_fetch, files, HTTP, data + office utilities,
|
|
300
|
+
# scraping, encoding, SQL, etc. — ALWAYS discoverable via search_tools/
|
|
301
|
+
# activate_tool. The side-effecting ones run on the USER's OWN machine (this
|
|
302
|
+
# runner) and are HITL-gated by the assistant's safe/payments modes, so exposing
|
|
303
|
+
# them for discovery is safe. They stay in the LAZY pool (not pinned) so the
|
|
304
|
+
# ~73-tool family never explodes the active budget.
|
|
305
|
+
#
|
|
306
|
+
# IMPORTANT: `melaya_core` is a CLIENT-SIDE DISPLAY bucket (toolServiceMap.ts),
|
|
307
|
+
# NOT a runtime registry category — the primitives actually live under `tools`,
|
|
308
|
+
# `scraping`, `msoffice`, `database`, … So we include the AUTHORITATIVE runtime
|
|
309
|
+
# category list (CORE_TOOL_CATEGORIES). Passing "melaya_core" matched NOTHING,
|
|
310
|
+
# which is why web_search/web_fetch never appeared in search_tools.
|
|
311
|
+
try:
|
|
312
|
+
from shared.runtime.registry import CORE_TOOL_CATEGORIES as core_categories
|
|
313
|
+
except Exception:
|
|
314
|
+
# Fallback if an older shared bundle predates the constant — hardcode the set.
|
|
315
|
+
core_categories = ["tools", "scraping", "data_utils", "msoffice", "aiml",
|
|
316
|
+
"media", "knowledge", "netutil_tools", "qr_tools",
|
|
317
|
+
"ics_tools"] # excluded (need a connector): video_pipeline, database, devops
|
|
281
318
|
try:
|
|
282
319
|
from shared.orchestration.lazy_registry import build_lazy_toolkit
|
|
283
320
|
# Base tools (melaya_agent + phone) stay active+pinned; core + any selected
|
|
@@ -339,6 +376,18 @@ def _build_agent():
|
|
|
339
376
|
"data — read it from the connector.\n"
|
|
340
377
|
if connector_services else ""
|
|
341
378
|
)
|
|
379
|
+
# Core primitives are ALWAYS in the lazy pool now, so the model must be told
|
|
380
|
+
# they exist (they are not pinned/loaded upfront) — otherwise it concludes "no
|
|
381
|
+
# web-search tool is available" and refuses, exactly the reported failure.
|
|
382
|
+
core_rule = (
|
|
383
|
+
"- You have built-in CORE tools that are NOT loaded upfront: web_search "
|
|
384
|
+
"(live web search) and web_fetch (fetch a URL's content), plus file read/"
|
|
385
|
+
"write, HTTP requests, scraping, data/CSV/Excel utilities, SQL, QR and media. "
|
|
386
|
+
"To use any of them, call search_tools(query=...) (e.g. search_tools(\"web "
|
|
387
|
+
"search\")), then activate_tool(name=\"web_search\"), then call it. Whenever a "
|
|
388
|
+
"question needs live/current web data or a page's contents, use web_search / "
|
|
389
|
+
"web_fetch — do NOT claim you lack a web tool.\n"
|
|
390
|
+
)
|
|
342
391
|
sys_prompt = (
|
|
343
392
|
"You are the Melaya Assistant, the in-app copilot of the Melaya "
|
|
344
393
|
"agent-orchestration platform. You have READ-ONLY tools over the data "
|
|
@@ -351,6 +400,7 @@ def _build_agent():
|
|
|
351
400
|
"supports dimension='pipeline' to find which pipeline cost the most.\n"
|
|
352
401
|
+ phone_rule
|
|
353
402
|
+ connector_rule
|
|
403
|
+
+ core_rule
|
|
354
404
|
+ "- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
|
|
355
405
|
"- If a question is outside the platform, answer normally without tools.\n"
|
|
356
406
|
+ (f"- Answer in the user's language: {language}.\n" if language and language != "en" else "")
|
|
@@ -652,6 +702,9 @@ def main() -> int:
|
|
|
652
702
|
if not message:
|
|
653
703
|
_emit(turn_id, "done")
|
|
654
704
|
continue
|
|
705
|
+
# P2-6: nudge the live memory budget down if a prior turn's OOM downgraded
|
|
706
|
+
# the ollama context (no-op for every other provider / when unchanged).
|
|
707
|
+
_sync_ollama_memory_budget(agent)
|
|
655
708
|
_run_turn(agent, turn_id, message)
|
|
656
709
|
|
|
657
710
|
|
package/dist/connection.js
CHANGED
|
@@ -898,17 +898,34 @@ export async function connect(opts) {
|
|
|
898
898
|
return;
|
|
899
899
|
const generation = Number(payload.generation ?? 0) || 0;
|
|
900
900
|
const emitEv = (ev) => socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", ...ev });
|
|
901
|
-
// A live host already exists — report ready WITH its last-known memory
|
|
902
|
-
// watermark so the server doesn't needlessly re-offer a rehydrate (a bare
|
|
903
|
-
//
|
|
901
|
+
// A live host already exists — normally report ready WITH its last-known memory
|
|
902
|
+
// watermark so the server doesn't needlessly re-offer a rehydrate (a bare ready
|
|
903
|
+
// would read as watermark 0) and NO second spawn. BUT first check config drift:
|
|
904
904
|
const live = activeAssistants.get(sid);
|
|
905
|
-
|
|
906
|
-
// server that restarted (its in-memory session map wiped) can DETECT a host
|
|
907
|
-
// that booted under an older generation and reboot it instead of serving the
|
|
908
|
-
// next turn from stale memory/config. A bare ready hid this.
|
|
905
|
+
const expectedConfig = String(payload.configHash || "");
|
|
909
906
|
if (live) {
|
|
910
|
-
|
|
911
|
-
|
|
907
|
+
// SECURITY (config-drift fail-open fix): the server may have RESTARTED and
|
|
908
|
+
// lost its session map, then sent assistant_start with the CURRENT config
|
|
909
|
+
// (e.g. a connector the user has since removed). If the live host booted under
|
|
910
|
+
// a DIFFERENT config, returning it would serve the removed connector's tools +
|
|
911
|
+
// credentials. Kill the stale host and fall through to spawn a FRESH one under
|
|
912
|
+
// the new config. Same-config → reuse (the fast path). An empty live.configHash
|
|
913
|
+
// (a pre-configHash host) is treated as stale so it can't linger fail-open.
|
|
914
|
+
const drifted = expectedConfig ? (live.configHash !== expectedConfig) : false;
|
|
915
|
+
if (drifted || (expectedConfig && !live.configHash)) {
|
|
916
|
+
console.log(chalk.yellow(` ◆ Assistant host ${sid.slice(0, 10)}… config drift (host=${live.configHash || "∅"} ≠ server=${expectedConfig}) — rebooting`));
|
|
917
|
+
live.killed = true; // suppress its session_closed (see proc.exit)
|
|
918
|
+
activeAssistants.delete(sid); // stop returning it immediately
|
|
919
|
+
try {
|
|
920
|
+
live.proc.kill();
|
|
921
|
+
}
|
|
922
|
+
catch { /* already dead */ } // SIGTERM → Python atexit cleanup
|
|
923
|
+
// fall through to the fresh-boot path below (bootingAssistants guards races)
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
emitEv({ kind: "ready", memoryWatermark: live.memoryWatermark || 0, generation: live.generation, hostInstanceId: live.hostInstanceId, configHash: live.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
912
929
|
}
|
|
913
930
|
// A boot is already in flight for this sid (concurrent start / server restart
|
|
914
931
|
// mid-boot). Drop this duplicate — the in-flight boot will emit `ready`.
|
|
@@ -1066,6 +1083,13 @@ export async function connect(opts) {
|
|
|
1066
1083
|
// would orphan the newcomer and make the next turn "session_not_found".
|
|
1067
1084
|
if (activeAssistants.get(sid) === session)
|
|
1068
1085
|
activeAssistants.delete(sid);
|
|
1086
|
+
// A host we INTENTIONALLY killed for a config-drift reboot must NOT emit
|
|
1087
|
+
// session_closed — that frame would fail the fresh boot's ready waiter
|
|
1088
|
+
// (the server can't pre-mark it superseded here, the kill is runner-side).
|
|
1089
|
+
if (session.killed) {
|
|
1090
|
+
console.log(chalk.gray(` ■ Assistant host ${sid.slice(0, 10)}… replaced (config drift, exit ${code})`));
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1069
1093
|
const detail = code !== 0 && stderrTail.length ? stderrTail.slice(-12).join("\n") : "";
|
|
1070
1094
|
socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", kind: "session_closed", code, detail });
|
|
1071
1095
|
console.log(chalk.gray(` ■ Assistant session ${sid.slice(0, 10)}… closed (exit ${code})`));
|