@melaya/runner 1.0.24 → 1.0.27
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/connection.js +150 -5
- package/dist/modelLoader.d.ts +42 -0
- package/dist/modelLoader.js +225 -0
- package/dist/pythonEnv.d.ts +17 -0
- package/dist/pythonEnv.js +112 -0
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -104,6 +104,58 @@ export async function connect(opts) {
|
|
|
104
104
|
}
|
|
105
105
|
// Ensure shared modules are cached
|
|
106
106
|
await ensureSharedModules(opts.serverUrl, payload.sharedVersion);
|
|
107
|
+
// Ensure the dedicated venv at ~/.melaya-runner/venv/ has
|
|
108
|
+
// agentscope + its transitive deps installed. Without this, the
|
|
109
|
+
// operator's system python3 typically doesn't have shortuuid /
|
|
110
|
+
// anthropic / openai / opentelemetry / etc. pre-installed and
|
|
111
|
+
// every pipeline run crashes during `import agentscope` with a
|
|
112
|
+
// ModuleNotFoundError. The venv is created lazily on first run
|
|
113
|
+
// and reused until sharedVersion changes.
|
|
114
|
+
const { ensurePythonEnv } = await import("./pythonEnv.js");
|
|
115
|
+
const envResult = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion, (msg) => {
|
|
116
|
+
console.log(chalk.gray(` [venv] ${msg}`));
|
|
117
|
+
// Surface bootstrap progress to the FE so the launching
|
|
118
|
+
// animation reflects the real state instead of staying on a
|
|
119
|
+
// generic spinner during the 1-2 min first-time install.
|
|
120
|
+
socket.emit("runner:event", {
|
|
121
|
+
runId: payload.runId,
|
|
122
|
+
payload: {
|
|
123
|
+
event_type: "pipeline_phase",
|
|
124
|
+
run_id: payload.runId,
|
|
125
|
+
project: payload.project,
|
|
126
|
+
step: 1,
|
|
127
|
+
total: 5,
|
|
128
|
+
label: `venv: ${msg}`,
|
|
129
|
+
status: "started",
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
if (!envResult.ok) {
|
|
134
|
+
console.log(chalk.red(` ✗ python venv bootstrap failed: ${envResult.reason}`));
|
|
135
|
+
socket.emit("runner:event", {
|
|
136
|
+
runId: payload.runId,
|
|
137
|
+
payload: {
|
|
138
|
+
event_type: "agent_message",
|
|
139
|
+
run_id: payload.runId,
|
|
140
|
+
project: payload.project,
|
|
141
|
+
replyId: `venv-${payload.runId}`,
|
|
142
|
+
replyName: "Runner",
|
|
143
|
+
replyRole: "system",
|
|
144
|
+
msg: {
|
|
145
|
+
id: `venv-${payload.runId}`,
|
|
146
|
+
name: "Runner",
|
|
147
|
+
role: "system",
|
|
148
|
+
content: [{ type: "text", text: `Python env bootstrap failed: ${envResult.reason}` }],
|
|
149
|
+
metadata: {},
|
|
150
|
+
timestamp: new Date().toISOString(),
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Replace the system python with the venv python for THIS spawn.
|
|
158
|
+
const pipelinePython = envResult.pythonPath;
|
|
107
159
|
// Register run in DB + set active project for event relay
|
|
108
160
|
setActiveProject(payload.project);
|
|
109
161
|
socket.emit("runner:registerRun", {
|
|
@@ -116,6 +168,58 @@ export async function connect(opts) {
|
|
|
116
168
|
mkdirSync(runDir, { recursive: true });
|
|
117
169
|
writeFileSync(join(runDir, "main.py"), payload.mainPyContent, "utf-8");
|
|
118
170
|
writeFileSync(join(runDir, "config.json"), payload.configJson, "utf-8");
|
|
171
|
+
// Local-model preflight: ensure LM Studio / Ollama have the
|
|
172
|
+
// pipeline's model loaded BEFORE we spawn the Python subprocess.
|
|
173
|
+
// Without this, the subprocess hangs on its first chat completion
|
|
174
|
+
// call (LM Studio JIT-loads transparently, can take 60-120s with
|
|
175
|
+
// zero progress feedback). Pipeline run d5ead8b8655b47fa hit this
|
|
176
|
+
// — only the `format openai` span landed, no `chat …` span ever
|
|
177
|
+
// arrived because LM Studio was still loading the qwen weights.
|
|
178
|
+
const { preflightModel } = await import("./modelLoader.js");
|
|
179
|
+
const onPreflightProgress = (msg) => {
|
|
180
|
+
console.log(chalk.gray(` [model] ${msg}`));
|
|
181
|
+
// Surface to the FE via a pipeline_phase event so the launching
|
|
182
|
+
// animation reflects "Loading qwen3-vl-8b…" instead of staying
|
|
183
|
+
// on a generic spinner.
|
|
184
|
+
socket.emit("runner:event", {
|
|
185
|
+
runId: payload.runId,
|
|
186
|
+
payload: {
|
|
187
|
+
event_type: "pipeline_phase",
|
|
188
|
+
run_id: payload.runId,
|
|
189
|
+
project: payload.project,
|
|
190
|
+
step: 1,
|
|
191
|
+
total: 5,
|
|
192
|
+
label: msg,
|
|
193
|
+
status: "started",
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
const preflight = await preflightModel(payload.configJson, onPreflightProgress);
|
|
198
|
+
if (!preflight.ok) {
|
|
199
|
+
const detail = `${preflight.reason ?? "unknown"}${preflight.hint ? ` — ${preflight.hint}` : ""}`;
|
|
200
|
+
console.log(chalk.red(` ✗ model preflight failed: ${detail}`));
|
|
201
|
+
socket.emit("runner:event", {
|
|
202
|
+
runId: payload.runId,
|
|
203
|
+
payload: {
|
|
204
|
+
event_type: "agent_message",
|
|
205
|
+
run_id: payload.runId,
|
|
206
|
+
project: payload.project,
|
|
207
|
+
replyId: `preflight-${payload.runId}`,
|
|
208
|
+
replyName: "Runner",
|
|
209
|
+
replyRole: "system",
|
|
210
|
+
msg: {
|
|
211
|
+
id: `preflight-${payload.runId}`,
|
|
212
|
+
name: "Runner",
|
|
213
|
+
role: "system",
|
|
214
|
+
content: [{ type: "text", text: `Model preflight failed: ${detail}` }],
|
|
215
|
+
metadata: {},
|
|
216
|
+
timestamp: new Date().toISOString(),
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
119
223
|
// Build env vars for the subprocess
|
|
120
224
|
const relayUrl = `http://127.0.0.1:${relay.port}/events/relay/${relay.nonce}`;
|
|
121
225
|
const sharedDir = getSharedDir();
|
|
@@ -147,26 +251,67 @@ export async function connect(opts) {
|
|
|
147
251
|
}
|
|
148
252
|
: {}),
|
|
149
253
|
};
|
|
150
|
-
// Spawn Python subprocess
|
|
151
|
-
const proc = spawn(
|
|
254
|
+
// Spawn Python subprocess (uses the bootstrapped venv python).
|
|
255
|
+
const proc = spawn(pipelinePython, ["-u", join(runDir, "main.py")], {
|
|
152
256
|
cwd: runDir,
|
|
153
257
|
env,
|
|
154
258
|
stdio: ["ignore", "pipe", "pipe"],
|
|
155
259
|
});
|
|
156
260
|
activeProcesses.set(payload.runId, proc);
|
|
261
|
+
// Ring buffer of recent stderr — surfaced to the FE on non-zero
|
|
262
|
+
// exit so the operator sees the actual Python traceback in the
|
|
263
|
+
// chat panel instead of a silent "failed" with no context.
|
|
264
|
+
// Capped at 60 lines so a noisy traceback can't OOM us.
|
|
265
|
+
const stderrTail = [];
|
|
266
|
+
const STDERR_TAIL_MAX = 60;
|
|
157
267
|
proc.stdout?.on("data", (data) => {
|
|
158
268
|
const line = data.toString().trim();
|
|
159
269
|
if (line && opts.verbose)
|
|
160
270
|
console.log(chalk.gray(` [stdout] ${line}`));
|
|
161
271
|
});
|
|
162
272
|
proc.stderr?.on("data", (data) => {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
273
|
+
const text = data.toString();
|
|
274
|
+
for (const ln of text.split("\n")) {
|
|
275
|
+
const t = ln.trimEnd();
|
|
276
|
+
if (!t)
|
|
277
|
+
continue;
|
|
278
|
+
console.log(chalk.yellow(` [stderr] ${t}`));
|
|
279
|
+
stderrTail.push(t);
|
|
280
|
+
if (stderrTail.length > STDERR_TAIL_MAX)
|
|
281
|
+
stderrTail.shift();
|
|
282
|
+
}
|
|
166
283
|
});
|
|
167
284
|
proc.on("exit", (code) => {
|
|
168
285
|
activeProcesses.delete(payload.runId);
|
|
169
286
|
const status = code === 0 ? "done" : "failed";
|
|
287
|
+
// On non-zero exit, relay the captured stderr tail to the FE
|
|
288
|
+
// as a system agent_message so the operator sees the actual
|
|
289
|
+
// failure reason. Without this, a Python crash before
|
|
290
|
+
// agentscope.init() runs (bad import, missing dep, syntax
|
|
291
|
+
// error in generated main.py) shows up only as a silent
|
|
292
|
+
// run_status:failed and the user has no clue what happened.
|
|
293
|
+
if (status === "failed" && stderrTail.length > 0) {
|
|
294
|
+
const tail = stderrTail.slice(-15).join("\n");
|
|
295
|
+
socket.emit("runner:event", {
|
|
296
|
+
run_id: payload.runId,
|
|
297
|
+
event_type: "agent_message",
|
|
298
|
+
project: payload.project,
|
|
299
|
+
replyId: `runner-exit-${payload.runId}`,
|
|
300
|
+
replyName: "Runner",
|
|
301
|
+
replyRole: "system",
|
|
302
|
+
msg: {
|
|
303
|
+
id: `runner-exit-${payload.runId}`,
|
|
304
|
+
name: "Runner",
|
|
305
|
+
role: "system",
|
|
306
|
+
content: [{
|
|
307
|
+
type: "text",
|
|
308
|
+
text: `Pipeline exited with code ${code}. Last stderr:\n\`\`\`\n${tail}\n\`\`\``,
|
|
309
|
+
}],
|
|
310
|
+
metadata: { exitCode: code },
|
|
311
|
+
timestamp: new Date().toISOString(),
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
170
315
|
// Emit run_status FIRST (before runComplete deletes from ownedRunIds)
|
|
171
316
|
socket.emit("runner:event", {
|
|
172
317
|
run_id: payload.runId,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-runner preflight: ensure the LLM model the pipeline is about to
|
|
3
|
+
* call is actually loaded in LM Studio / pulled in Ollama before we spawn
|
|
4
|
+
* the Python subprocess.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists:
|
|
7
|
+
* - LM Studio's OpenAI-compat `/v1/chat/completions` will JIT-load a
|
|
8
|
+
* downloaded model on first request, but the request blocks for the
|
|
9
|
+
* entire load (60-120s for 8B-class quantised models). The pipeline
|
|
10
|
+
* subprocess sees no progress, the FE sees a stalled "Launching
|
|
11
|
+
* pipeline" spinner, the agent's first ChatResponse never arrives.
|
|
12
|
+
* Run d5ead8b8655b47fa hit this exactly: 1 `format openai` span, no
|
|
13
|
+
* `chat ...` span, no messages.
|
|
14
|
+
* - Ollama works similarly — first call to a model that isn't pulled
|
|
15
|
+
* will fail with "model not found"; downloads have to be explicit.
|
|
16
|
+
*
|
|
17
|
+
* What we do:
|
|
18
|
+
* 1. Read the model name + provider from the pipeline's config.json.
|
|
19
|
+
* 2. For lmstudio: GET /api/v0/models to see {id, state} for every
|
|
20
|
+
* model. If state="loaded" we're good. If state="not-loaded" we
|
|
21
|
+
* POST a tiny chat completion to force the JIT load while
|
|
22
|
+
* surfacing progress to the operator's stderr + emitting a
|
|
23
|
+
* socket event so the FE can show "Loading qwen/qwen3-vl-8b…".
|
|
24
|
+
* If model isn't in the list at all → fail fast with a clear
|
|
25
|
+
* "open LM Studio and download <name>" message.
|
|
26
|
+
* 3. For ollama: GET /api/tags. If model not present → fail fast with
|
|
27
|
+
* `ollama pull <name>` instruction. If present, ollama auto-loads.
|
|
28
|
+
*/
|
|
29
|
+
export interface ModelPreflightResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
reason?: string;
|
|
32
|
+
hint?: string;
|
|
33
|
+
loaded?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function preflightLmStudio(modelName: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
36
|
+
export declare function preflightOllama(modelName: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
37
|
+
/**
|
|
38
|
+
* Top-level preflight — resolves the provider/model from the pipeline
|
|
39
|
+
* config and dispatches to the right helper. Returns immediately for
|
|
40
|
+
* non-local providers (cloud-spawn shouldn't reach the runner anyway).
|
|
41
|
+
*/
|
|
42
|
+
export declare function preflightModel(configJson: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-runner preflight: ensure the LLM model the pipeline is about to
|
|
3
|
+
* call is actually loaded in LM Studio / pulled in Ollama before we spawn
|
|
4
|
+
* the Python subprocess.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists:
|
|
7
|
+
* - LM Studio's OpenAI-compat `/v1/chat/completions` will JIT-load a
|
|
8
|
+
* downloaded model on first request, but the request blocks for the
|
|
9
|
+
* entire load (60-120s for 8B-class quantised models). The pipeline
|
|
10
|
+
* subprocess sees no progress, the FE sees a stalled "Launching
|
|
11
|
+
* pipeline" spinner, the agent's first ChatResponse never arrives.
|
|
12
|
+
* Run d5ead8b8655b47fa hit this exactly: 1 `format openai` span, no
|
|
13
|
+
* `chat ...` span, no messages.
|
|
14
|
+
* - Ollama works similarly — first call to a model that isn't pulled
|
|
15
|
+
* will fail with "model not found"; downloads have to be explicit.
|
|
16
|
+
*
|
|
17
|
+
* What we do:
|
|
18
|
+
* 1. Read the model name + provider from the pipeline's config.json.
|
|
19
|
+
* 2. For lmstudio: GET /api/v0/models to see {id, state} for every
|
|
20
|
+
* model. If state="loaded" we're good. If state="not-loaded" we
|
|
21
|
+
* POST a tiny chat completion to force the JIT load while
|
|
22
|
+
* surfacing progress to the operator's stderr + emitting a
|
|
23
|
+
* socket event so the FE can show "Loading qwen/qwen3-vl-8b…".
|
|
24
|
+
* If model isn't in the list at all → fail fast with a clear
|
|
25
|
+
* "open LM Studio and download <name>" message.
|
|
26
|
+
* 3. For ollama: GET /api/tags. If model not present → fail fast with
|
|
27
|
+
* `ollama pull <name>` instruction. If present, ollama auto-loads.
|
|
28
|
+
*/
|
|
29
|
+
import chalk from "chalk";
|
|
30
|
+
const LMSTUDIO_BASE = process.env.LMSTUDIO_BASE_URL || "http://127.0.0.1:1234";
|
|
31
|
+
const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434";
|
|
32
|
+
// JIT-load timeout. 8B-class quantised models on M-series Macs typically
|
|
33
|
+
// load in 30-60s; bigger models or slow disks can take longer. We give
|
|
34
|
+
// 180s total before declaring the load stuck. Below this the runner
|
|
35
|
+
// simply prints progress.
|
|
36
|
+
const LMSTUDIO_LOAD_TIMEOUT_MS = 180_000;
|
|
37
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
38
|
+
const { timeoutMs = 5_000, ...rest } = init;
|
|
39
|
+
return fetch(url, { ...rest, signal: AbortSignal.timeout(timeoutMs) });
|
|
40
|
+
}
|
|
41
|
+
async function listLmStudioModels() {
|
|
42
|
+
try {
|
|
43
|
+
// /api/v0/models returns an array WITH `state` (loaded/not-loaded).
|
|
44
|
+
// /v1/models returns OpenAI-shape without state. We prefer v0 for
|
|
45
|
+
// the state field; fall back to v1 if v0 returns 404 on older
|
|
46
|
+
// LM Studio versions.
|
|
47
|
+
const r = await fetchWithTimeout(`${LMSTUDIO_BASE}/api/v0/models`, { timeoutMs: 3_000 });
|
|
48
|
+
if (r.ok) {
|
|
49
|
+
const j = await r.json();
|
|
50
|
+
const arr = Array.isArray(j) ? j : (j.data ?? []);
|
|
51
|
+
return arr;
|
|
52
|
+
}
|
|
53
|
+
if (r.status === 404) {
|
|
54
|
+
const r2 = await fetchWithTimeout(`${LMSTUDIO_BASE}/v1/models`, { timeoutMs: 3_000 });
|
|
55
|
+
if (!r2.ok)
|
|
56
|
+
return null;
|
|
57
|
+
const j = await r2.json();
|
|
58
|
+
// No state field — treat as state-unknown so we fall through to
|
|
59
|
+
// a JIT-load attempt below.
|
|
60
|
+
return (j.data ?? []).map((m) => ({ id: m.id }));
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function jitLoadLmStudioModel(modelId, onProgress) {
|
|
69
|
+
// POST a 1-token chat completion; LM Studio JIT-loads the model and
|
|
70
|
+
// blocks until it's resident. We use a small max_tokens so the actual
|
|
71
|
+
// generation cost is negligible — what we care about is the load
|
|
72
|
+
// happening before the real pipeline call.
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
const tick = setInterval(() => {
|
|
75
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
76
|
+
onProgress(`still loading ${modelId} (${elapsed}s elapsed)…`);
|
|
77
|
+
}, 10_000);
|
|
78
|
+
try {
|
|
79
|
+
const r = await fetchWithTimeout(`${LMSTUDIO_BASE}/v1/chat/completions`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json" },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
model: modelId,
|
|
84
|
+
messages: [{ role: "user", content: "." }],
|
|
85
|
+
max_tokens: 1,
|
|
86
|
+
stream: false,
|
|
87
|
+
}),
|
|
88
|
+
timeoutMs: LMSTUDIO_LOAD_TIMEOUT_MS,
|
|
89
|
+
});
|
|
90
|
+
if (!r.ok) {
|
|
91
|
+
const body = await r.text().catch(() => "");
|
|
92
|
+
onProgress(`LM Studio rejected load: HTTP ${r.status} ${body.slice(0, 200)}`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
onProgress(`load timed out / failed: ${e.message}`);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearInterval(tick);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function preflightLmStudio(modelName, onProgress = () => { }) {
|
|
106
|
+
const models = await listLmStudioModels();
|
|
107
|
+
if (models === null) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: "lmstudio_unreachable",
|
|
111
|
+
hint: `Could not reach LM Studio at ${LMSTUDIO_BASE}. Start LM Studio and enable the local server (Developer tab → Start Server).`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// Match by exact id first, then by suffix (LM Studio model ids include
|
|
115
|
+
// the publisher prefix; users sometimes type just the leaf name).
|
|
116
|
+
const exact = models.find((m) => m.id === modelName);
|
|
117
|
+
const suffix = exact ? null : models.find((m) => m.id.endsWith(`/${modelName}`) || m.id.endsWith(`:${modelName}`));
|
|
118
|
+
const match = exact ?? suffix;
|
|
119
|
+
if (!match) {
|
|
120
|
+
const available = models.map((m) => m.id).slice(0, 6).join(", ");
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: "model_not_downloaded",
|
|
124
|
+
hint: `Model "${modelName}" not found in LM Studio. Open LM Studio → Search → download it. Available: ${available || "(none)"}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const state = (match.state ?? "unknown");
|
|
128
|
+
if (state === "loaded") {
|
|
129
|
+
onProgress(`✓ ${match.id} already loaded in LM Studio`);
|
|
130
|
+
return { ok: true, loaded: true };
|
|
131
|
+
}
|
|
132
|
+
onProgress(`Loading ${match.id} into LM Studio (this can take 30-120s for first load)…`);
|
|
133
|
+
const loaded = await jitLoadLmStudioModel(match.id, onProgress);
|
|
134
|
+
if (!loaded) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
reason: "load_failed",
|
|
138
|
+
hint: `LM Studio could not load "${match.id}". Check available RAM and the LM Studio server log.`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
onProgress(`✓ ${match.id} loaded`);
|
|
142
|
+
return { ok: true, loaded: true };
|
|
143
|
+
}
|
|
144
|
+
export async function preflightOllama(modelName, onProgress = () => { }) {
|
|
145
|
+
try {
|
|
146
|
+
const r = await fetchWithTimeout(`${OLLAMA_BASE}/api/tags`, { timeoutMs: 3_000 });
|
|
147
|
+
if (!r.ok) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: "ollama_unreachable",
|
|
151
|
+
hint: `Could not reach Ollama at ${OLLAMA_BASE}. Start the Ollama daemon.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const j = await r.json();
|
|
155
|
+
const tags = (j.models ?? []).map((m) => m.name);
|
|
156
|
+
if (!tags.includes(modelName)) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: "model_not_pulled",
|
|
160
|
+
hint: `Model "${modelName}" not pulled in Ollama. Run: ollama pull ${modelName}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
onProgress(`✓ ${modelName} present in Ollama`);
|
|
164
|
+
return { ok: true, loaded: true };
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
return { ok: false, reason: "ollama_error", hint: e.message };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Top-level preflight — resolves the provider/model from the pipeline
|
|
172
|
+
* config and dispatches to the right helper. Returns immediately for
|
|
173
|
+
* non-local providers (cloud-spawn shouldn't reach the runner anyway).
|
|
174
|
+
*/
|
|
175
|
+
export async function preflightModel(configJson, onProgress = (m) => console.log(chalk.gray(` [model] ${m}`))) {
|
|
176
|
+
let cfg;
|
|
177
|
+
try {
|
|
178
|
+
cfg = JSON.parse(configJson);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return { ok: true }; /* let main.py error naturally */
|
|
182
|
+
}
|
|
183
|
+
// Pipeline-level + per-step / per-agent providers — match the same
|
|
184
|
+
// walk as builder_server.py:2716-2725 so we honour the agent that
|
|
185
|
+
// overrides the top-level pick.
|
|
186
|
+
const providers = new Set();
|
|
187
|
+
const models = new Set();
|
|
188
|
+
const top = String(cfg.model_provider ?? cfg.model?.provider ?? "").toLowerCase();
|
|
189
|
+
if (top)
|
|
190
|
+
providers.add(top);
|
|
191
|
+
const topModel = String(cfg.model_name ?? cfg.model?.name ?? "");
|
|
192
|
+
if (topModel)
|
|
193
|
+
models.add(topModel);
|
|
194
|
+
for (const step of (cfg.steps ?? [])) {
|
|
195
|
+
const a = step?.agent ?? {};
|
|
196
|
+
const p = String(a?.model?.provider ?? "").toLowerCase();
|
|
197
|
+
const n = String(a?.model?.name ?? "");
|
|
198
|
+
if (p)
|
|
199
|
+
providers.add(p);
|
|
200
|
+
if (n)
|
|
201
|
+
models.add(n);
|
|
202
|
+
}
|
|
203
|
+
for (const a of (cfg.agents ?? [])) {
|
|
204
|
+
const p = String(a?.model_provider ?? a?.model?.provider ?? "").toLowerCase();
|
|
205
|
+
const n = String(a?.model_name ?? a?.model?.name ?? "");
|
|
206
|
+
if (p)
|
|
207
|
+
providers.add(p);
|
|
208
|
+
if (n)
|
|
209
|
+
models.add(n);
|
|
210
|
+
}
|
|
211
|
+
// Run preflight for every distinct (provider, model) combo this
|
|
212
|
+
// pipeline will exercise. One bad agent = whole run fails fast.
|
|
213
|
+
const tasks = [];
|
|
214
|
+
for (const m of models) {
|
|
215
|
+
if (providers.has("lmstudio"))
|
|
216
|
+
tasks.push(preflightLmStudio(m, onProgress));
|
|
217
|
+
if (providers.has("ollama"))
|
|
218
|
+
tasks.push(preflightOllama(m, onProgress));
|
|
219
|
+
}
|
|
220
|
+
if (tasks.length === 0)
|
|
221
|
+
return { ok: true };
|
|
222
|
+
const results = await Promise.all(tasks);
|
|
223
|
+
const failed = results.find((r) => !r.ok);
|
|
224
|
+
return failed ?? { ok: true, loaded: true };
|
|
225
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manage a dedicated Python venv at ~/.melaya-runner/venv/ so pipelines
|
|
3
|
+
* don't depend on whatever's globally installed in the operator's
|
|
4
|
+
* `python3`. Without this, every fresh runner install hits import errors
|
|
5
|
+
* (`ModuleNotFoundError: No module named 'shortuuid'`, etc.) on the
|
|
6
|
+
* first pipeline run because agentscope and its 23 transitive deps are
|
|
7
|
+
* not present in the operator's system Python.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent: re-uses the venv if a marker file matches the current
|
|
10
|
+
* shared-bundle version. Re-bootstraps when the bundle version changes.
|
|
11
|
+
*/
|
|
12
|
+
export declare function venvPython(): string;
|
|
13
|
+
export declare function ensurePythonEnv(systemPython: string, expectedVersion: string, onProgress?: (msg: string) => void): Promise<{
|
|
14
|
+
ok: boolean;
|
|
15
|
+
pythonPath: string;
|
|
16
|
+
reason?: string;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manage a dedicated Python venv at ~/.melaya-runner/venv/ so pipelines
|
|
3
|
+
* don't depend on whatever's globally installed in the operator's
|
|
4
|
+
* `python3`. Without this, every fresh runner install hits import errors
|
|
5
|
+
* (`ModuleNotFoundError: No module named 'shortuuid'`, etc.) on the
|
|
6
|
+
* first pipeline run because agentscope and its 23 transitive deps are
|
|
7
|
+
* not present in the operator's system Python.
|
|
8
|
+
*
|
|
9
|
+
* Idempotent: re-uses the venv if a marker file matches the current
|
|
10
|
+
* shared-bundle version. Re-bootstraps when the bundle version changes.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "child_process";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { homedir, platform } from "os";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
const CACHE_DIR = join(homedir(), ".melaya-runner");
|
|
18
|
+
const VENV_DIR = join(CACHE_DIR, "venv");
|
|
19
|
+
const VENV_MARK = join(CACHE_DIR, "venv-version.txt");
|
|
20
|
+
const AGENTSCOPE = join(CACHE_DIR, "agentscope");
|
|
21
|
+
// Extra deps the cached `shared/tools/*.py` import that aren't in
|
|
22
|
+
// agentscope's pyproject. Keep this list narrow — full tool-specific
|
|
23
|
+
// SDKs (linkedin_api, twilio, stripe, etc.) install lazily on first
|
|
24
|
+
// use via tool-specific scripts. The minimum to GET A PIPELINE RUNNING
|
|
25
|
+
// is agentscope + aiohttp + requests + redis (used by some tools).
|
|
26
|
+
const EXTRA_DEPS = ["aiohttp", "requests", "python-dotenv"];
|
|
27
|
+
export function venvPython() {
|
|
28
|
+
return platform() === "win32"
|
|
29
|
+
? join(VENV_DIR, "Scripts", "python.exe")
|
|
30
|
+
: join(VENV_DIR, "bin", "python");
|
|
31
|
+
}
|
|
32
|
+
function venvIsValid(expectedVersion) {
|
|
33
|
+
if (!existsSync(venvPython()))
|
|
34
|
+
return false;
|
|
35
|
+
if (!existsSync(VENV_MARK))
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
const cached = readFileSync(VENV_MARK, "utf-8").trim();
|
|
39
|
+
return cached === expectedVersion;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function runProc(cmd, args, onLine) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
48
|
+
const chew = (b) => {
|
|
49
|
+
for (const ln of b.toString().split("\n")) {
|
|
50
|
+
const t = ln.trimEnd();
|
|
51
|
+
if (t)
|
|
52
|
+
onLine(t);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
child.stdout?.on("data", chew);
|
|
56
|
+
child.stderr?.on("data", chew);
|
|
57
|
+
child.on("exit", (code) => resolve(code ?? 1));
|
|
58
|
+
child.on("error", () => resolve(1));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export async function ensurePythonEnv(systemPython, expectedVersion, onProgress = (m) => console.log(chalk.gray(` [venv] ${m}`))) {
|
|
62
|
+
if (venvIsValid(expectedVersion)) {
|
|
63
|
+
return { ok: true, pythonPath: venvPython() };
|
|
64
|
+
}
|
|
65
|
+
if (!existsSync(AGENTSCOPE)) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
pythonPath: systemPython,
|
|
69
|
+
reason: `agentscope cache missing at ${AGENTSCOPE} — restart the runner so the shared bundle downloads first.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
73
|
+
// Step 1: create the venv if missing.
|
|
74
|
+
if (!existsSync(venvPython())) {
|
|
75
|
+
onProgress(`creating venv at ${VENV_DIR} (one-time, ~5s)`);
|
|
76
|
+
const code = await runProc(systemPython, ["-m", "venv", VENV_DIR], onProgress);
|
|
77
|
+
if (code !== 0 || !existsSync(venvPython())) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
pythonPath: systemPython,
|
|
81
|
+
reason: `python -m venv failed (exit ${code}). Confirm the venv module is available: ${systemPython} -m venv --help`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Step 2: upgrade pip + wheel so wheel-based deps install cleanly.
|
|
86
|
+
onProgress("upgrading pip / wheel inside the venv");
|
|
87
|
+
await runProc(venvPython(), ["-m", "pip", "install", "--quiet", "--upgrade", "pip", "wheel"], onProgress);
|
|
88
|
+
// Step 3: install agentscope from the local cache (its pyproject pulls
|
|
89
|
+
// anthropic, openai, shortuuid, opentelemetry-*, etc.).
|
|
90
|
+
onProgress("installing agentscope + 23 transitive deps (anthropic, openai, opentelemetry, …) — first run can take 1-2 min");
|
|
91
|
+
const acode = await runProc(venvPython(), ["-m", "pip", "install", "--quiet", "-e", AGENTSCOPE, ...EXTRA_DEPS], onProgress);
|
|
92
|
+
if (acode !== 0) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
pythonPath: systemPython,
|
|
96
|
+
reason: `pip install agentscope failed (exit ${acode}). Check the lines above for the actual pip error.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(VENV_MARK, expectedVersion, "utf-8");
|
|
100
|
+
// sanity: confirm shortuuid resolves now (it's the historic canary
|
|
101
|
+
// import that fails on a stale env).
|
|
102
|
+
const probe = await runProc(venvPython(), ["-c", "import shortuuid, agentscope, anthropic, openai"], onProgress);
|
|
103
|
+
if (probe !== 0) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
pythonPath: systemPython,
|
|
107
|
+
reason: "venv created but agentscope import probe failed. Delete ~/.melaya-runner/venv and restart the runner.",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
onProgress(`✓ venv ready (python=${venvPython()})`);
|
|
111
|
+
return { ok: true, pythonPath: venvPython() };
|
|
112
|
+
}
|