comfyui-mcp 0.23.4 → 0.24.0
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/README.md +48 -9
- package/dist/index.js +55 -5
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/agent-backend.js +18 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/backend-readiness.js +20 -0
- package/dist/orchestrator/backend-readiness.js.map +1 -1
- package/dist/orchestrator/index.js +44 -5
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/ollama-backend.js +668 -0
- package/dist/orchestrator/ollama-backend.js.map +1 -0
- package/dist/services/agent-setup.js +122 -0
- package/dist/services/agent-setup.js.map +1 -0
- package/dist/services/ui-bridge.js +58 -0
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/tools/catalog.js +77 -0
- package/dist/tools/catalog.js.map +1 -0
- package/dist/tools/compact.js +174 -0
- package/dist/tools/compact.js.map +1 -0
- package/dist/tools/index.js +74 -47
- package/dist/tools/index.js.map +1 -1
- package/dist/transport/cli.js +45 -0
- package/dist/transport/cli.js.map +1 -1
- package/package.json +5 -2
- package/scripts/arena-bestof.mjs +88 -0
- package/scripts/arena-graphic.mjs +119 -0
- package/scripts/llm-arena.mjs +553 -0
- package/scripts/panel-smoke.mjs +217 -0
- package/scripts/test-local-llm.mjs +150 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ComfyUI LLM Arena — run a field of local/hosted models through the same
|
|
3
|
+
// real-ComfyUI task set over compact-mode MCP, and produce a leaderboard.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/llm-arena.mjs # default local field via Ollama
|
|
6
|
+
// ARENA_MODELS=gemma4:e4b,qwen3:4b node scripts/llm-arena.mjs
|
|
7
|
+
// OLLAMA_HOST=http://127.0.0.1:11434 # endpoint override
|
|
8
|
+
//
|
|
9
|
+
// Hosted models (any OpenAI-compatible API — DeepSeek, GLM, MiniMax, MiMo,
|
|
10
|
+
// or all of them through OpenRouter):
|
|
11
|
+
// ARENA_API=openai ARENA_BASE_URL=https://openrouter.ai/api/v1 \
|
|
12
|
+
// ARENA_API_KEY=sk-... ARENA_MODELS="deepseek/deepseek-chat,z-ai/glm-4.7" \
|
|
13
|
+
// node scripts/llm-arena.mjs
|
|
14
|
+
// ARENA_OUT=<dir> redirects output (default ./arena-results, results merge).
|
|
15
|
+
//
|
|
16
|
+
// Requirements: `npm run build`, a running ComfyUI with at least one txt2img
|
|
17
|
+
// checkpoint, Ollama with the models pulled. Results land in arena-results/
|
|
18
|
+
// (JSON + share-ready markdown report).
|
|
19
|
+
//
|
|
20
|
+
// TIP: generate_image auto-selects the FIRST local checkpoint when none is
|
|
21
|
+
// set. If your checkpoints folder leads with a non-txt2img model (video/SAM),
|
|
22
|
+
// pin one for the whole arena: COMFYUI_DEFAULT_CHECKPOINT=<file>.safetensors
|
|
23
|
+
//
|
|
24
|
+
// Scoring per scenario: PASS = 2 (task done, verified against ComfyUI),
|
|
25
|
+
// PARTIAL = 1 (right tool ran, outcome incomplete), FAIL = 0.
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { dirname, join } from "node:path";
|
|
29
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
30
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
31
|
+
|
|
32
|
+
const OLLAMA = process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434";
|
|
33
|
+
const MODELS = (process.env.ARENA_MODELS ?? "gemma4:e4b,gemma4:e2b,qwen3:8b,qwen3:4b,llama3.1:8b")
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((m) => m.trim())
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
const MAX_ROUNDS = Number(process.env.ARENA_MAX_ROUNDS ?? 22);
|
|
38
|
+
/** Tier label recorded on this run's models (e.g. "SoTA", "B-tier", "local")
|
|
39
|
+
* so the merged leaderboard can compare classes. */
|
|
40
|
+
const TIER = process.env.ARENA_TIER ?? "local";
|
|
41
|
+
const SCENARIO_TIMEOUT_MS = Number(process.env.ARENA_SCENARIO_TIMEOUT_MS ?? 360_000);
|
|
42
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
43
|
+
const OUT_DIR = process.env.ARENA_OUT ?? join(process.cwd(), "arena-results");
|
|
44
|
+
|
|
45
|
+
const SYSTEM =
|
|
46
|
+
"You control a ComfyUI MCP server through exactly three tools: list_tools (catalog), " +
|
|
47
|
+
"describe_tool (one tool's parameters), call_tool (run a tool by name with args). " +
|
|
48
|
+
"Always look up a tool with describe_tool before running it with call_tool. " +
|
|
49
|
+
"Catalog entries are tool NAMES, not data — complete every task by actually running tools with call_tool.";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Each scenario: task prompt, the underlying tools that count as the "right"
|
|
53
|
+
* primary move, and an optional verify(harnessCall, transcript) ground-truth
|
|
54
|
+
* check the HARNESS runs against ComfyUI itself (never trusts the model).
|
|
55
|
+
*/
|
|
56
|
+
const SCENARIOS = [
|
|
57
|
+
{
|
|
58
|
+
id: "health",
|
|
59
|
+
title: "Server health & GPU report",
|
|
60
|
+
task: "Check whether the ComfyUI server is healthy and tell me the GPU name and how much free VRAM it has.",
|
|
61
|
+
primary: ["health_check", "get_system_stats"],
|
|
62
|
+
verify: async (_call, t) => /(cuda|nvidia|rtx|gtx|radeon|vram)/i.test(t.finalAnswer),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "models",
|
|
66
|
+
title: "Installed checkpoint discovery",
|
|
67
|
+
task: "Find out which checkpoint models are installed on the ComfyUI server and tell me the name of one of them.",
|
|
68
|
+
primary: ["list_local_models"],
|
|
69
|
+
verify: async (call, t) => {
|
|
70
|
+
const res = await call("list_local_models", { model_type: "checkpoints" });
|
|
71
|
+
const names = [...res.matchAll(/([\w.-]+)\.(safetensors|ckpt|sft|gguf)/gi)].map((m) =>
|
|
72
|
+
m[1].toLowerCase(),
|
|
73
|
+
);
|
|
74
|
+
const answer = t.finalAnswer.toLowerCase();
|
|
75
|
+
return names.some((n) => answer.includes(n) || answer.includes(n.slice(0, 12)));
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "registry",
|
|
80
|
+
title: "Custom-node registry search",
|
|
81
|
+
task: "Find a tool that can search for ComfyUI custom node packs, then use it to search for 'controlnet' and tell me the name of one node pack from its results.",
|
|
82
|
+
primary: ["search_custom_nodes", "search_models"],
|
|
83
|
+
verify: async (_call, t) => t.finalAnswer.trim().length > 0,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "queue",
|
|
87
|
+
title: "Queue inspection",
|
|
88
|
+
task: "How many jobs are currently running or pending in the ComfyUI queue? Answer with the numbers.",
|
|
89
|
+
primary: ["get_queue", "health_check", "get_system_stats"],
|
|
90
|
+
verify: async (_call, t) => /\d/.test(t.finalAnswer),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "generate",
|
|
94
|
+
title: "Text-to-image generation + async polling",
|
|
95
|
+
task:
|
|
96
|
+
"Generate a 512x512 image of a red apple on a wooden table. The generation runs asynchronously — " +
|
|
97
|
+
"after starting it, check its job status until it has finished, then tell me the output filename or asset id.",
|
|
98
|
+
primary: ["generate_image", "enqueue_workflow"],
|
|
99
|
+
// right family but incomplete execution (built a workflow, never enqueued)
|
|
100
|
+
partial: ["create_workflow", "dsl_to_workflow"],
|
|
101
|
+
followup: ["get_job_status", "get_history", "list_output_images", "view_image", "list_assets", "get_queue", "generation_stats"],
|
|
102
|
+
verify: async (call, t) => {
|
|
103
|
+
// ground truth: the prompt_id the model started must be done with outputs
|
|
104
|
+
const ids = [...t.toolText.matchAll(/"prompt_id":\s*"([0-9a-f-]{8,})"/g)].map((m) => m[1]);
|
|
105
|
+
if (!ids.length) return false;
|
|
106
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
107
|
+
const status = await call("get_job_status", { prompt_id: ids[ids.length - 1] });
|
|
108
|
+
if (/"done":\s*true/.test(status) && !/"error"/.test(status)) return true;
|
|
109
|
+
if (/"error":/.test(status)) return false;
|
|
110
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
// ── The GAUNTLET — added when the whole SoTA tier tied 10/10 on the base
|
|
116
|
+
// set. Same server-side verification discipline, but these stress parameter
|
|
117
|
+
// fidelity, error recovery, and multi-render state tracking.
|
|
118
|
+
{
|
|
119
|
+
id: "precision",
|
|
120
|
+
title: "Parameter-exact build + render",
|
|
121
|
+
task:
|
|
122
|
+
"Render a txt2img image with the checkpoint v1-5-pruned-emaonly-fp16.safetensors, EXACTLY 12 sampling steps, " +
|
|
123
|
+
"EXACTLY 384x384 pixels, positive prompt 'a green pear on a table'. Wait until it finishes, then report the prompt_id.",
|
|
124
|
+
primary: ["generate_image", "enqueue_workflow"],
|
|
125
|
+
partial: ["create_workflow", "dsl_to_workflow"],
|
|
126
|
+
verify: async (_call, t) => {
|
|
127
|
+
// ground truth from ComfyUI itself: the EXECUTED graph must carry the
|
|
128
|
+
// exact parameters, and the job must have completed with outputs.
|
|
129
|
+
const ids = [...t.toolText.matchAll(/"prompt_id":\s*"([0-9a-f-]{8,})"/g)].map((m) => m[1]);
|
|
130
|
+
for (const id of ids.reverse()) {
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch(`${process.env.COMFYUI_URL ?? "http://127.0.0.1:8188"}/history/${id}`);
|
|
133
|
+
const hist = (await res.json())[id];
|
|
134
|
+
if (!hist?.status?.completed) continue;
|
|
135
|
+
const nodes = Object.values(hist.prompt?.[2] ?? {});
|
|
136
|
+
const steps = nodes.some((n) => n.inputs?.steps === 12);
|
|
137
|
+
const size = nodes.some((n) => n.inputs?.width === 384 && n.inputs?.height === 384);
|
|
138
|
+
if (steps && size) return true;
|
|
139
|
+
} catch {
|
|
140
|
+
/* try the next id */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "breakfix",
|
|
148
|
+
title: "Deliberate failure → diagnose → recover",
|
|
149
|
+
task:
|
|
150
|
+
"First, try to render an image using a checkpoint named exactly 'nonexistent-model.safetensors'. That will fail — " +
|
|
151
|
+
"read the error and explain in ONE sentence why. Then recover: render 'a blue cube' at 512x512 with a checkpoint " +
|
|
152
|
+
"that IS installed, wait for it to complete, and report its prompt_id.",
|
|
153
|
+
primary: ["generate_image", "enqueue_workflow"],
|
|
154
|
+
partial: ["create_workflow"],
|
|
155
|
+
verify: async (call, t) => {
|
|
156
|
+
// (a) the failure actually happened (the bogus name shows up in an error),
|
|
157
|
+
// (b) a real render then completed.
|
|
158
|
+
const sawFailure = /nonexistent-model\.safetensors/.test(t.toolText) && /error|invalid|not (?:found|in)/i.test(t.toolText);
|
|
159
|
+
if (!sawFailure) return false;
|
|
160
|
+
const ids = [...t.toolText.matchAll(/"prompt_id":\s*"([0-9a-f-]{8,})"/g)].map((m) => m[1]);
|
|
161
|
+
for (const id of ids.reverse()) {
|
|
162
|
+
const status = await call("get_job_status", { prompt_id: id });
|
|
163
|
+
if (/"done":\s*true/.test(status) && !/"error"/.test(status)) return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "provenance",
|
|
170
|
+
title: "Generate → find asset → regenerate with override",
|
|
171
|
+
task:
|
|
172
|
+
"Render a 512x512 image of 'a red bicycle' and wait for it to complete. Then find the ASSET it produced " +
|
|
173
|
+
"(the asset registry lists recent assets), and regenerate that asset with a steps=8 override, waiting for the " +
|
|
174
|
+
"second render to complete too. Report both prompt_ids.",
|
|
175
|
+
primary: ["generate_image", "enqueue_workflow"],
|
|
176
|
+
partial: ["create_workflow", "list_assets", "get_asset_metadata"],
|
|
177
|
+
verify: async (call, t) => {
|
|
178
|
+
// regenerate must have actually run, and there must be two DISTINCT
|
|
179
|
+
// completed prompts.
|
|
180
|
+
if (!t.calls.some((c) => c.tool === "regenerate" && c.ok)) return false;
|
|
181
|
+
const ids = [...new Set([...t.toolText.matchAll(/"prompt_id":\s*"([0-9a-f-]{8,})"/g)].map((m) => m[1]))];
|
|
182
|
+
if (ids.length < 2) return false;
|
|
183
|
+
let done = 0;
|
|
184
|
+
for (const id of ids) {
|
|
185
|
+
const status = await call("get_job_status", { prompt_id: id });
|
|
186
|
+
if (/"done":\s*true/.test(status) && !/"error"/.test(status)) done++;
|
|
187
|
+
}
|
|
188
|
+
return done >= 2;
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
// ── The CRUCIBLE — round 3, added when four models tied the gauntlet at
|
|
192
|
+
// 16/16. Custom graph COMPOSITION (no template covers these): one graph
|
|
193
|
+
// with two piped outputs, and a two-stage pipeline chained through the
|
|
194
|
+
// staging tool. All still SD1.5-only and server-verified.
|
|
195
|
+
{
|
|
196
|
+
id: "multiout",
|
|
197
|
+
title: "One graph, two piped outputs",
|
|
198
|
+
task:
|
|
199
|
+
"Build and enqueue ONE single workflow that renders 'a lighthouse at dusk' at 512x512 AND, in the same graph, " +
|
|
200
|
+
"pipes that image through a 2x upscale so the SAME run saves TWO outputs: the 512x512 original and a 1024x1024 " +
|
|
201
|
+
"version. No template does this — compose the graph yourself. Wait for completion and report the prompt_id.",
|
|
202
|
+
primary: ["enqueue_workflow"],
|
|
203
|
+
partial: ["create_workflow", "dsl_to_workflow", "generate_image", "modify_workflow"],
|
|
204
|
+
verify: async (_call, t) => {
|
|
205
|
+
const base = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188";
|
|
206
|
+
const ids = [...new Set([...t.toolText.matchAll(/"prompt_id":\s*"([0-9a-f-]{8,})"/g)].map((m) => m[1]))];
|
|
207
|
+
for (const id of ids.reverse()) {
|
|
208
|
+
try {
|
|
209
|
+
const hist = (await (await fetch(`${base}/history/${id}`)).json())[id];
|
|
210
|
+
if (!hist?.status?.completed) continue;
|
|
211
|
+
// gather every output image of THIS single prompt and read its real
|
|
212
|
+
// pixel size from the PNG header — need two distinct sizes, 2x apart.
|
|
213
|
+
const dims = new Set();
|
|
214
|
+
for (const out of Object.values(hist.outputs ?? {})) {
|
|
215
|
+
for (const img of out.images ?? []) {
|
|
216
|
+
if (img.type !== "output") continue;
|
|
217
|
+
const u = new URL("/view", base);
|
|
218
|
+
u.searchParams.set("filename", img.filename);
|
|
219
|
+
u.searchParams.set("type", "output");
|
|
220
|
+
if (img.subfolder) u.searchParams.set("subfolder", img.subfolder);
|
|
221
|
+
const buf = Buffer.from(await (await fetch(u)).arrayBuffer());
|
|
222
|
+
if (buf.length > 24 && buf.toString("ascii", 1, 4) === "PNG") {
|
|
223
|
+
dims.add(`${buf.readUInt32BE(16)}x${buf.readUInt32BE(20)}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (dims.has("512x512") && dims.has("1024x1024")) return true;
|
|
228
|
+
} catch {
|
|
229
|
+
/* try next id */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: "pipeline",
|
|
237
|
+
title: "Two-stage pipe via output staging",
|
|
238
|
+
task:
|
|
239
|
+
"Two-stage pipeline. Stage 1: render 'a plain wooden mask' at 512x512 and wait for it to finish. Stage 2: run an " +
|
|
240
|
+
"img2img pass over stage 1's ACTUAL output image with the prompt 'an ornate golden mask' and denoise about 0.55, " +
|
|
241
|
+
"and wait for it too. Do NOT guess file paths — use the staging tool that feeds a previous output into the next " +
|
|
242
|
+
"stage's loader. Report both prompt_ids.",
|
|
243
|
+
primary: ["enqueue_workflow", "generate_image"],
|
|
244
|
+
partial: ["create_workflow", "stage_output_as_input"],
|
|
245
|
+
verify: async (call, t) => {
|
|
246
|
+
// the staging tool must have actually run…
|
|
247
|
+
if (!t.calls.some((c) => c.tool === "stage_output_as_input" && c.ok)) return false;
|
|
248
|
+
const base = process.env.COMFYUI_URL ?? "http://127.0.0.1:8188";
|
|
249
|
+
const ids = [...new Set([...t.toolText.matchAll(/"prompt_id":\s*"([0-9a-f-]{8,})"/g)].map((m) => m[1]))];
|
|
250
|
+
if (ids.length < 2) return false;
|
|
251
|
+
// …and the LAST completed prompt must be a real img2img graph: a
|
|
252
|
+
// LoadImage feeding it and a KSampler with partial denoise.
|
|
253
|
+
for (const id of ids.reverse()) {
|
|
254
|
+
try {
|
|
255
|
+
const hist = (await (await fetch(`${base}/history/${id}`)).json())[id];
|
|
256
|
+
if (!hist?.status?.completed) continue;
|
|
257
|
+
const nodes = Object.values(hist.prompt?.[2] ?? {});
|
|
258
|
+
const hasLoad = nodes.some((n) => n.class_type === "LoadImage");
|
|
259
|
+
const partialDenoise = nodes.some(
|
|
260
|
+
(n) => typeof n.inputs?.denoise === "number" && n.inputs.denoise > 0.2 && n.inputs.denoise < 0.9,
|
|
261
|
+
);
|
|
262
|
+
if (hasLoad && partialDenoise) return true;
|
|
263
|
+
break; // only judge the newest completed prompt as stage 2
|
|
264
|
+
} catch {
|
|
265
|
+
/* try next id */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Two API dialects, one arena:
|
|
275
|
+
* - ARENA_API=ollama (default): Ollama-native /api/chat — lets us pin num_ctx.
|
|
276
|
+
* - ARENA_API=openai: any OpenAI-compatible /v1/chat/completions — hosted
|
|
277
|
+
* models (DeepSeek, GLM, MiniMax, MiMo, OpenRouter, ...). Set ARENA_BASE_URL
|
|
278
|
+
* and ARENA_API_KEY. Tool results carry tool_call_id per the OpenAI shape.
|
|
279
|
+
*/
|
|
280
|
+
const API = process.env.ARENA_API ?? "ollama";
|
|
281
|
+
const BASE_URL = process.env.ARENA_BASE_URL ?? `${OLLAMA}/v1`;
|
|
282
|
+
const API_KEY = process.env.ARENA_API_KEY ?? "";
|
|
283
|
+
|
|
284
|
+
async function chat(model, messages, tools) {
|
|
285
|
+
if (API === "openai") {
|
|
286
|
+
const res = await fetch(`${BASE_URL.replace(/\/$/, "")}/chat/completions`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: {
|
|
289
|
+
"content-type": "application/json",
|
|
290
|
+
...(API_KEY ? { authorization: `Bearer ${API_KEY}` } : {}),
|
|
291
|
+
},
|
|
292
|
+
body: JSON.stringify({ model, messages, tools, tool_choice: "auto", temperature: 0 }),
|
|
293
|
+
});
|
|
294
|
+
if (!res.ok) throw new Error(`${BASE_URL} http ${res.status}: ${await res.text()}`);
|
|
295
|
+
const msg = (await res.json()).choices?.[0]?.message;
|
|
296
|
+
if (!msg) throw new Error("openai-compatible response had no choices[0].message");
|
|
297
|
+
return msg;
|
|
298
|
+
}
|
|
299
|
+
const res = await fetch(`${OLLAMA}/api/chat`, {
|
|
300
|
+
method: "POST",
|
|
301
|
+
headers: { "content-type": "application/json" },
|
|
302
|
+
body: JSON.stringify({
|
|
303
|
+
model,
|
|
304
|
+
messages,
|
|
305
|
+
tools,
|
|
306
|
+
stream: false,
|
|
307
|
+
options: { num_ctx: 16384, temperature: 0 },
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
if (!res.ok) throw new Error(`ollama http ${res.status}: ${await res.text()}`);
|
|
311
|
+
return (await res.json()).message;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Tool-result message in the active dialect. */
|
|
315
|
+
function toolResultMessage(toolCall, toolName, content) {
|
|
316
|
+
if (API === "openai") {
|
|
317
|
+
return { role: "tool", tool_call_id: toolCall.id ?? "call_0", content };
|
|
318
|
+
}
|
|
319
|
+
return { role: "tool", tool_name: toolName, content };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function connectMcp() {
|
|
323
|
+
const transport = new StdioClientTransport({
|
|
324
|
+
command: process.execPath,
|
|
325
|
+
args: [join(ROOT, "dist", "index.js")],
|
|
326
|
+
env: {
|
|
327
|
+
...process.env,
|
|
328
|
+
COMFYUI_MCP_TOOL_MODE: "compact",
|
|
329
|
+
COMFYUI_MCP_PANEL_AUTOINSTALL: "0",
|
|
330
|
+
COMFYUI_MCP_AUTOUPDATE: "0",
|
|
331
|
+
LOG_LEVEL: "error",
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const mcp = new Client({ name: "llm-arena", version: "0.0.0" });
|
|
335
|
+
await mcp.connect(transport);
|
|
336
|
+
return mcp;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function textOf(result) {
|
|
340
|
+
return (result.content ?? [])
|
|
341
|
+
.filter((c) => c.type === "text")
|
|
342
|
+
.map((c) => c.text)
|
|
343
|
+
.join("\n");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function runScenario(mcp, ollamaTools, model, scenario) {
|
|
347
|
+
const messages = [
|
|
348
|
+
{ role: "system", content: SYSTEM },
|
|
349
|
+
{ role: "user", content: scenario.task },
|
|
350
|
+
];
|
|
351
|
+
const t = { calls: [], toolText: "", finalAnswer: "", rounds: 0, nudges: 0 };
|
|
352
|
+
const started = Date.now();
|
|
353
|
+
|
|
354
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
355
|
+
if (Date.now() - started > SCENARIO_TIMEOUT_MS) break;
|
|
356
|
+
t.rounds = round + 1;
|
|
357
|
+
let msg;
|
|
358
|
+
try {
|
|
359
|
+
msg = await chat(model, messages, ollamaTools);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
t.finalAnswer = `(harness error: ${err.message})`;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
messages.push(msg);
|
|
365
|
+
|
|
366
|
+
if (!msg.tool_calls?.length) {
|
|
367
|
+
if (!t.calls.some((c) => c.ok) && t.nudges < 2) {
|
|
368
|
+
t.nudges++;
|
|
369
|
+
messages.push({
|
|
370
|
+
role: "user",
|
|
371
|
+
content:
|
|
372
|
+
"You have not successfully run a tool yet. Use describe_tool then call_tool to actually do the task.",
|
|
373
|
+
});
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
t.finalAnswer = msg.content ?? "";
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const tc of msg.tool_calls) {
|
|
381
|
+
const name = tc.function.name;
|
|
382
|
+
let args = tc.function.arguments;
|
|
383
|
+
if (typeof args === "string") {
|
|
384
|
+
try {
|
|
385
|
+
args = JSON.parse(args);
|
|
386
|
+
} catch {
|
|
387
|
+
args = {};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
let text = "";
|
|
391
|
+
let ok = false;
|
|
392
|
+
try {
|
|
393
|
+
const result = await mcp.callTool({ name, arguments: args });
|
|
394
|
+
text = textOf(result);
|
|
395
|
+
ok = !result.isError;
|
|
396
|
+
} catch (err) {
|
|
397
|
+
text = `MCP error: ${err.message}`;
|
|
398
|
+
}
|
|
399
|
+
if (name === "call_tool") {
|
|
400
|
+
const inner = args?.name ?? args?.tool_name ?? "?";
|
|
401
|
+
t.calls.push({ tool: inner, ok });
|
|
402
|
+
console.log(` ${inner} ${ok ? "ok" : "ERR"}`);
|
|
403
|
+
}
|
|
404
|
+
t.toolText += `\n${text}`;
|
|
405
|
+
messages.push(toolResultMessage(tc, name, text.slice(0, 12000)));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const okTools = [...new Set(t.calls.filter((c) => c.ok).map((c) => c.tool))];
|
|
410
|
+
const primaryOk = scenario.primary.some((p) => okTools.includes(p));
|
|
411
|
+
const followupOk = !scenario.followup || scenario.followup.some((p) => okTools.includes(p));
|
|
412
|
+
|
|
413
|
+
// harness-side ground truth, via a direct call_tool (never trusts the model)
|
|
414
|
+
const harnessCall = async (toolName, toolArgs) => {
|
|
415
|
+
const res = await mcp.callTool({ name: "call_tool", arguments: { name: toolName, args: toolArgs } });
|
|
416
|
+
return textOf(res);
|
|
417
|
+
};
|
|
418
|
+
let verified = false;
|
|
419
|
+
if (primaryOk) {
|
|
420
|
+
try {
|
|
421
|
+
verified = await scenario.verify(harnessCall, t);
|
|
422
|
+
} catch {
|
|
423
|
+
verified = false;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const partialOk = primaryOk || (scenario.partial ?? []).some((p) => okTools.includes(p));
|
|
428
|
+
const score = primaryOk && followupOk && verified ? 2 : partialOk ? 1 : 0;
|
|
429
|
+
return {
|
|
430
|
+
scenario: scenario.id,
|
|
431
|
+
score,
|
|
432
|
+
verdict: score === 2 ? "PASS" : score === 1 ? "PARTIAL" : "FAIL",
|
|
433
|
+
rounds: t.rounds,
|
|
434
|
+
nudges: t.nudges,
|
|
435
|
+
seconds: Math.round((Date.now() - started) / 1000),
|
|
436
|
+
okTools,
|
|
437
|
+
finalAnswer: t.finalAnswer.slice(0, 400),
|
|
438
|
+
transcript: messages,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
console.log(`ComfyUI LLM Arena — models: ${MODELS.join(", ")}`);
|
|
444
|
+
const mcp = await connectMcp();
|
|
445
|
+
const { tools } = await mcp.listTools();
|
|
446
|
+
if (tools.length !== 3) {
|
|
447
|
+
console.error(`expected 3 compact meta-tools, got ${tools.length}`);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
const ollamaTools = tools.map((t) => ({
|
|
451
|
+
type: "function",
|
|
452
|
+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
|
|
453
|
+
}));
|
|
454
|
+
|
|
455
|
+
// preflight: ComfyUI reachable?
|
|
456
|
+
const preflight = await mcp.callTool({ name: "call_tool", arguments: { name: "get_system_stats", args: {} } });
|
|
457
|
+
if (preflight.isError) {
|
|
458
|
+
console.error(`ComfyUI is not reachable: ${textOf(preflight).slice(0, 300)}`);
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
let gpu = "unknown GPU";
|
|
462
|
+
try {
|
|
463
|
+
const stats = JSON.parse(textOf(preflight));
|
|
464
|
+
gpu = stats?.devices?.[0]?.name ?? gpu;
|
|
465
|
+
} catch {
|
|
466
|
+
// non-JSON stats — leave the placeholder
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const all = [];
|
|
470
|
+
for (const model of MODELS) {
|
|
471
|
+
console.log(`\n════════ ${model} ════════`);
|
|
472
|
+
const results = [];
|
|
473
|
+
for (const scenario of SCENARIOS) {
|
|
474
|
+
console.log(` ▸ ${scenario.id}`);
|
|
475
|
+
const r = await runScenario(mcp, ollamaTools, model, scenario);
|
|
476
|
+
console.log(` ${r.verdict} (${r.seconds}s, ${r.rounds} rounds, nudges=${r.nudges})`);
|
|
477
|
+
results.push(r);
|
|
478
|
+
}
|
|
479
|
+
const total = results.reduce((s, r) => s + r.score, 0);
|
|
480
|
+
// full transcripts go to their own files; keep the leaderboard JSON light
|
|
481
|
+
mkdirSync(join(OUT_DIR, "transcripts"), { recursive: true });
|
|
482
|
+
for (const r of results) {
|
|
483
|
+
writeFileSync(
|
|
484
|
+
join(OUT_DIR, "transcripts", `${model.replace(/[:/]/g, "_")}-${r.scenario}.json`),
|
|
485
|
+
JSON.stringify(r.transcript, null, 2),
|
|
486
|
+
);
|
|
487
|
+
delete r.transcript;
|
|
488
|
+
}
|
|
489
|
+
all.push({ model, tier: TIER, total, max: SCENARIOS.length * 2, results });
|
|
490
|
+
console.log(` Σ ${model}: ${total}/${SCENARIOS.length * 2}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
await mcp.close();
|
|
494
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// merge with prior runs so the field can be run one model at a time
|
|
498
|
+
mkdirSync(OUT_DIR, { recursive: true });
|
|
499
|
+
const resultsPath = join(OUT_DIR, "arena-results.json");
|
|
500
|
+
if (existsSync(resultsPath)) {
|
|
501
|
+
try {
|
|
502
|
+
const prior = JSON.parse(readFileSync(resultsPath, "utf8"));
|
|
503
|
+
for (const p of prior.leaderboard ?? []) {
|
|
504
|
+
if (!all.some((m) => m.model === p.model)) all.push({ tier: "local", ...p });
|
|
505
|
+
}
|
|
506
|
+
} catch {
|
|
507
|
+
// corrupt prior results — overwrite
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Tie-breakers, in order: score, fewer nudges (didn't need correcting),
|
|
511
|
+
// fewer rounds (efficient tool use), less wall time. All already recorded.
|
|
512
|
+
const nudgesOf = (m) => m.results.reduce((s, r) => s + (r.nudges ?? 0), 0);
|
|
513
|
+
const roundsOf = (m) => m.results.reduce((s, r) => s + (r.rounds ?? 0), 0);
|
|
514
|
+
const secondsOf = (m) => m.results.reduce((s, r) => s + (r.seconds ?? 0), 0);
|
|
515
|
+
all.sort(
|
|
516
|
+
(a, b) =>
|
|
517
|
+
b.total - a.total ||
|
|
518
|
+
nudgesOf(a) - nudgesOf(b) ||
|
|
519
|
+
roundsOf(a) - roundsOf(b) ||
|
|
520
|
+
secondsOf(a) - secondsOf(b),
|
|
521
|
+
);
|
|
522
|
+
writeFileSync(resultsPath, JSON.stringify({ gpu, scenarios: SCENARIOS.map((s) => ({ id: s.id, title: s.title, task: s.task })), leaderboard: all }, null, 2));
|
|
523
|
+
|
|
524
|
+
const md = [];
|
|
525
|
+
md.push(`# ComfyUI LLM Arena`);
|
|
526
|
+
md.push("");
|
|
527
|
+
md.push(
|
|
528
|
+
`Local models driving a real ComfyUI (${gpu}) through [comfyui-mcp](https://github.com/artokun/comfyui-mcp)'s ` +
|
|
529
|
+
`compact tool mode — 3 meta-tools instead of ~200 schemas, so even small models can play. ` +
|
|
530
|
+
`Each model gets the identical task set; results are verified against the ComfyUI server, not the model's claims.`,
|
|
531
|
+
);
|
|
532
|
+
md.push("");
|
|
533
|
+
const header = ["Model", "Tier", ...SCENARIOS.map((s) => s.id), "Score", "Nudges", "Rounds", "Time"];
|
|
534
|
+
md.push(`| ${header.join(" | ")} |`);
|
|
535
|
+
md.push(`|${header.map(() => "---").join("|")}|`);
|
|
536
|
+
const icon = { 2: "✅", 1: "🟡", 0: "❌" };
|
|
537
|
+
for (const m of all) {
|
|
538
|
+
const cells = m.results.map((r) => icon[r.score]);
|
|
539
|
+
md.push(
|
|
540
|
+
`| \`${m.model}\` | ${m.tier ?? "local"} | ${cells.join(" | ")} | **${m.total}/${m.max}** | ${nudgesOf(m)} | ${roundsOf(m)} | ${secondsOf(m)}s |`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
md.push("");
|
|
544
|
+
md.push(`Scenarios: ${SCENARIOS.map((s) => `**${s.id}** (${s.title.toLowerCase()})`).join(" · ")}`);
|
|
545
|
+
md.push("");
|
|
546
|
+
md.push(`✅ task completed & verified · 🟡 right tool, incomplete outcome · ❌ failed`);
|
|
547
|
+
md.push("");
|
|
548
|
+
md.push("Reproduce: `npm run build && node scripts/llm-arena.mjs` — bring your own models via `ARENA_MODELS=...`");
|
|
549
|
+
writeFileSync(join(OUT_DIR, "arena-report.md"), `${md.join("\n")}\n`);
|
|
550
|
+
|
|
551
|
+
console.log(`\n══════ LEADERBOARD ══════`);
|
|
552
|
+
for (const m of all) console.log(`${String(m.total).padStart(2)}/${m.max} ${m.model}`);
|
|
553
|
+
console.log(`\nreport: ${join(OUT_DIR, "arena-report.md")}`);
|