@melaya/runner 1.0.29 → 1.0.31

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.
@@ -117,38 +117,35 @@ export async function connect(opts) {
117
117
  // Surface bootstrap progress to the FE so the launching
118
118
  // animation reflects the real state instead of staying on a
119
119
  // generic spinner during the 1-2 min first-time install.
120
+ // FLAT shape — server reads payload.run_id at top level
121
+ // (runnerNamespace.ts:202). My earlier nested {runId, payload:{}}
122
+ // shape was silently dropped by `if (!runId) return`.
120
123
  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
- },
124
+ run_id: payload.runId,
125
+ event_type: "pipeline_phase",
126
+ project: payload.project,
127
+ step: 1,
128
+ total: 5,
129
+ label: `venv: ${msg}`,
130
+ status: "started",
131
131
  });
132
132
  });
133
133
  if (!envResult.ok) {
134
134
  console.log(chalk.red(` ✗ python venv bootstrap failed: ${envResult.reason}`));
135
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
- },
136
+ run_id: payload.runId,
137
+ event_type: "agent_message",
138
+ project: payload.project,
139
+ replyId: `venv-${payload.runId}`,
140
+ replyName: "Runner",
141
+ replyRole: "system",
142
+ msg: {
143
+ id: `venv-${payload.runId}`,
144
+ name: "Runner",
145
+ role: "system",
146
+ content: [{ type: "text", text: `Python env bootstrap failed: ${envResult.reason}` }],
147
+ metadata: {},
148
+ timestamp: new Date().toISOString(),
152
149
  },
153
150
  });
154
151
  socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
@@ -178,20 +175,17 @@ export async function connect(opts) {
178
175
  const { preflightModel } = await import("./modelLoader.js");
179
176
  const onPreflightProgress = (msg) => {
180
177
  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.
178
+ // Flat shape server reads payload.run_id at top level
179
+ // (runnerNamespace.ts:202). Earlier nested {runId, payload:{}}
180
+ // was silently dropped by `if (!runId) return`.
184
181
  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
- },
182
+ run_id: payload.runId,
183
+ event_type: "pipeline_phase",
184
+ project: payload.project,
185
+ step: 1,
186
+ total: 5,
187
+ label: msg,
188
+ status: "started",
195
189
  });
196
190
  };
197
191
  const preflight = await preflightModel(payload.configJson, onPreflightProgress);
@@ -199,22 +193,19 @@ export async function connect(opts) {
199
193
  const detail = `${preflight.reason ?? "unknown"}${preflight.hint ? ` — ${preflight.hint}` : ""}`;
200
194
  console.log(chalk.red(` ✗ model preflight failed: ${detail}`));
201
195
  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
- },
196
+ run_id: payload.runId,
197
+ event_type: "agent_message",
198
+ project: payload.project,
199
+ replyId: `preflight-${payload.runId}`,
200
+ replyName: "Runner",
201
+ replyRole: "system",
202
+ msg: {
203
+ id: `preflight-${payload.runId}`,
204
+ name: "Runner",
205
+ role: "system",
206
+ content: [{ type: "text", text: `Model preflight failed: ${detail}` }],
207
+ metadata: {},
208
+ timestamp: new Date().toISOString(),
218
209
  },
219
210
  });
220
211
  socket.emit("runner:runComplete", { runId: payload.runId, status: "failed" });
@@ -223,8 +214,18 @@ export async function connect(opts) {
223
214
  // Build env vars for the subprocess
224
215
  const relayUrl = `http://127.0.0.1:${relay.port}/events/relay/${relay.nonce}`;
225
216
  const sharedDir = getSharedDir();
217
+ // Defense in depth: scrub any MEL_MODEL_* the operator may have
218
+ // exported in their shell before launching the runner. Without
219
+ // this, a stale leak (e.g. operator set MEL_MODEL_DISABLE_THINKING=1
220
+ // while debugging Qwen3, then ran a non-Qwen pipeline) would
221
+ // bypass the preflight gate and corrupt model.py's profile read.
222
+ const inherited = { ...process.env };
223
+ for (const k of Object.keys(inherited)) {
224
+ if (k.startsWith("MEL_MODEL_"))
225
+ delete inherited[k];
226
+ }
226
227
  const env = {
227
- ...process.env,
228
+ ...inherited,
228
229
  PYTHONPATH: sharedDir,
229
230
  PYTHONIOENCODING: "utf-8",
230
231
  PYTHONUTF8: "1",
@@ -250,7 +251,47 @@ export async function connect(opts) {
250
251
  MEL_LUMA_BROWSER_TOKEN: lumaBridge.token,
251
252
  }
252
253
  : {}),
254
+ // Per-model profile from preflight detection. Empty when
255
+ // preflight didn't classify (cloud-spawn or capable model with
256
+ // defaults intact). When set, shared/runtime/model.py reads
257
+ // these and overrides max_tokens / max_iters / thinking-disable
258
+ // for THIS model only (text-only / agentic-marginal). Run
259
+ // f9fd5e596e7f4c9a (qwen3-1.7b looped in <think>) was the
260
+ // canary that motivated this.
261
+ ...(preflight.profile
262
+ ? {
263
+ MEL_MODEL_TIER: preflight.profile.tier,
264
+ MEL_MODEL_FAMILY: preflight.profile.family ?? "",
265
+ MEL_MODEL_PARAMS_B: String(preflight.profile.paramsBillion ?? ""),
266
+ MEL_MODEL_MAX_TOKENS: String(preflight.profile.recommendedMaxTokens),
267
+ MEL_MODEL_MAX_ITERS: String(preflight.profile.recommendedMaxIters),
268
+ MEL_MODEL_DISABLE_THINKING: preflight.profile.thinkingDefault === "off" ? "1" : "0",
269
+ }
270
+ : {}),
253
271
  };
272
+ // Surface the tier to the chat panel as a system message so the
273
+ // operator sees WHY a small model is running with tighter caps.
274
+ if (preflight.profile && preflight.profile.tier !== "agentic-capable") {
275
+ socket.emit("runner:event", {
276
+ run_id: payload.runId,
277
+ event_type: "agent_message",
278
+ project: payload.project,
279
+ replyId: `model-tier-${payload.runId}`,
280
+ replyName: "Runner",
281
+ replyRole: "system",
282
+ msg: {
283
+ id: `model-tier-${payload.runId}`,
284
+ name: "Runner",
285
+ role: "system",
286
+ content: [{
287
+ type: "text",
288
+ text: `⚠ Model tier: **${preflight.profile.tier}** (${preflight.profile.id}, ${preflight.profile.paramsBillion ?? "?"}B). ${preflight.profile.reason}. Caps: max_tokens=${preflight.profile.recommendedMaxTokens}, max_iters=${preflight.profile.recommendedMaxIters}${preflight.profile.thinkingDefault === "off" ? ", thinking=off" : ""}. For full agentic capability use a ≥7B model or a cloud provider.`,
289
+ }],
290
+ metadata: { tier: preflight.profile.tier, params_b: preflight.profile.paramsBillion },
291
+ timestamp: new Date().toISOString(),
292
+ },
293
+ });
294
+ }
254
295
  // Spawn Python subprocess (uses the bootstrapped venv python).
255
296
  const proc = spawn(pipelinePython, ["-u", join(runDir, "main.py")], {
256
297
  cwd: runDir,
@@ -26,11 +26,36 @@
26
26
  * 3. For ollama: GET /api/tags. If model not present → fail fast with
27
27
  * `ollama pull <name>` instruction. If present, ollama auto-loads.
28
28
  */
29
+ export declare function classifyModel(id: string, lmstudioMeta?: LMStudioModelInfo): ModelProfile;
30
+ type LMStudioModelState = "loaded" | "not-loaded" | "loading" | string;
31
+ interface LMStudioModelInfo {
32
+ id: string;
33
+ state?: LMStudioModelState;
34
+ type?: string;
35
+ arch?: string;
36
+ params?: string | number;
37
+ max_context_length?: number;
38
+ }
39
+ export type ModelTier = "agentic-capable" | "agentic-marginal" | "text-only";
40
+ export interface ModelProfile {
41
+ schemaVersion: number;
42
+ id: string;
43
+ tier: ModelTier;
44
+ paramsBillion: number | null;
45
+ family: string | null;
46
+ supportsToolCalling: boolean;
47
+ thinkingDefault: "on" | "off" | "n/a";
48
+ recommendedMaxTokens: number;
49
+ recommendedMaxIters: number;
50
+ detectedAt: string;
51
+ reason: string;
52
+ }
29
53
  export interface ModelPreflightResult {
30
54
  ok: boolean;
31
55
  reason?: string;
32
56
  hint?: string;
33
57
  loaded?: boolean;
58
+ profile?: ModelProfile;
34
59
  }
35
60
  export declare function preflightLmStudio(modelName: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
36
61
  export declare function preflightOllama(modelName: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
@@ -40,3 +65,4 @@ export declare function preflightOllama(modelName: string, onProgress?: (msg: st
40
65
  * non-local providers (cloud-spawn shouldn't reach the runner anyway).
41
66
  */
42
67
  export declare function preflightModel(configJson: string, onProgress?: (msg: string) => void): Promise<ModelPreflightResult>;
68
+ export {};
@@ -27,6 +27,194 @@
27
27
  * `ollama pull <name>` instruction. If present, ollama auto-loads.
28
28
  */
29
29
  import chalk from "chalk";
30
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
31
+ import { join } from "path";
32
+ import { homedir } from "os";
33
+ // ── Model classification ───────────────────────────────────────────────────────
34
+ //
35
+ // Goal: detect when a local model is too small / weak to drive an
36
+ // agentic ReAct pipeline, BEFORE we spawn the python subprocess. The
37
+ // pipeline can then receive a tighter profile (max_tokens, max_iters,
38
+ // thinking-disable) so it fails fast or runs reliably instead of
39
+ // silently looping inside <think> tags forever (run f9fd5e596e7f4c9a
40
+ // canary: qwen3-1.7b produced 81 thinking blocks, 0 tool_use, 0 text).
41
+ //
42
+ // Classification is deterministic from (a) LM Studio metadata, (b)
43
+ // param-count regex on the model id, (c) a hard family table for known
44
+ // edge cases that don't match the regex. NO network probe today —
45
+ // keeps the preflight cheap; we lean on Layer 3 (runtime loop guard)
46
+ // to catch detection misses.
47
+ const PROFILE_DIR = join(homedir(), ".melaya-runner", "model-profiles");
48
+ // Models BELOW this param count are forced to `agentic-marginal` or
49
+ // `text-only` regardless of family. Above it, family table overrides.
50
+ const TIER_PARAM_THRESHOLDS = {
51
+ agenticCapable: 7.0, // 7B+ is the empirical floor for reliable tool_use
52
+ agenticMarginal: 4.0, // 4-7B can do simple tool_use, struggles with multi-step ReAct
53
+ // <4B is text-only — too small for reliable agentic work
54
+ };
55
+ // Family hints for models that don't expose param count cleanly via
56
+ // name OR are known special cases regardless of size. Match against
57
+ // the lowercased model id; first match wins. ORDER MATTERS — put
58
+ // MORE-SPECIFIC patterns first (e.g. qwen3-vl-4b before qwen3-4b),
59
+ // otherwise the looser pattern claims the id. All `\b...b\b` anchors
60
+ // use \b so `qwen3-14b` doesn't accidentally match `4b`.
61
+ const FAMILY_OVERRIDES = [
62
+ // Qwen3 family — thinking is on by default; for the small variants
63
+ // (which can't escape <think>) we need to disable it via
64
+ // chat_template_kwargs.enable_thinking=false. The vl-* and 8B+ keep
65
+ // thinking ON because they're capable enough to use it for ReAct.
66
+ // ── Qwen3 — VL (vision) variants FIRST (more specific than non-vl) ──
67
+ { pattern: /qwen3.*vl.*\b4b\b/i, family: "qwen3", tier: "agentic-marginal", thinking: "on", reason: "qwen3-vl-4b: vision variant has stronger reasoning, marginal but keep thinking on" },
68
+ { pattern: /qwen3.*vl.*\b8b\b/i, family: "qwen3", tier: "agentic-capable", thinking: "on", reason: "qwen3-vl-8b: agentic-capable, full thinking" },
69
+ // ── Qwen3 — capable (≥8B) BEFORE the smaller non-vl 4b pattern, so
70
+ // 14b / 32b don't get caught by the 4b regex's stem match. ──
71
+ { pattern: /qwen3.*\b(8b|14b|32b)\b/i, family: "qwen3", tier: "agentic-capable", thinking: "on", reason: "qwen3 ≥8b: agentic-capable" },
72
+ // ── Qwen3 — small variants ──
73
+ { pattern: /qwen3.*\b1\.7b\b/i, family: "qwen3", tier: "text-only", thinking: "off", reason: "qwen3-1.7b: param count below the agentic 7B floor; thinking disabled to prevent <think> loops" },
74
+ { pattern: /qwen3.*\b0\.5b\b/i, family: "qwen3", tier: "text-only", thinking: "off", reason: "qwen3-0.5b: too small for agentic work" },
75
+ { pattern: /qwen3.*\b4b\b/i, family: "qwen3", tier: "agentic-marginal", thinking: "off", reason: "qwen3-4b non-vl: marginal — thinking off, tighter caps" },
76
+ // ── DeepSeek-R1 — also emits <think> blocks. Capable variants keep
77
+ // thinking on; small distills get thinking off. R1's runtime
78
+ // accepts the same chat_template_kwargs.enable_thinking=false
79
+ // extra_body via the OpenAI-compat server (LM Studio / vLLM). ──
80
+ { pattern: /deepseek.*r1.*\b1\.5b\b/i, family: "deepseek-r1", tier: "text-only", thinking: "off", reason: "deepseek-r1-distill-1.5b: too small for agentic, thinking off to prevent <think> loops" },
81
+ { pattern: /deepseek.*r1.*\b7b\b/i, family: "deepseek-r1", tier: "agentic-marginal", thinking: "on", reason: "deepseek-r1-distill-7b: marginal, thinking on" },
82
+ { pattern: /deepseek.*r1.*\b(8b|14b|32b|70b|671b)\b/i, family: "deepseek-r1", tier: "agentic-capable", thinking: "on", reason: "deepseek-r1-distill ≥8b: agentic-capable" },
83
+ // Gemma3 — small variants are text-completion oriented. Use \b
84
+ // anchors so gemma-3-12b doesn't collide with the 1b/2b rule via
85
+ // substring match (the previous unanchored pattern misclassified
86
+ // every gemma-N{1,2}b variant containing '1b' or '2b' as a substring).
87
+ { pattern: /gemma-?3?.*\b(1b|2b)\b/i, family: "gemma", tier: "text-only", thinking: "n/a", reason: "gemma 1-2b: text-only, no reliable tool_use" },
88
+ { pattern: /gemma-?3?.*\b4b\b/i, family: "gemma", tier: "agentic-marginal", thinking: "n/a", reason: "gemma 4b: marginal" },
89
+ { pattern: /gemma-?3?.*\b(7b|9b|12b|21b|27b)\b/i, family: "gemma", tier: "agentic-capable", thinking: "n/a", reason: "gemma ≥7b: agentic-capable" },
90
+ // Llama3.2 small variants
91
+ { pattern: /llama-?3\.?2.*\b(1b|3b)\b/i, family: "llama", tier: "text-only", thinking: "n/a", reason: "llama3.2 1-3b: text-only" },
92
+ // Phi-4 (14B) and Phi-4-mini (3.8B) — name has no \bNb\b token.
93
+ // Phi-3.5 mini (3.8B) covered separately.
94
+ { pattern: /phi-?4(?!-?mini)/i, family: "phi", tier: "agentic-capable", thinking: "n/a", reason: "phi-4: 14B, agentic-capable" },
95
+ { pattern: /phi-?4-?mini/i, family: "phi", tier: "agentic-marginal", thinking: "n/a", reason: "phi-4-mini: 3.8B, marginal" },
96
+ { pattern: /phi-?3\.?5.*mini/i, family: "phi", tier: "agentic-marginal", thinking: "n/a", reason: "phi-3.5-mini: 3.8B, marginal" },
97
+ ];
98
+ function _parseParamCount(id) {
99
+ // Match formats: "8b", "1.7b", "32B", "8x7b" (mixture-of-experts → use the active param count, e.g. mixtral-8x7b ≈ 12.9B active)
100
+ const m = id.match(/(\d+x)?(\d+(?:\.\d+)?)\s*[bB]\b/);
101
+ if (!m)
102
+ return null;
103
+ const moe = m[1];
104
+ const base = parseFloat(m[2]);
105
+ if (isNaN(base))
106
+ return null;
107
+ // For MoE, we approximate active params (rough — Mixtral 8x7B is ~12.9B active).
108
+ // Treat conservatively as 1.5× the base for the tier check.
109
+ return moe ? base * 1.5 : base;
110
+ }
111
+ function _classify(id, lmstudioMeta) {
112
+ const lower = id.toLowerCase();
113
+ // 1. Family override wins if matched.
114
+ for (const ov of FAMILY_OVERRIDES) {
115
+ if (ov.pattern.test(lower)) {
116
+ const params = _parseParamCount(id);
117
+ return _buildProfile(id, ov.tier, params, ov.family, ov.thinking, ov.reason);
118
+ }
119
+ }
120
+ // 2. LM Studio metadata if present.
121
+ let params = null;
122
+ if (lmstudioMeta?.params != null) {
123
+ const raw = String(lmstudioMeta.params);
124
+ const m = raw.match(/(\d+(?:\.\d+)?)/);
125
+ if (m) {
126
+ const n = parseFloat(m[1]);
127
+ // LM Studio reports either a raw count (e.g. 7000000000) or a
128
+ // human string ("7B"). Normalise to billions.
129
+ params = raw.toLowerCase().includes("b") ? n : (n > 1e6 ? n / 1e9 : n);
130
+ }
131
+ }
132
+ if (params == null)
133
+ params = _parseParamCount(id);
134
+ // 3. Tier from param count.
135
+ let tier;
136
+ let reason;
137
+ if (params == null) {
138
+ tier = "agentic-marginal";
139
+ reason = `couldn't infer param count from "${id}" — defaulting to marginal (Layer 3 loop guard remains active)`;
140
+ }
141
+ else if (params >= TIER_PARAM_THRESHOLDS.agenticCapable) {
142
+ tier = "agentic-capable";
143
+ reason = `${params}B params ≥ ${TIER_PARAM_THRESHOLDS.agenticCapable}B threshold`;
144
+ }
145
+ else if (params >= TIER_PARAM_THRESHOLDS.agenticMarginal) {
146
+ tier = "agentic-marginal";
147
+ reason = `${params}B params: between ${TIER_PARAM_THRESHOLDS.agenticMarginal}B and ${TIER_PARAM_THRESHOLDS.agenticCapable}B — marginal, tighter caps applied`;
148
+ }
149
+ else {
150
+ tier = "text-only";
151
+ reason = `${params}B params < ${TIER_PARAM_THRESHOLDS.agenticMarginal}B threshold — text-only, abort if used for tool-using pipeline`;
152
+ }
153
+ return _buildProfile(id, tier, params, null, "n/a", reason);
154
+ }
155
+ function _buildProfile(id, tier, paramsBillion, family, thinkingDefault, reason) {
156
+ // Per-tier resource caps. Smaller models get tighter ceilings to
157
+ // bound runaway thinking loops.
158
+ const recommendedMaxTokens = tier === "agentic-capable" ? 8192
159
+ : tier === "agentic-marginal" ? 4096
160
+ : 2048;
161
+ const recommendedMaxIters = tier === "agentic-capable" ? 10
162
+ : tier === "agentic-marginal" ? 5
163
+ : 3;
164
+ return {
165
+ schemaVersion: PROFILE_SCHEMA_VERSION,
166
+ id,
167
+ tier,
168
+ paramsBillion,
169
+ family,
170
+ supportsToolCalling: tier !== "text-only",
171
+ thinkingDefault,
172
+ recommendedMaxTokens,
173
+ recommendedMaxIters,
174
+ detectedAt: new Date().toISOString(),
175
+ reason,
176
+ };
177
+ }
178
+ function _saveProfile(p) {
179
+ try {
180
+ mkdirSync(PROFILE_DIR, { recursive: true });
181
+ const safe = p.id.replace(/[^a-zA-Z0-9_.-]+/g, "_");
182
+ writeFileSync(join(PROFILE_DIR, `${safe}.json`), JSON.stringify(p, null, 2), "utf-8");
183
+ }
184
+ catch { /* best-effort — profile is regenerated on next preflight */ }
185
+ }
186
+ function _loadCachedProfile(id) {
187
+ try {
188
+ const safe = id.replace(/[^a-zA-Z0-9_.-]+/g, "_");
189
+ const p = join(PROFILE_DIR, `${safe}.json`);
190
+ if (!existsSync(p))
191
+ return null;
192
+ const parsed = JSON.parse(readFileSync(p, "utf-8"));
193
+ // Reject cached profiles produced by an older classifier — they
194
+ // may carry stale tiers (e.g. gemma-3-12b mis-tagged 'text-only'
195
+ // by the pre-v2 unanchored regex). Forces a fresh classification.
196
+ if ((parsed.schemaVersion ?? 1) !== PROFILE_SCHEMA_VERSION)
197
+ return null;
198
+ return parsed;
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
204
+ export function classifyModel(id, lmstudioMeta) {
205
+ // Cache hit if we've classified this id before. Cheap optimization
206
+ // to avoid redoing the regex/family walk on every spawn.
207
+ const cached = _loadCachedProfile(id);
208
+ if (cached && cached.id === id)
209
+ return cached;
210
+ const profile = _classify(id, lmstudioMeta);
211
+ _saveProfile(profile);
212
+ return profile;
213
+ }
214
+ // Bump whenever FAMILY_OVERRIDES, tier thresholds, or any classifier
215
+ // logic changes in a way that could re-tier an existing cached model.
216
+ // Cached profiles with a mismatched version are ignored and re-classified.
217
+ const PROFILE_SCHEMA_VERSION = 2;
30
218
  const LMSTUDIO_BASE = process.env.LMSTUDIO_BASE_URL || "http://127.0.0.1:1234";
31
219
  const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434";
32
220
  // JIT-load timeout. 8B-class quantised models on M-series Macs typically
@@ -124,10 +312,17 @@ export async function preflightLmStudio(modelName, onProgress = () => { }) {
124
312
  hint: `Model "${modelName}" not found in LM Studio. Open LM Studio → Search → download it. Available: ${available || "(none)"}`,
125
313
  };
126
314
  }
315
+ const profile = classifyModel(match.id, match);
316
+ if (profile.tier !== "agentic-capable") {
317
+ onProgress(`⚠ ${match.id} classified ${profile.tier} (${profile.reason})`);
318
+ }
319
+ else {
320
+ onProgress(`✓ ${match.id} classified ${profile.tier} (${profile.paramsBillion ?? "?"}B)`);
321
+ }
127
322
  const state = (match.state ?? "unknown");
128
323
  if (state === "loaded") {
129
324
  onProgress(`✓ ${match.id} already loaded in LM Studio`);
130
- return { ok: true, loaded: true };
325
+ return { ok: true, loaded: true, profile };
131
326
  }
132
327
  onProgress(`Loading ${match.id} into LM Studio (this can take 30-120s for first load)…`);
133
328
  const loaded = await jitLoadLmStudioModel(match.id, onProgress);
@@ -136,10 +331,11 @@ export async function preflightLmStudio(modelName, onProgress = () => { }) {
136
331
  ok: false,
137
332
  reason: "load_failed",
138
333
  hint: `LM Studio could not load "${match.id}". Check available RAM and the LM Studio server log.`,
334
+ profile,
139
335
  };
140
336
  }
141
337
  onProgress(`✓ ${match.id} loaded`);
142
- return { ok: true, loaded: true };
338
+ return { ok: true, loaded: true, profile };
143
339
  }
144
340
  export async function preflightOllama(modelName, onProgress = () => { }) {
145
341
  try {
@@ -160,8 +356,14 @@ export async function preflightOllama(modelName, onProgress = () => { }) {
160
356
  hint: `Model "${modelName}" not pulled in Ollama. Run: ollama pull ${modelName}`,
161
357
  };
162
358
  }
163
- onProgress(`✓ ${modelName} present in Ollama`);
164
- return { ok: true, loaded: true };
359
+ const profile = classifyModel(modelName);
360
+ if (profile.tier !== "agentic-capable") {
361
+ onProgress(`⚠ ${modelName} classified ${profile.tier} (${profile.reason})`);
362
+ }
363
+ else {
364
+ onProgress(`✓ ${modelName} classified ${profile.tier} (${profile.paramsBillion ?? "?"}B)`);
365
+ }
366
+ return { ok: true, loaded: true, profile };
165
367
  }
166
368
  catch (e) {
167
369
  return { ok: false, reason: "ollama_error", hint: e.message };
@@ -221,5 +423,14 @@ export async function preflightModel(configJson, onProgress = (m) => console.log
221
423
  return { ok: true };
222
424
  const results = await Promise.all(tasks);
223
425
  const failed = results.find((r) => !r.ok);
224
- return failed ?? { ok: true, loaded: true };
426
+ if (failed)
427
+ return failed;
428
+ // Pick the most-restrictive profile across every model the pipeline
429
+ // uses — if ANY agent is on a text-only model, the whole pipeline
430
+ // gets the text-only caps. Agentic-capable < marginal < text-only
431
+ // (most restrictive last).
432
+ const tierRank = { "agentic-capable": 0, "agentic-marginal": 1, "text-only": 2 };
433
+ const profiles = results.map((r) => r.profile).filter((p) => !!p);
434
+ const worst = profiles.reduce((acc, p) => (!acc || tierRank[p.tier] > tierRank[acc.tier]) ? p : acc, undefined);
435
+ return { ok: true, loaded: true, profile: worst };
225
436
  }
package/dist/pythonEnv.js CHANGED
@@ -69,6 +69,7 @@ const PIP_DEPS = [
69
69
  "openpyxl",
70
70
  "python-docx",
71
71
  "python-pptx",
72
+ "ddgs", // duckduckgo search rebrand — shared.tools.core imports DDGS
72
73
  ];
73
74
  export function venvPython() {
74
75
  return platform() === "win32"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.29",
3
+ "version": "1.0.31",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,