modelfunction-cli 0.1.2 → 0.2.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/lib/profile.js ADDED
@@ -0,0 +1,304 @@
1
+ import { pct } from "./score.js";
2
+
3
+ export function normalizeProfiles(raw, toolNames) {
4
+ if (raw === undefined || raw === null) return null;
5
+ if (typeof raw !== "object" || Array.isArray(raw)) {
6
+ throw new Error('suite "profiles" must be an object of { "group": ["tool", ...] }');
7
+ }
8
+ const known = new Set(toolNames);
9
+ const profiles = [];
10
+ for (const [key, list] of Object.entries(raw)) {
11
+ const name = String(key).trim();
12
+ if (!name) throw new Error('suite "profiles": group name must be non-empty');
13
+ if (!Array.isArray(list) || list.length === 0) {
14
+ throw new Error(`profile "${name}": must be a non-empty array of tool names`);
15
+ }
16
+ const tools = [];
17
+ for (const item of list) {
18
+ if (typeof item !== "string" || item.trim() === "") {
19
+ throw new Error(`profile "${name}": tool names must be non-empty strings`);
20
+ }
21
+ const toolName = item.trim();
22
+ if (!known.has(toolName)) throw new Error(`profile "${name}": unknown tool "${toolName}"`);
23
+ if (!tools.includes(toolName)) tools.push(toolName);
24
+ }
25
+ profiles.push({ name, tools });
26
+ }
27
+ if (profiles.length === 0) throw new Error('suite "profiles" is empty');
28
+ return profiles;
29
+ }
30
+
31
+ export function selectProfiles(profiles, wanted) {
32
+ if (!wanted || wanted.length === 0 || wanted.includes("all")) return profiles;
33
+ const byName = new Map(profiles.map((profile) => [profile.name, profile]));
34
+ const missing = wanted.filter((name) => !byName.has(name));
35
+ if (missing.length > 0) {
36
+ throw new Error(
37
+ `unknown profile(s): ${missing.join(", ")}(可用:${profiles.map((profile) => profile.name).join(", ")})`
38
+ );
39
+ }
40
+ return wanted.map((name) => byName.get(name));
41
+ }
42
+
43
+ // A case belongs to a group when its expected tool is mounted there. Negative
44
+ // cases ("no tool applies") hold everywhere: the group's agent still gets them.
45
+ export function selectProfileCases(cases, mountedTools) {
46
+ return cases.filter((testCase) => testCase.expectedTool === null || mountedTools.has(testCase.expectedTool));
47
+ }
48
+
49
+ export function profileWarnings(profiles, suite) {
50
+ const mounted = new Set(profiles.flatMap((profile) => profile.tools));
51
+ const warnings = [];
52
+ const unmounted = suite.tools.map((tool) => tool.name).filter((name) => !mounted.has(name));
53
+ if (unmounted.length > 0) warnings.push(`工具不在任何挂载组: ${unmounted.join(", ")}`);
54
+ const homeless = suite.cases.filter(
55
+ (testCase) => testCase.expectedTool !== null && !mounted.has(testCase.expectedTool)
56
+ ).length;
57
+ if (homeless > 0) warnings.push(`${homeless} 条用例的期望工具不在任何挂载组,不参与分组评分`);
58
+ const everything = suite.tools.length;
59
+ for (const profile of profiles) {
60
+ if (profile.tools.length === everything) {
61
+ warnings.push(`profile "${profile.name}" 挂载了全部工具,与全量对比没有意义`);
62
+ }
63
+ }
64
+ return warnings;
65
+ }
66
+
67
+ export function profileCaseCoverage(profiles, cases) {
68
+ const negatives = cases.filter((testCase) => testCase.expectedTool === null).length;
69
+ return profiles.map((profile) => {
70
+ const mounted = new Set(profile.tools);
71
+ const counts = new Map(profile.tools.map((name) => [name, 0]));
72
+ for (const testCase of cases) {
73
+ if (testCase.expectedTool !== null && mounted.has(testCase.expectedTool)) {
74
+ counts.set(testCase.expectedTool, counts.get(testCase.expectedTool) + 1);
75
+ }
76
+ }
77
+ const missing = [...counts.entries()].filter(([, count]) => count === 0).map(([name]) => name);
78
+ let total = 0;
79
+ for (const count of counts.values()) total += count;
80
+ return { name: profile.name, tools: profile.tools.length, cases: total, negatives, missing };
81
+ });
82
+ }
83
+
84
+ export function analyzeMount(profile, mounted, allInOne) {
85
+ const after = new Map(mounted.rows.map((row) => [row.id, row]));
86
+ const lifted = [];
87
+ const jammed = [];
88
+ for (const before of allInOne.rows) {
89
+ const row = after.get(before.id);
90
+ if (!row || before.reviewed === false || row.reviewed === false) continue;
91
+ if (!before.selectionCorrect && row.selectionCorrect) lifted.push(changeOf(before, row));
92
+ else if (before.selectionCorrect && !row.selectionCorrect) jammed.push(changeOf(before, row));
93
+ }
94
+
95
+ const mountedMetrics = metricsOf(mounted);
96
+ const allMetrics = metricsOf(allInOne);
97
+ const reviewed = mounted.reviewedTotal;
98
+ return {
99
+ profile: profile.name,
100
+ mountedTools: profile.tools.length,
101
+ cases: mounted.total,
102
+ reviewed,
103
+ mounted: mountedMetrics,
104
+ allInOne: allMetrics,
105
+ delta: {
106
+ selection: reviewed ? mountedMetrics.selectionAccuracy - allMetrics.selectionAccuracy : null,
107
+ args:
108
+ mountedMetrics.argAccuracy === null || allMetrics.argAccuracy === null
109
+ ? null
110
+ : mountedMetrics.argAccuracy - allMetrics.argAccuracy,
111
+ pass: reviewed ? mountedMetrics.passRate - allMetrics.passRate : null
112
+ },
113
+ lifted,
114
+ jammed
115
+ };
116
+ }
117
+
118
+ export function modelTotals(entries) {
119
+ let reviewed = 0;
120
+ let mountedCorrect = 0;
121
+ let allCorrect = 0;
122
+ for (const entry of entries) {
123
+ reviewed += entry.reviewed;
124
+ mountedCorrect += entry.mounted.correct;
125
+ allCorrect += entry.allInOne.correct;
126
+ }
127
+ if (reviewed === 0) {
128
+ return {
129
+ reviewed: 0,
130
+ mountedCorrect: 0,
131
+ allCorrect: 0,
132
+ mountedAccuracy: null,
133
+ allAccuracy: null,
134
+ delta: null
135
+ };
136
+ }
137
+ const mountedAccuracy = mountedCorrect / reviewed;
138
+ const allAccuracy = allCorrect / reviewed;
139
+ return {
140
+ reviewed,
141
+ mountedCorrect,
142
+ allCorrect,
143
+ mountedAccuracy,
144
+ allAccuracy,
145
+ delta: mountedAccuracy - allAccuracy
146
+ };
147
+ }
148
+
149
+ export function formatProfileReport(report, { showCases = false } = {}) {
150
+ const lines = [
151
+ `mount compare: ${report.groups.length} 组(profiles ${report.hash})`,
152
+ "组内用例只在对应挂载下评分;all-in-one 用同一批用例、全部工具都挂。"
153
+ ];
154
+
155
+ for (const model of report.models) {
156
+ lines.push("", `[${model.model}]`);
157
+ const rows = model.profiles.map((entry) => [
158
+ entry.profile,
159
+ String(entry.mountedTools),
160
+ String(entry.cases),
161
+ pct(entry.mounted.selectionAccuracy),
162
+ pct(entry.allInOne.selectionAccuracy),
163
+ signedPoints(entry.delta.selection)
164
+ ]);
165
+ lines.push(
166
+ ...renderTable(
167
+ ["profile", "tools", "cases", "mounted", "all-in-one", "delta"],
168
+ rows,
169
+ [1, 2, 3, 4, 5]
170
+ )
171
+ );
172
+ const totals = model.totals;
173
+ if (totals.reviewed > 0) {
174
+ lines.push(
175
+ ` 合计: 分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed})` +
176
+ ` / 全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed})` +
177
+ `,${signedPoints(totals.delta)}`
178
+ );
179
+ }
180
+ const unstable = unstableOf(model.profiles);
181
+ if (unstable.length > 0) lines.push(` 组内不稳定: ${unstable.join(", ")}`);
182
+ if (showCases) {
183
+ for (const entry of model.profiles) {
184
+ for (const item of entry.lifted) {
185
+ lines.push(
186
+ ` + ${entry.profile}/${item.id}: ${item.before ?? "(no tool)"} → ${item.after ?? "(no tool)"}(期望 ${item.expected ?? "(no tool)"})`
187
+ );
188
+ }
189
+ for (const item of entry.jammed) {
190
+ lines.push(
191
+ ` - ${entry.profile}/${item.id}: ${item.before ?? "(no tool)"} → ${item.after ?? "(no tool)"}(期望 ${item.expected ?? "(no tool)"})`
192
+ );
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ if (report.warnings.length > 0) {
199
+ lines.push("", "warnings:");
200
+ for (const warning of report.warnings) lines.push(` - ${warning}`);
201
+ }
202
+ return lines.join("\n");
203
+ }
204
+
205
+ export function formatProfileMarkdown(report) {
206
+ const lines = [
207
+ "## 挂载对比(分组 vs 全量)",
208
+ "",
209
+ "组内用例只在对应挂载下评分;全量 = 同一批用例、全部工具都挂。",
210
+ ""
211
+ ];
212
+ for (const model of report.models) {
213
+ lines.push(
214
+ `### ${model.model}`,
215
+ "",
216
+ "| group | tools | cases | mounted | all-in-one | delta |",
217
+ "| --- | --- | --- | --- | --- | --- |"
218
+ );
219
+ for (const entry of model.profiles) {
220
+ lines.push(
221
+ `| ${entry.profile} | ${entry.mountedTools} | ${entry.cases} | ${pct(entry.mounted.selectionAccuracy)} | ${pct(entry.allInOne.selectionAccuracy)} | ${signedPoints(entry.delta.selection)} |`
222
+ );
223
+ }
224
+ const totals = model.totals;
225
+ lines.push("");
226
+ if (totals.reviewed > 0) {
227
+ lines.push(
228
+ `- 合计:分组 ${pct(totals.mountedAccuracy)}(${totals.mountedCorrect}/${totals.reviewed}),` +
229
+ `全量 ${pct(totals.allAccuracy)}(${totals.allCorrect}/${totals.reviewed}),${signedPoints(totals.delta)}`
230
+ );
231
+ } else {
232
+ lines.push("- 合计:没有已复核用例,无法比较");
233
+ }
234
+ const unstable = unstableOf(model.profiles);
235
+ if (unstable.length > 0) lines.push(`- 组内不稳定:${unstable.join(", ")}`);
236
+ lines.push("");
237
+ }
238
+ if (report.warnings.length > 0) lines.push(`> ${report.warnings.join(";")}`, "");
239
+ return lines.join("\n").trimEnd();
240
+ }
241
+
242
+ function unstableOf(entries) {
243
+ return entries.flatMap((entry) =>
244
+ (entry.stability?.unstable ?? []).map((item) => `${entry.profile}/${item.id}`)
245
+ );
246
+ }
247
+
248
+ function changeOf(before, after) {
249
+ return {
250
+ id: before.id,
251
+ expected: before.expectedTool ?? null,
252
+ before: before.actualTool ?? null,
253
+ after: after.actualTool ?? null
254
+ };
255
+ }
256
+
257
+ function metricsOf(summary) {
258
+ const reviewedRows = summary.rows.filter((row) => row.reviewed !== false);
259
+ const correct = reviewedRows.filter((row) => row.selectionCorrect).length;
260
+ return {
261
+ selectionAccuracy: reviewedRows.length ? correct / reviewedRows.length : null,
262
+ argAccuracy: summary.argAccuracy,
263
+ passRate: reviewedRows.length
264
+ ? reviewedRows.filter((row) => row.passed).length / reviewedRows.length
265
+ : null,
266
+ correct
267
+ };
268
+ }
269
+
270
+ function signedPoints(value) {
271
+ if (value === null || value === undefined) return "n/a";
272
+ const points = Math.round(value * 100);
273
+ return `${points > 0 ? "+" : ""}${points}pp`;
274
+ }
275
+
276
+ function renderTable(headers, rows, numericColumns) {
277
+ const widths = headers.map((header, index) =>
278
+ Math.max(displayWidth(header), ...rows.map((row) => displayWidth(row[index])))
279
+ );
280
+ const line = (cells) =>
281
+ cells
282
+ .map((cell, index) => padDisplay(cell, widths[index], numericColumns.includes(index)))
283
+ .join(" ");
284
+ return [line(headers), widths.map((width) => "-".repeat(width)).join(" "), ...rows.map(line)].map(
285
+ (text) => ` ${text}`
286
+ );
287
+ }
288
+
289
+ function padDisplay(text, width, rightAlign) {
290
+ const fill = " ".repeat(Math.max(0, width - displayWidth(text)));
291
+ return rightAlign ? fill + text : text + fill;
292
+ }
293
+
294
+ export function displayWidth(text) {
295
+ let width = 0;
296
+ for (const char of String(text)) {
297
+ width += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(
298
+ char
299
+ )
300
+ ? 2
301
+ : 1;
302
+ }
303
+ return width;
304
+ }
package/lib/scan.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
- import { basename, join, relative, resolve } from "node:path";
2
+ import { basename, extname, join, relative, resolve } from "node:path";
3
3
 
4
4
  const SKIP_DIRS = new Set([
5
5
  "node_modules",
@@ -43,6 +43,10 @@ const OPENAPI_PATTERN = /^(openapi|swagger)[^/]*\.json$/i;
43
43
  const SKILL_FILE = "skill.md";
44
44
  const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".py"]);
45
45
 
46
+ // Test fixtures and mocks use the same APIs as real tool definitions, so any
47
+ // match there is noise for a tool inventory.
48
+ const TEST_PATH = /(^|[\\/])(__tests__|tests?)([\\/])|\.(test|spec)\.[cm]?[jt]sx?$|(^|[\\/])test_[^\\/]+\.py$|_test\.py$/i;
49
+
46
50
  const CODE_PATTERNS = [
47
51
  { label: "registerTool", confidence: "medium", re: /registerTool\s*\(\s*["'`]([\w.-]+)["'`]/ },
48
52
  { label: "server.tool", confidence: "medium", re: /\.tool\s*\(\s*["'`]([\w.-]+)["'`]/ },
@@ -209,7 +213,21 @@ export async function scanProject(root = ".") {
209
213
  if (!info || info.size > MAX_FILE_BYTES) continue;
210
214
  const text = await readText(file, report);
211
215
  if (!text) continue;
212
- const matches = scanCode(text);
216
+ if (name.endsWith(".py") && !TEST_PATH.test(rel)) {
217
+ const pythonTools = scanPythonTools(text);
218
+ if (pythonTools.length > 0) {
219
+ addTools(report, pythonTools, {
220
+ source: rel,
221
+ origin: "py-tool",
222
+ confidence: "medium",
223
+ note: "从 @tool 装饰器与类型注解提取,需人工确认"
224
+ });
225
+ }
226
+ }
227
+ if (TEST_PATH.test(rel)) continue;
228
+ // Only matches that carry a tool name are actionable; a bare `input_schema:`
229
+ // in API plumbing is not a tool and only inflates the report.
230
+ const matches = scanCode(text).filter((match) => match.name);
213
231
  for (const match of matches) {
214
232
  report.codeMatches.push({
215
233
  source: rel,
@@ -236,13 +254,13 @@ export async function scanProject(root = ".") {
236
254
  return report;
237
255
  }
238
256
 
239
- export function addTools(report, tools, { source, origin, confidence = "high", note }) {
257
+ export function addTools(report, tools, { source, origin, confidence = "high", note, meta }) {
240
258
  const existing = new Set(report.tools.map((tool) => tool.name));
241
259
  const added = [];
242
260
  for (const tool of tools) {
243
261
  if (!tool?.name || existing.has(tool.name)) continue;
244
262
  existing.add(tool.name);
245
- report.tools.push({ ...tool, source, origin });
263
+ report.tools.push({ ...tool, source, origin, ...(meta ?? {}) });
246
264
  added.push(tool.name);
247
265
  }
248
266
  report.sources.push({
@@ -266,7 +284,7 @@ export function redactReport(report) {
266
284
  }
267
285
 
268
286
  export function buildSuiteDraft(report) {
269
- return {
287
+ const draft = {
270
288
  name: `${basename(report.root)}-draft`,
271
289
  description: "由 mf scan 生成。请人工确认工具描述与参数,并补充 cases 后再运行 mf eval。",
272
290
  tools: report.tools.map((tool) => ({
@@ -276,6 +294,34 @@ export function buildSuiteDraft(report) {
276
294
  })),
277
295
  cases: []
278
296
  };
297
+ if (report.skills.length > 0) draft.skills = report.skills.map((skill) => skill.name);
298
+ // Where a tool came from is the only mount hint the scan can observe without
299
+ // guessing: one MCP server or one spec file is normally one thing agents mount.
300
+ const profiles = groupBySource(report.tools);
301
+ if (profiles) draft.profiles = profiles;
302
+ return draft;
303
+ }
304
+
305
+ function groupBySource(tools) {
306
+ const groups = new Map();
307
+ const owner = new Map();
308
+ for (const tool of tools) {
309
+ const key = tool.provider ?? tool.source ?? "";
310
+ if (!key) continue;
311
+ let label = profileLabel(key);
312
+ if (owner.has(label) && owner.get(label) !== key) label = String(key).replace(/[\\/]+/g, "-");
313
+ owner.set(label, key);
314
+ if (!groups.has(label)) groups.set(label, []);
315
+ groups.get(label).push(tool.name);
316
+ }
317
+ if (groups.size < 2) return null;
318
+ return Object.fromEntries([...groups.entries()].sort(([a], [b]) => a.localeCompare(b)));
319
+ }
320
+
321
+ function profileLabel(key) {
322
+ const text = String(key);
323
+ if (!/[\\/]|\.(json|ya?ml|toml|js|mjs|ts|py)$/i.test(text)) return text;
324
+ return basename(text, extname(text)) || text;
279
325
  }
280
326
 
281
327
  export function formatScanSummary(report) {
@@ -480,6 +526,140 @@ function parseFrontmatter(text) {
480
526
  return meta;
481
527
  }
482
528
 
529
+ // LangChain-style tool definitions: @tool above a typed function. The runtime
530
+ // builds the JSON schema from the same annotations, so this is the real schema,
531
+ // not a guess — only the description can drift (first docstring line vs args_schema).
532
+ export function scanPythonTools(text) {
533
+ const lines = text.split(/\r?\n/);
534
+ const tools = [];
535
+ for (let i = 0; i < lines.length; i += 1) {
536
+ if (!/^\s*@tool\b/.test(lines[i])) continue;
537
+ let j = i + 1;
538
+ while (j < lines.length && j - i <= 4 && !/^\s*(async\s+)?def\s+\w+\s*\(/.test(lines[j])) j += 1;
539
+ const defMatch = lines[j]?.match(/^\s*(?:async\s+)?def\s+(\w+)\s*\(/);
540
+ if (!defMatch) continue;
541
+ const signature = collectSignature(lines, j, defMatch[0].length);
542
+ if (signature === null) continue;
543
+ const properties = {};
544
+ const required = [];
545
+ for (const part of splitPyParams(signature.text)) {
546
+ const param = parsePyParam(part);
547
+ if (!param) continue;
548
+ properties[param.name] = param.schema;
549
+ if (param.required) required.push(param.name);
550
+ }
551
+ tools.push({
552
+ name: defMatch[1],
553
+ description: pythonDocstring(lines, signature.endLine),
554
+ parameters: {
555
+ type: "object",
556
+ properties,
557
+ ...(required.length > 0 ? { required } : {})
558
+ }
559
+ });
560
+ i = signature.endLine;
561
+ }
562
+ return tools;
563
+ }
564
+
565
+ function collectSignature(lines, startLine, offset) {
566
+ let depth = 1;
567
+ let out = "";
568
+ for (let i = startLine; i < lines.length; i += 1) {
569
+ const line = lines[i];
570
+ for (let c = i === startLine ? offset : 0; c < line.length; c += 1) {
571
+ const ch = line[c];
572
+ if (ch === "(") depth += 1;
573
+ else if (ch === ")") {
574
+ depth -= 1;
575
+ if (depth === 0) return { text: out, endLine: i };
576
+ }
577
+ out += ch;
578
+ }
579
+ out += "\n";
580
+ }
581
+ return null;
582
+ }
583
+
584
+ function splitPyParams(text) {
585
+ const parts = [];
586
+ let depth = 0;
587
+ let quote = null;
588
+ let current = "";
589
+ for (const ch of text) {
590
+ if (quote) {
591
+ current += ch;
592
+ if (ch === quote) quote = null;
593
+ continue;
594
+ }
595
+ if (ch === '"' || ch === "'") {
596
+ quote = ch;
597
+ current += ch;
598
+ continue;
599
+ }
600
+ if (ch === "(" || ch === "[" || ch === "{") depth += 1;
601
+ if (ch === ")" || ch === "]" || ch === "}") depth -= 1;
602
+ if (ch === "," && depth === 0) {
603
+ parts.push(current);
604
+ current = "";
605
+ continue;
606
+ }
607
+ current += ch;
608
+ }
609
+ if (current.trim()) parts.push(current);
610
+ return parts.map((part) => part.trim()).filter(Boolean);
611
+ }
612
+
613
+ const PY_TYPES = {
614
+ str: "string",
615
+ int: "number",
616
+ float: "number",
617
+ bool: "boolean",
618
+ list: "array",
619
+ List: "array",
620
+ dict: "object",
621
+ Dict: "object"
622
+ };
623
+
624
+ function parsePyParam(part) {
625
+ const eq = part.indexOf("=");
626
+ const head = eq === -1 ? part : part.slice(0, eq);
627
+ const hasDefault = eq !== -1;
628
+ const colon = head.indexOf(":");
629
+ const name = (colon === -1 ? head : head.slice(0, colon)).trim().replace(/^\*+/, "");
630
+ if (!name || name === "self" || name === "cls") return null;
631
+ const typeText = colon === -1 ? "" : head.slice(colon + 1).trim();
632
+ const description = (typeText.match(/Annotated\s*\[[^,]+,\s*["']([^"']+)["']/) ?? [])[1] ?? "";
633
+ const schema = { type: pyType(typeText) };
634
+ if (description) schema.description = description;
635
+ return { name, schema, required: !hasDefault };
636
+ }
637
+
638
+ function pyType(typeText) {
639
+ const core = typeText
640
+ .replace(/^Annotated\s*\[\s*([^,\]]+).*/s, "$1")
641
+ .replace(/^Optional\s*\[\s*(.*?)\s*\]$/s, "$1")
642
+ .trim();
643
+ return PY_TYPES[core] ?? (core.endsWith("List") || core.startsWith("list") ? "array" : "string");
644
+ }
645
+
646
+ function pythonDocstring(lines, fromLine) {
647
+ for (let i = fromLine + 1; i < Math.min(lines.length, fromLine + 6); i += 1) {
648
+ const match = lines[i].match(/^\s*(?:[rRuU])?("""|''')\s*(.*)$/);
649
+ if (!match) continue;
650
+ const quote = match[1];
651
+ const rest = match[2];
652
+ const sameLine = rest.includes(quote) ? rest.split(quote)[0] : rest;
653
+ if (sameLine.trim()) return sameLine.trim();
654
+ for (let k = i + 1; k < lines.length; k += 1) {
655
+ const text = lines[k].trim();
656
+ if (!text) continue;
657
+ return text.startsWith(quote) ? "" : text;
658
+ }
659
+ }
660
+ return "";
661
+ }
662
+
483
663
  function scanCode(text) {
484
664
  const matches = [];
485
665
  for (const pattern of CODE_PATTERNS) {
package/lib/score.js CHANGED
@@ -54,6 +54,28 @@ export function matchArgs(actualArgs, expectedArgs) {
54
54
  };
55
55
  }
56
56
 
57
+ // Even at temperature 0 providers can flip a tool call between identical
58
+ // requests. Repeats exist so the scorecard can show that instead of baking one
59
+ // sample into the number.
60
+ export function pickRepresentative(runs) {
61
+ const signature = (run) => (run?.ok ? run.toolCalls?.[0]?.name ?? "(no tool)" : `error: ${run?.error ?? ""}`);
62
+ const counts = new Map();
63
+ for (const run of runs) {
64
+ const key = signature(run);
65
+ counts.set(key, (counts.get(key) ?? 0) + 1);
66
+ }
67
+ let best = null;
68
+ for (const [key, count] of counts) {
69
+ if (!best || count > best.count) best = { key, count };
70
+ }
71
+ const chosen = runs.find((run) => signature(run) === best.key) ?? runs[0];
72
+ return {
73
+ result: chosen,
74
+ unstable: counts.size > 1,
75
+ outcomes: [...counts.entries()].map(([outcome, count]) => ({ outcome, count }))
76
+ };
77
+ }
78
+
57
79
  export function scoreModel({ model, cases, results, pricing }) {
58
80
  const rows = cases.map((testCase, index) => {
59
81
  const result = results[index];
package/lib/suite.js CHANGED
@@ -28,6 +28,8 @@ export async function loadSuite(suitePath, { allowEmptyCases = false } = {}) {
28
28
  path: abs,
29
29
  tools,
30
30
  cases,
31
+ profiles: raw.profiles ?? null,
32
+ skills: Array.isArray(raw.skills) ? raw.skills.map(String) : [],
31
33
  execution:
32
34
  raw.execution && typeof raw.execution === "object" && !Array.isArray(raw.execution)
33
35
  ? { ...raw.execution }