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),
309 lines • 13 kB
JavaScript
/**
* #1969 — an unrecognized argument key used to name only itself.
*
* Trajectories for the in-house panel model are server-VERIFIED, so they
* contain only successful calls. A 2–4B model never sees the error-to-recovery
* transition; a frontier model infers it from sibling tools. The cheap runtime
* substitute is to JOIN the two halves of the refusal the caller already gets:
*
* unrecognized_keys: ["group"]
* expected number, received undefined at group_id
*
* into one line a small model can act on:
*
* Unrecognized key 'group' — did you mean 'group_id'?
*
* This does NOT accept the wrong key (that's #1968's aliases). It only names
* the intended one. Wired through `strictPanelSchema` so both transports see it.
*/
import { safeParse } from "zod";
const COMMON_SUFFIXES = ["_id", "_ids", "_name", "_key"];
/**
* Levenshtein distance. Keys are short (a handful of characters); a full DP
* table is cheaper than importing a package, and this runs only on the
* validation-failure path.
*/
export function editDistance(a, b) {
if (a === b)
return 0;
if (!a.length)
return b.length;
if (!b.length)
return a.length;
const prev = new Array(b.length + 1);
const cur = new Array(b.length + 1);
for (let j = 0; j <= b.length; j++)
prev[j] = j;
for (let i = 1; i <= a.length; i++) {
cur[0] = i;
const ca = a.charCodeAt(i - 1);
for (let j = 1; j <= b.length; j++) {
const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
}
for (let j = 0; j <= b.length; j++)
prev[j] = cur[j];
}
return prev[b.length];
}
function fold(s) {
return s.toLowerCase().replace(/-/g, "_");
}
function compact(s) {
return fold(s).replace(/_/g, "");
}
/** True when one name is the other plus a separator/`_id`-style suffix —
* `group` vs `group_id`, `groupid` vs `group_id`. Not a general thesaurus. */
export function isAffixMatch(a, b) {
const fa = fold(a);
const fb = fold(b);
if (fa === fb)
return true;
const [shorter, longer] = fa.length <= fb.length ? [fa, fb] : [fb, fa];
if (longer.startsWith(`${shorter}_`) || longer.endsWith(`_${shorter}`))
return shorter.length >= 2;
const ca = compact(a);
const cb = compact(b);
const [cs, cl] = ca.length <= cb.length ? [ca, cb] : [cb, ca];
if (cs.length < 3 || !cl.startsWith(cs))
return false;
// Same letters, different separators: `groupid` vs `group_id`.
if (cs === cl)
return true;
return COMMON_SUFFIXES.includes(`_${cl.slice(cs.length)}`);
}
function distanceThreshold(a, b) {
const n = Math.min(a.length, b.length);
if (n <= 3)
return 1;
if (n <= 6)
return 2;
return 3;
}
/**
* Closest known key, or undefined when nothing is close enough to recommend
* OR when two candidates fit equally well.
*
* Prefer an affix match over a mere edit distance, so `group` → `group_id`
* beats a coincidental neighbour of the same length.
*/
export function suggestKnownKey(unknown, candidates, fits, value) {
const folded = fold(unknown);
let tied = [];
let bestScore = Infinity;
for (const c of candidates) {
if (c === unknown)
continue;
const fc = fold(c);
if (fc === folded)
return c;
let score;
if (isAffixMatch(unknown, c)) {
// #1969 review — every affix match is the SAME quality of evidence: the
// name is the other one plus a separator/suffix. Ranking them by length
// difference is not evidence, and it invented a winner — `panel_connect`
// called with `id` affix-matches both `from_node_id` and `to_node_id`,
// and the length tiebreak silently answered "did you mean 'to_node_id'?"
// on what is a coin flip.
score = 0;
}
else {
const d = editDistance(folded, fc);
if (d > distanceThreshold(folded, fc))
continue;
// Affix scores are 0; offset typos so they cannot outrank an affix.
score = 1 + d;
}
if (score < bestScore) {
bestScore = score;
tied = [c];
}
else if (score === bestScore) {
tied.push(c);
}
}
if (tied.length === 1)
return tied[0];
// The NAMES tie, so let the VALUE break it — but only when exactly one
// candidate would accept what the caller actually sent. `panel_edit_node`
// carries both `node_id` and `node_ids`, so `node: 5` ties on the name and
// is decided by the 5 (a scalar is not a `node_ids`). `panel_connect` called
// with `id: 5` stays ambiguous, because a number fits both endpoints — and
// there, naming one is a guess wearing the same phrasing as a real match.
if (tied.length > 1 && fits) {
const accepting = tied.filter((c) => fits(c, value));
if (accepting.length === 1)
return accepting[0];
}
return undefined;
}
function providedKeys(input) {
if (!input || typeof input !== "object" || Array.isArray(input))
return new Set();
return new Set(Object.keys(input));
}
function valueAt(input, key) {
if (!input || typeof input !== "object" || Array.isArray(input))
return undefined;
try {
return input[key];
}
catch {
// A revoked proxy throws on read. No value is not a fit.
return undefined;
}
}
/**
* Pair each unrecognized key with at most one known key.
*
* 1. Confident match (affix / short edit) against missing required keys.
* 2. Same, against every known key (optional fields included).
* 3. If exactly one unknown and exactly one required key remain, join them
* even when the names are not similar (`todos` vs `items`). That is the
* "information is present but the caller has to join it" case.
*
* Rules 1 and 2 are always confident — they matched on the NAME. Rule 3 is a
* structural coincidence of counts and is confident only when `fits` agrees
* the caller's value would also parse under the target. See the note at the
* rule-3 branch for the surface measurement behind that split.
*/
export function pairUnrecognizedKeys(unknownKeys, knownKeys, requiredKeys, input, fits) {
const suggestions = new Map();
const used = new Set();
const present = providedKeys(input);
const missingRequired = requiredKeys.filter((k) => !present.has(k));
const take = (unknown, pool) => {
const leftover = pool.filter((k) => !used.has(k) && k !== unknown);
const match = suggestKnownKey(unknown, leftover, fits, valueAt(input, unknown));
if (!match)
return;
suggestions.set(unknown, { key: match, confident: true });
used.add(match);
};
for (const k of unknownKeys)
take(k, missingRequired);
for (const k of unknownKeys) {
if (!suggestions.has(k))
take(k, knownKeys);
}
const leftoverUnknown = unknownKeys.filter((k) => !suggestions.has(k));
const leftoverMissing = missingRequired.filter((k) => !used.has(k));
if (leftoverUnknown.length === 1 && leftoverMissing.length === 1) {
const unknown = leftoverUnknown[0];
const target = leftoverMissing[0];
// #1969 review — rule 3 holds because the COUNTS line up, not because the
// names mean the same thing, and 34 of the 95 live panel tools take exactly
// one required key. So without this split every one of them answered any
// stray key with a confident guess: `panel_remove_node {dry_run: true}`
// replied "did you mean 'node_id'?", phrased identically to the true
// `group` → `group_id` case and indistinguishable from it by the 2–4B
// models this whole change exists to serve. Promote to "did you mean" only
// when the VALUE the caller sent also parses under the target's schema —
// which still covers the reporter's `todos` → `items`, because the todo
// array they sent is a valid `items`.
const confident = fits ? fits(target, valueAt(input, unknown)) : false;
suggestions.set(unknown, { key: target, confident });
}
return suggestions;
}
function quoteKey(k) {
return `'${k.replace(/'/g, "\\'")}'`;
}
/**
* #2217 — what a caller is told when NOTHING pairs.
*
* `panel_restart_comfyui {reason}` had no near-miss (`reason` is neither an
* affix nor an edit-neighbour of `force`) and no missing required key, so every
* rule declined and the caller was told what is wrong and nothing about what is
* right. The served JSON schema already lists `properties`, so this adds no
* information the caller lacks — it JOINs it into the refusal, which is what
* #1969 exists to do for the models that will not join it themselves.
*
* Stated in FULL, no cap: measured across the 96 live panel tools the widest
* shape is 14 keys (`panel_edit_node`) and the longest clause is 173
* characters, so eliding entries would risk hiding the very key the caller
* wanted to save a line that is already short.
*/
function acceptedKeysClause(knownKeys) {
// 15 of the 96 panel tools take no arguments at all (`panel_free_vram`,
// `panel_list_workflows`, …). "accepted keys:" followed by nothing would be
// a worse answer than the bare name it replaces.
if (!knownKeys.length)
return "this tool takes no arguments";
return `accepted keys: ${knownKeys.map(quoteKey).join(", ")}`;
}
/** The sentence a caller reads. Never throws — a formatter that throws turns
* a clean validation refusal into a 500. */
export function formatUnrecognizedKeyMessage(keys, knownKeys, requiredKeys, input, fits) {
try {
const unique = [...new Set(keys)];
const paired = pairUnrecognizedKeys(unique, knownKeys, requiredKeys, input, fits);
const messages = unique.map((k) => {
const suggestion = paired.get(k);
if (!suggestion)
return `Unrecognized key ${quoteKey(k)}`;
// An unconfident pairing still carries both halves of the join in one
// sentence — which is the whole ask — but states the schema fact it
// actually knows instead of guessing at intent.
return suggestion.confident
? `Unrecognized key ${quoteKey(k)} — did you mean ${quoteKey(suggestion.key)}?`
: `Unrecognized key ${quoteKey(k)} — required key ${quoteKey(suggestion.key)} is missing`;
});
// Only where nothing at all was said: a pairing — confident or not — has
// already named the one key that matters, and the list would bury it.
if (unique.every((k) => paired.has(k)))
return messages.join("; ");
// The list is a property of the TOOL, not of any one stray key, so it is
// stated once. With a single message it reads as that key's remedy; with
// several it is its own trailing clause rather than one key's footnote.
const clause = acceptedKeysClause(knownKeys);
return messages.length === 1 ? `${messages[0]} — ${clause}` : `${messages.join("; ")}; ${clause}`;
}
catch {
const listed = keys.map((k) => quoteKey(k)).join(", ");
return `Unrecognized key${keys.length > 1 ? "s" : ""} ${listed}`;
}
}
function requiredKeysOf(shape) {
const keys = [];
for (const [k, schema] of Object.entries(shape)) {
try {
// ZodRawShape values are typed as core $ZodType (no `.safeParse` method).
// The top-level helper accepts that shape.
if (!safeParse(schema, undefined).success)
keys.push(k);
}
catch {
// A throwing probe is not evidence the field is required.
}
}
return keys;
}
/**
* Zod 4 `error` map for a strict object. Returns a message only for
* `unrecognized_keys`; every other issue falls through to the locale default.
*/
export function unrecognizedKeysError(shape) {
const knownKeys = Object.keys(shape);
const requiredKeys = requiredKeysOf(shape);
// Runs only on the validation-failure path, and only for the single leftover
// candidate — never across the whole shape.
const fits = (target, value) => {
const schema = shape[target];
if (!schema)
return false;
try {
return safeParse(schema, value).success;
}
catch {
// A throwing probe is not evidence the value fits.
return false;
}
};
return (iss) => {
if (iss.code !== "unrecognized_keys" || !iss.keys?.length)
return undefined;
return formatUnrecognizedKeyMessage(iss.keys, knownKeys, requiredKeys, iss.input, fits);
};
}
//# sourceMappingURL=unrecognized-keys.js.map