comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
161 lines • 8.13 kB
JavaScript
// Per-MODEL tool-mode selection (issue #788).
//
// Compact tool mode -- an on-demand router in front of the catalog -- has
// existed since #97 and became the DEFAULT in #667. #788's original
// complaint — "a small local model gets the full surface and nobody tells you
// --compact exists" — is therefore already half-answered: nothing gets the full
// surface by accident any more. What is still missing, and what this module
// adds, is the OTHER direction and the visibility:
//
// • a large local model is needlessly held behind the router,
// • an explicit user choice must win in BOTH directions,
// • and whichever mode is active must be SAID, not inferred.
//
// The issue's binding constraint: key it on the MODEL, not the provider.
// "Ollama ⇒ compact" is wrong both ways — a 70B local model handles the full
// surface, and some small hosted models would benefit from compact. So the
// signal here is the parameter count carried in the model id, with an explicit
// documented fallback when there isn't one.
//
// Deliberately NOT keyed on the tool COUNT or on any list of tool names: the
// ~30-tool consolidation (#726) rewrites both, and a policy that hard-codes
// either would silently become wrong the day it lands. Callers that want to
// SHOW the surface size read it from the live catalog and append it themselves.
/**
* Smallest parameter count (in billions) at which a model is given the FULL
* tool surface by default.
*
* 70 is not a tuned number and is not presented as one — it is the only figure
* anyone has actually asserted about this axis (#788: "a 70B local model
* handles the full surface fine and would be needlessly crippled"). The other
* end is #97's qwen3:4b, which needed compact. Everything between is untested,
* so the default promotes only what we have a claim for and leaves the rest on
* today's behaviour — no regression, and no invented capability curve.
*
* `COMFYUI_MCP_FULL_SURFACE_MIN_PARAMS_B` moves it, so someone with a 32B can
* try the full surface without patching the source (and can tell us where it
* actually falls off — see #792's quantization/VRAM ladder).
*/
export const DEFAULT_FULL_SURFACE_MIN_PARAMS_B = 70;
export function fullSurfaceMinParamsB(env = process.env) {
const raw = env.COMFYUI_MCP_FULL_SURFACE_MIN_PARAMS_B;
if (raw === undefined)
return DEFAULT_FULL_SURFACE_MIN_PARAMS_B;
const n = Number(raw);
// A garbage value must not silently reshape the tool surface.
return Number.isFinite(n) && n > 0 ? n : DEFAULT_FULL_SURFACE_MIN_PARAMS_B;
}
/**
* Parameter count in BILLIONS parsed from a model id, or null when the id
* carries no such marker.
*
* Handles the shapes that actually appear in Ollama / OpenRouter / LM Studio
* ids: `qwen3:4b`, `llama3.3:70b`, `gpt-oss:120b`, `deepseek-r1:1.5b`,
* `mixtral:8x7b` (MoE — total, not per-expert), and Gemma's `e2b`/`e4b`
* "effective parameter" tags. Ids with no marker at all (`moonshotai/kimi-k2.5`,
* `claude-opus-4`) return null — which is a real answer, not a failure, and the
* caller must treat it as "unknown", never as "small".
*/
export function parseModelParamsB(model) {
const id = model.toLowerCase();
// Mixture-of-experts: "8x7b" is 8 experts of 7B ≈ 56B of weights resident.
const moe = id.match(/(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)\s*b(?![a-z0-9])/);
if (moe) {
const total = Number(moe[1]) * Number(moe[2]);
if (Number.isFinite(total) && total > 0)
return total;
}
// Gemma's effective-parameter tags (e2b / e4b): the RESIDENT cost is the
// effective count, which is the number that matters for how much surface the
// model can hold. Matched before the generic rule so "e4b" is read as 4, not
// as whatever a looser pattern would take.
const eff = id.match(/(?:^|[^a-z0-9])e(\d+(?:\.\d+)?)b(?![a-z0-9])/);
if (eff) {
const n = Number(eff[1]);
if (Number.isFinite(n) && n > 0)
return n;
}
// Generic "<n>b" markers. Take the LARGEST: a tag can carry several numbers
// (`llama-3.3-70b-instruct-q4`), and the parameter count is the big one.
let best = null;
for (const m of id.matchAll(/(?:^|[^a-z0-9.])(\d+(?:\.\d+)?)b(?![a-z0-9])/g)) {
const n = Number(m[1]);
if (Number.isFinite(n) && n > 0 && (best === null || n > best))
best = n;
}
return best;
}
/** Read an explicit tool mode out of an env bag. Anything other than the two
* exact lowercase tokens is NOT an explicit choice (matches cli.ts, which
* treats "FULL" and "verbose" as unset rather than guessing at intent). */
export function explicitToolMode(env) {
const v = env.COMFYUI_MCP_TOOL_MODE;
return v === "full" || v === "compact" ? v : null;
}
/**
* Decide the tool mode for one model, and say why.
*
* Precedence, both directions honoured:
* 1. caller-supplied explicit mode (a spawn spec that pinned it)
* 2. user explicit mode (COMFYUI_MCP_TOOL_MODE / --compact / --full)
* 3. the model's parameter count
* 4. the documented fallback (compact — today's behaviour)
*
* An explicit choice at 1 or 2 is never overridden by 3, in EITHER direction:
* forcing `full` on a 4B model works, and forcing `compact` on a 405B model
* works. That is the whole point of #788's "manual override in both directions".
*/
export function resolveToolModeForModel(opts = {}) {
const env = opts.env ?? process.env;
const model = opts.model?.trim() || undefined;
const callerChoice = opts.callerEnv ? explicitToolMode(opts.callerEnv) : null;
if (callerChoice) {
return {
mode: callerChoice,
source: "caller-explicit",
model,
explain: `Tool mode: ${callerChoice} — pinned for this agent by an explicit COMFYUI_MCP_TOOL_MODE in its spawn config.`,
};
}
const userChoice = explicitToolMode(env);
if (userChoice) {
return {
mode: userChoice,
source: "user-explicit",
model,
explain: `Tool mode: ${userChoice} — you set it (COMFYUI_MCP_TOOL_MODE=${userChoice} / --${userChoice}). Auto-selection is not overriding your choice.`,
};
}
if (!model) {
return {
mode: "compact",
source: "no-model-fallback",
explain: "Tool mode: compact — no model is known at this point, so this lane falls back to the small-model-safe choice. " +
"(The server's own default is full since 0.50.0; auto-selection is deliberately more conservative, because it " +
"cannot rule out a model that would drown in the full surface.) Set COMFYUI_MCP_TOOL_MODE=full to override.",
};
}
const paramsB = parseModelParamsB(model);
if (paramsB === null) {
return {
mode: "compact",
source: "unknown-model-fallback",
model,
explain: `Tool mode: compact — "${model}" carries no parameter count I can read, so this lane falls back to the ` +
`small-model-safe choice rather than the server's full default. ` +
`Set COMFYUI_MCP_TOOL_MODE=full if this model can hold the full tool surface.`,
};
}
const threshold = fullSurfaceMinParamsB(env);
const mode = paramsB >= threshold ? "full" : "compact";
return {
mode,
source: "model-parameters",
model,
paramsB,
explain: mode === "full"
? `Tool mode: full — chosen for this MODEL: "${model}" is ~${paramsB}B parameters, at or above the ${threshold}B full-surface threshold. Set COMFYUI_MCP_TOOL_MODE=compact to force the on-demand router instead.`
: `Tool mode: compact — chosen for this MODEL: "${model}" is ~${paramsB}B parameters, below the ${threshold}B full-surface threshold, so tools load on demand instead of all at once. Set COMFYUI_MCP_TOOL_MODE=full to force the full surface (or raise COMFYUI_MCP_FULL_SURFACE_MIN_PARAMS_B).`,
};
}
//# sourceMappingURL=tool-mode-policy.js.map