modelfunction-cli 0.6.1 → 0.6.2
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 +5 -5
- package/SCHEMA.md +3 -2
- package/lib/compare.js +9 -0
- package/lib/js-tools.js +17 -0
- package/lib/lint.js +30 -7
- package/lib/scan.js +182 -4
- package/mf.js +14 -3
- package/package.json +12 -5
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx -y modelfunction-cli@0.6.
|
|
8
|
+
npx -y modelfunction-cli@0.6.2 --version
|
|
9
9
|
# 或
|
|
10
10
|
npm i -D modelfunction-cli
|
|
11
11
|
npx mf --version
|
|
@@ -82,7 +82,7 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
|
|
|
82
82
|
| `mf audit` | 一次静态审计:lint + 混淆族边界 + 用例覆盖 + 挂载检查,输出可转发的 Markdown/JSON;不调用模型 |
|
|
83
83
|
| `mf lint` | 静态检查工具描述与参数 schema,以及 skills 的 name/description(不调用模型);0.6.1 起含「可选参数祈使句」与「悬空工具名引用」两条 info 规则;重复规则自动合并成一条;有 `profiles` 时给出「这些组不要合并挂载」 |
|
|
84
84
|
| `mf check` | 安装门禁:一个或一包新能力 × 已装能力(`--manifest` / `--manifests` + `--installed`),纯静态、返回 pass/warn、不阻断;门禁语境只对混淆对/必填来源/结构性错误报 warn,其余降 info;也可 `import { check, checkMany } from "modelfunction-cli"` |
|
|
85
|
-
| `mf compare` |
|
|
85
|
+
| `mf compare` | 跨版本比较、干扰归因、混淆矩阵;两份报告都有分组时按组对比;`--fail-on-regression` 发现回归退出 2;`excludeFromBase` 行不参与回归判定 |
|
|
86
86
|
| `mf ablate` | 留一法:去掉某个工具再跑一遍,量化它是否干扰其他工具;`--profile` 限定在某个挂载组内 |
|
|
87
87
|
| `mf cluster` | 聚类工具,建议挂载分组;参数形状拆散的同一族(如 `sessions_*`)列为「同族候选」;并输出「族内边界清单」(每族会互抢的对 + 每个工具可用的区分词);`--out` 把分组写成套件的 `profiles` |
|
|
88
88
|
| `mf exec` | 单工具执行 + 指标(Node / Python),支持 `--allow-read/write/net`、超时、sideEffects 准入 |
|
|
@@ -126,7 +126,7 @@ mf eval --suite mf.suite.json --models internal-ds \
|
|
|
126
126
|
"mcpServers": {
|
|
127
127
|
"modelfunction": {
|
|
128
128
|
"command": "npx",
|
|
129
|
-
"args": ["-y", "modelfunction-cli@0.6.
|
|
129
|
+
"args": ["-y", "modelfunction-cli@0.6.2", "mcp"]
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
}
|
|
@@ -138,7 +138,7 @@ mf eval --suite mf.suite.json --models internal-ds \
|
|
|
138
138
|
|
|
139
139
|
对外 JSON 字段、`check()` 返回结构、MCP 参数与退出码见 [SCHEMA.md](SCHEMA.md),自 0.4.3 起冻结为 v1:v1 内只做加法,重命名或删除字段需 major 并提前两个 minor 或 90 天弃用。`check()` 的类型声明随包发布。
|
|
140
140
|
|
|
141
|
-
`0.6.
|
|
141
|
+
`0.6.2` 待 npm 发布:修复 `param-optional-imperative` 的名词短语/工具动作误报与多出现漏报;`mf compare` 遵守 `excludeFromBase`(对比结果带 `excluded`),新增 `mf compare --fail-on-regression`。`0.6.1` 已发布到 npm(latest):新增 `param-optional-imperative` 与 `desc-dangling-tool` 两条 info lint,并把「具名否定 / 泛化否定 / 可选参数祈使句 / 改名引用 / 话术范围」写进 `skills/mf-cases/SKILL.md`。`0.6.0` 已发布到 npm(latest):token 成为主口径(评分卡新增 `totalTokens`),模型注册表支持 `currency`(CNY/USD,默认 CNY),成本字段改为 `totalCost` / `avgCost` + `costCurrency`(`totalCostCny` / `avgCostCny` 仅在 CNY 时保留),新增 `mf eval --max-tokens <n>` 与 MCP `maxTokens`;`--fail-on-regression` 在基线模型缺失或本轮全部调用失败时改为 exit 2(无法判定 ≠ 无回归),自动选模型时在 stderr 打 note;本地门禁模板改用退出码判定、只在成功 eval 后更新 state,`.cmd` 固定 CRLF。`0.5.5` 含 eval `--retry <n>` / `--min-interval <ms>`、评分卡 `retries`、CI 模板 static 层 `cluster --json`、`templates/local/` 与 `docs/local-gate.md`(内网/非 GitHub 环境的 pre-push 静态门禁、变更门、定时回归)。`0.5.4` 含用例级 `excludeFromBase` / `excludeFromProfiles`、评分卡 `errorKind` / `errorsByKind` / `scoredTotal` / `selectionAccuracyExcludingErrors`、挂载对比错误不计 jammed、audit 分组 `neg%` 与分组 token 用量聚合。`0.5.3` 及更早的能力见下:凭证与本地反馈、usage `ci` 标记、eval `--progress`/`--max-cost`、`mf audit`、`scan --probe` streamable HTTP、MCP 静态工具 `lint`/`check`/`cluster`/`compare`、`checkMany` 一包批量检查、门禁严重度收敛、静态规则性能(真实 100 能力清单 `checkMany` 471ms→40ms)、`installed` 形状校验与 `profiles` 平台形态兼容。示例统一使用 `@0.6.2`。
|
|
142
142
|
|
|
143
143
|
## 数据边界
|
|
144
144
|
|
|
@@ -167,7 +167,7 @@ mf feedback --out out.json # 导出脱敏反馈包(聚合值,不含原始
|
|
|
167
167
|
|
|
168
168
|
```bash
|
|
169
169
|
# 1. 把 mf 挂成 MCP server
|
|
170
|
-
claude mcp add modelfunction -- npx -y modelfunction-cli@0.6.
|
|
170
|
+
claude mcp add modelfunction -- npx -y modelfunction-cli@0.6.2 mcp
|
|
171
171
|
```
|
|
172
172
|
|
|
173
173
|
```text
|
package/SCHEMA.md
CHANGED
|
@@ -116,6 +116,7 @@ skills 只做静态检查,不参与模型评测。
|
|
|
116
116
|
- `--repeat` 时模型对象带 `stability`(翻转用例与多次结果分布)。
|
|
117
117
|
- `--profile` 时每个组含 `profile`、`mounted`、`allInOne`、`delta`、`lifted`、`jammed`、`errors`,以及 `usage`(`inputTokens` / `cachedTokens` / `outputTokens` / `totalTokens` / `cost` / `costCurrency`;CNY 时另带 `costCny`);错误行不参与挂载对比(不会被记成 jammed),每个组的 `reviewed` 是可评分行数。
|
|
118
118
|
- `fingerprint` 由工具与用例内容计算;`mf compare` 用它判断两份报告是否可比。
|
|
119
|
+
- `mf compare` 与 `--fail-on-regression` 会跳过任一报告中标了 `excludeFromBase` 的行;对比结果里的 `excluded` 给出跳过的行数。这些行既不参与回归判定,也不进入对比指标。
|
|
119
120
|
|
|
120
121
|
## 3. 扫描输出(`mf scan`)
|
|
121
122
|
|
|
@@ -144,7 +145,7 @@ report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` /
|
|
|
144
145
|
- `param` 出现在参数级发现上;`pair` 出现在工具对/技能对发现上;`ref` / `similarTo` 出现在悬空工具名引用上。
|
|
145
146
|
- `profilePairs` 只在套件含 `profiles` 时出现。
|
|
146
147
|
- 0.6.1 新增两条 advisory(均为 `info`,不影响 `check()` 的 pass/warn):
|
|
147
|
-
- `param-optional-imperative`:可选参数的描述用了祈使句;用户输入没提供该参数时可能抑制调用。
|
|
148
|
+
- `param-optional-imperative`:可选参数的描述用了祈使句;用户输入没提供该参数时可能抑制调用。0.6.2 起扫描参数名的所有出现位置,英文只认显式“参数/parameter”搭配,名词短语(`Include tool messages`)与工具自身动作(`Send message ... by sessionKey`)不再误报。
|
|
148
149
|
- `desc-dangling-tool`:描述引用了套件中不存在的工具名;`similarTo` 给出共享领域词的现有工具,通常来自改名后遗留的引用。
|
|
149
150
|
|
|
150
151
|
## 5. cluster JSON(`mf cluster --json`)
|
|
@@ -243,7 +244,7 @@ const result = checkMany({ manifests: [/* ... */], installed: [/* ... */] });
|
|
|
243
244
|
| --- | --- |
|
|
244
245
|
| 0 | 成功(`mf check` 的 warn 也是 0) |
|
|
245
246
|
| 1 | 输入/用法错误;`mf compare` 判定不可直接比较 |
|
|
246
|
-
| 2 | `mf eval --fail-under` 未达标、`--fail-on-regression` 发现回退或无法判定(基线模型缺失、本轮全部调用失败)、`--max-cost` / `--max-tokens`
|
|
247
|
+
| 2 | `mf eval --fail-under` 未达标、`--fail-on-regression` 发现回退或无法判定(基线模型缺失、本轮全部调用失败)、`--max-cost` / `--max-tokens` 超限;`mf compare --fail-on-regression` 发现回归 |
|
|
247
248
|
| 3 | `mf exec` 被权限策略拒绝;`mf scan --json` 未找到工具或 skill |
|
|
248
249
|
| 4 | `mf exec` 超时 |
|
|
249
250
|
|
package/lib/compare.js
CHANGED
|
@@ -33,6 +33,7 @@ export function compareScorecards(before, after) {
|
|
|
33
33
|
const confusionCounts = new Map();
|
|
34
34
|
const interferenceMap = new Map();
|
|
35
35
|
let reviewed = 0;
|
|
36
|
+
let excluded = 0;
|
|
36
37
|
let selectionBefore = 0;
|
|
37
38
|
let selectionAfter = 0;
|
|
38
39
|
let passBefore = 0;
|
|
@@ -42,6 +43,10 @@ export function compareScorecards(before, after) {
|
|
|
42
43
|
const rowBefore = rowsBefore.get(id);
|
|
43
44
|
const rowAfter = rowsAfter.get(id);
|
|
44
45
|
if (rowBefore.reviewed === false || rowAfter.reviewed === false) continue;
|
|
46
|
+
if (rowBefore.excludeFromBase || rowAfter.excludeFromBase) {
|
|
47
|
+
excluded += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
45
50
|
reviewed += 1;
|
|
46
51
|
if (rowBefore.selectionCorrect) selectionBefore += 1;
|
|
47
52
|
if (rowAfter.selectionCorrect) selectionAfter += 1;
|
|
@@ -109,6 +114,7 @@ export function compareScorecards(before, after) {
|
|
|
109
114
|
comparable: toolChanges.length === 0 && caseChanges.length === 0 && !executionChanged,
|
|
110
115
|
commonCases: comparableIds.length,
|
|
111
116
|
reviewed,
|
|
117
|
+
excluded,
|
|
112
118
|
metrics: {
|
|
113
119
|
selection: { before: ratio(selectionBefore, reviewed), after: ratio(selectionAfter, reviewed) },
|
|
114
120
|
pass: { before: ratio(passBefore, reviewed), after: ratio(passAfter, reviewed) }
|
|
@@ -246,6 +252,9 @@ export function formatCompare(report) {
|
|
|
246
252
|
}
|
|
247
253
|
lines.push(`[${model.model}] ${model.comparable ? "可比较" : "不可直接比较(工具定义或用例已变更)"}`);
|
|
248
254
|
lines.push(` 共同且未变更用例: ${model.commonCases}(已复核 ${model.reviewed})`);
|
|
255
|
+
if (model.excluded > 0) {
|
|
256
|
+
lines.push(` 排除出回归判定: ${model.excluded} 条(excludeFromBase)`);
|
|
257
|
+
}
|
|
249
258
|
lines.push(` 选择准确率: ${percent(model.metrics.selection.before)} → ${percent(model.metrics.selection.after)}`);
|
|
250
259
|
lines.push(` 通过率: ${percent(model.metrics.pass.before)} → ${percent(model.metrics.pass.after)}`);
|
|
251
260
|
if (model.interference.length > 0) {
|
package/lib/js-tools.js
CHANGED
|
@@ -36,6 +36,23 @@ export function extractJsTools(src, { externals, onUnresolvedName } = {}) {
|
|
|
36
36
|
const parsed = parseArgs(src, match.index + match[0].length - 1);
|
|
37
37
|
if (parsed) push(toolFromArgs(parsed.items, ctx));
|
|
38
38
|
}
|
|
39
|
+
// Plugin registries (e.g. anything-llm) call `aibitat.function({ name,
|
|
40
|
+
// description, parameters })` inside a setup hook instead of a builder.
|
|
41
|
+
for (const match of src.matchAll(/\.function\s*\(/g)) {
|
|
42
|
+
const parsed = parseArgs(src, match.index + match[0].length - 1);
|
|
43
|
+
if (!parsed || !parsed.items[0] || parsed.items[0].t !== "obj") continue;
|
|
44
|
+
const node = parsed.items[0];
|
|
45
|
+
const preset = {};
|
|
46
|
+
const nameNode = node.props.get("name");
|
|
47
|
+
if (!nameNode || nameNode.t !== "str") {
|
|
48
|
+
// Plugin registries often write `name: this.name` inside the setup hook;
|
|
49
|
+
// the literal lives on the enclosing plugin object just above it.
|
|
50
|
+
const before = src.slice(Math.max(0, match.index - 2000), match.index);
|
|
51
|
+
const names = [...before.matchAll(/name\s*:\s*["']([^"']+)["']/g)];
|
|
52
|
+
if (names.length > 0) preset.name = names[names.length - 1][1];
|
|
53
|
+
}
|
|
54
|
+
push(toolFromObject(node, ctx, preset));
|
|
55
|
+
}
|
|
39
56
|
for (const match of src.matchAll(/(?<![.\w$])registerTool\s*\(/g)) {
|
|
40
57
|
const parsed = parseArgs(src, match.index + match[0].length - 1);
|
|
41
58
|
if (parsed) push(toolFromArgs(parsed.items, ctx));
|
package/lib/lint.js
CHANGED
|
@@ -61,10 +61,14 @@ const NO_BOUNDARY_SIMILARITY = 0.5;
|
|
|
61
61
|
// names in sibling descriptions. Both are info-only; they depend on wording.
|
|
62
62
|
const OPTIONAL_HINT =
|
|
63
63
|
/(可选|optional|非必填|如有|如果需要|可提供|if [^.\n]{0,30}\b(provided|available|present|omitted)\b|when [^.\n]{0,30}\b(provided|available|present)\b)/i;
|
|
64
|
-
const IMPERATIVE_EN = /\b(pass|provide|supply|include|set|specify|add|send|put)\b/i;
|
|
65
64
|
const IMPERATIVE_ZH = /(传入|传|提供|带上|设置|指定|填写|附上|注明)/;
|
|
66
|
-
|
|
65
|
+
// `Include tool messages` is a noun-phrase switch description, not an
|
|
66
|
+
// imperative; `include` stays out of the start-of-description list.
|
|
67
|
+
const IMPERATIVE_START_EN = /^(pass|provide|supply|set|specify|add|send|put)\b/i;
|
|
67
68
|
const IMPERATIVE_START_ZH = /^(传入|传|提供|带上|设置|指定|填写|附上|注明)/;
|
|
69
|
+
// English descriptions routinely start with the tool's own action
|
|
70
|
+
// ("Send message to ... by sessionKey"). Require an explicit parameter link
|
|
71
|
+
// ("... via the title parameter") so tool behaviour is not read as guidance.
|
|
68
72
|
const BACKTICK_ID = /`([a-z][a-z0-9_-]{2,})`/g;
|
|
69
73
|
const SNAKE_ID = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\b/g;
|
|
70
74
|
const NON_TOOL_SUFFIX =
|
|
@@ -89,11 +93,30 @@ function escapeRegExp(value) {
|
|
|
89
93
|
|
|
90
94
|
function mentionsImperatively(text, name) {
|
|
91
95
|
if (!text || !name) return false;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
let index = text.indexOf(name);
|
|
97
|
+
while (index !== -1) {
|
|
98
|
+
const around = text.slice(Math.max(0, index - 80), Math.min(text.length, index + name.length + 80));
|
|
99
|
+
// "optional charts" earlier in a long description must not mask a real
|
|
100
|
+
// imperative later. Only a hint next to this occurrence exempts it.
|
|
101
|
+
const optionalAround = text.slice(
|
|
102
|
+
Math.max(0, index - 30),
|
|
103
|
+
Math.min(text.length, index + name.length + 30)
|
|
104
|
+
);
|
|
105
|
+
if (!OPTIONAL_HINT.test(optionalAround)) {
|
|
106
|
+
if (explicitImperativeEnglish(around, name) || IMPERATIVE_ZH.test(around)) return true;
|
|
107
|
+
}
|
|
108
|
+
index = text.indexOf(name, index + name.length);
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function explicitImperativeEnglish(text, name) {
|
|
114
|
+
const escaped = escapeRegExp(name);
|
|
115
|
+
const pattern = new RegExp(
|
|
116
|
+
`\\b(pass|provide|supply|set|specify|add|send|put)\\b[^.!?\\n]{0,80}(?:^|[^A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])[^.!?\\n]{0,40}\\b(parameter|param|argument|field|option)\\b`,
|
|
117
|
+
"i"
|
|
118
|
+
);
|
|
119
|
+
return pattern.test(text);
|
|
97
120
|
}
|
|
98
121
|
|
|
99
122
|
function startsImperatively(text) {
|
package/lib/scan.js
CHANGED
|
@@ -16,10 +16,21 @@ const SKIP_DIRS = new Set([
|
|
|
16
16
|
"__pycache__",
|
|
17
17
|
".idea",
|
|
18
18
|
".turbo",
|
|
19
|
-
".cache"
|
|
19
|
+
".cache",
|
|
20
|
+
// Not part of the agent's tool surface: generated API specs, examples and
|
|
21
|
+
// test fixtures otherwise look like tools and dominate the report.
|
|
22
|
+
"examples",
|
|
23
|
+
"example",
|
|
24
|
+
"tests",
|
|
25
|
+
"test",
|
|
26
|
+
"e2e",
|
|
27
|
+
"fixtures",
|
|
28
|
+
"swagger"
|
|
20
29
|
]);
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
// Monorepo tool registries often sit 7–8 levels deep
|
|
32
|
+
// (server/utils/agents/<framework>/plugins/<plugin>/file.js).
|
|
33
|
+
const MAX_DEPTH = 8;
|
|
23
34
|
// 5k was too small: a real agent monorepo (openclaw) has 16k files after the
|
|
24
35
|
// skip list, so the walk stopped a third of the way in and reported 26 of its
|
|
25
36
|
// 53 skills. Silent partial results are worse than a slow scan.
|
|
@@ -58,6 +69,7 @@ const CODE_PATTERNS = [
|
|
|
58
69
|
{ label: "registerTool", confidence: "medium", re: /registerTool\s*\(\s*["'`]([\w.-]+)["'`]/ },
|
|
59
70
|
{ label: "server.tool", confidence: "medium", re: /\.tool\s*\(\s*["'`]([\w.-]+)["'`]/ },
|
|
60
71
|
{ label: "py-tool-decorator", confidence: "low", re: /^\s*@tool\b/ },
|
|
72
|
+
{ label: "py-action-decorator", confidence: "medium", re: /^\s*@(?:self\.)?registry\.action\s*\(/ },
|
|
61
73
|
{ label: "vercel-tool-call", confidence: "low", re: /\btool\s*\(\s*\{/ },
|
|
62
74
|
{ label: "openai-tools-array", confidence: "low", re: /\btools\s*:\s*\[\s*\{/ },
|
|
63
75
|
{ label: "anthropic-input-schema", confidence: "low", re: /\binput_?schema\s*:/ }
|
|
@@ -231,13 +243,17 @@ export async function scanProject(root = ".") {
|
|
|
231
243
|
const text = await readText(file, report);
|
|
232
244
|
if (!text) continue;
|
|
233
245
|
if (name.endsWith(".py") && !TEST_PATH.test(rel)) {
|
|
234
|
-
const pythonTools =
|
|
246
|
+
const pythonTools = [
|
|
247
|
+
...scanPythonTools(text),
|
|
248
|
+
...scanPythonClassTools(text),
|
|
249
|
+
...scanPythonActionDecorators(text)
|
|
250
|
+
];
|
|
235
251
|
if (pythonTools.length > 0) {
|
|
236
252
|
addTools(report, pythonTools, {
|
|
237
253
|
source: rel,
|
|
238
254
|
origin: "py-tool",
|
|
239
255
|
confidence: "medium",
|
|
240
|
-
note: "从 @tool
|
|
256
|
+
note: "从 @tool 装饰器、类型注解或 class-based Tool 提取,需人工确认"
|
|
241
257
|
});
|
|
242
258
|
}
|
|
243
259
|
}
|
|
@@ -650,6 +666,168 @@ export function scanPythonTools(text) {
|
|
|
650
666
|
return tools;
|
|
651
667
|
}
|
|
652
668
|
|
|
669
|
+
// Class-based tools: smolagents-style `class X(Tool)` with `name`,
|
|
670
|
+
// `description` and an `inputs` dict. Static only; never executes the class.
|
|
671
|
+
export function scanPythonClassTools(text) {
|
|
672
|
+
const lines = text.split(/\r?\n/);
|
|
673
|
+
const tools = [];
|
|
674
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
675
|
+
const classMatch = lines[i].match(/^(\s*)class\s+\w+\s*\(\s*Tool\s*\)\s*:/);
|
|
676
|
+
if (!classMatch) continue;
|
|
677
|
+
const indent = classMatch[1].length;
|
|
678
|
+
let end = i + 1;
|
|
679
|
+
for (; end < lines.length; end += 1) {
|
|
680
|
+
const line = lines[end];
|
|
681
|
+
if (!line.trim()) continue;
|
|
682
|
+
if (line.match(/^\s*/)[0].length <= indent) break;
|
|
683
|
+
}
|
|
684
|
+
const joined = lines.slice(i + 1, end).join("\n");
|
|
685
|
+
const nameMatch = joined.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
|
|
686
|
+
if (!nameMatch) continue;
|
|
687
|
+
tools.push({
|
|
688
|
+
name: nameMatch[1],
|
|
689
|
+
description: pythonAttrString(joined, "description") || pythonDocstring(lines, i),
|
|
690
|
+
parameters: {
|
|
691
|
+
type: "object",
|
|
692
|
+
properties: pythonInputs(joined)
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
i = end;
|
|
696
|
+
}
|
|
697
|
+
return tools;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Registry actions: browser-use-style `@registry.action("desc", param_model=…)`
|
|
701
|
+
// above an async function. The model class is usually in another file, so the
|
|
702
|
+
// schema is limited to literal function parameters; name + description are
|
|
703
|
+
// still useful for inventory and description lint.
|
|
704
|
+
export function scanPythonActionDecorators(text) {
|
|
705
|
+
const lines = text.split(/\r?\n/);
|
|
706
|
+
const tools = [];
|
|
707
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
708
|
+
const decorator = lines[i].match(/^\s*@(?:self\.)?registry\.action\s*\(/);
|
|
709
|
+
if (!decorator) continue;
|
|
710
|
+
const signature = collectSignature(lines, i, decorator[0].length);
|
|
711
|
+
if (signature === null) continue;
|
|
712
|
+
const description = firstPythonString(signature.text);
|
|
713
|
+
let j = signature.endLine + 1;
|
|
714
|
+
while (j < lines.length && j - signature.endLine <= 6 && !/^\s*(?:async\s+)?def\s+\w+\s*\(/.test(lines[j])) j += 1;
|
|
715
|
+
const def = lines[j]?.match(/^\s*(?:async\s+)?def\s+(\w+)\s*\(/);
|
|
716
|
+
if (!def) continue;
|
|
717
|
+
const args = collectSignature(lines, j, def[0].length);
|
|
718
|
+
const properties = {};
|
|
719
|
+
if (args !== null) {
|
|
720
|
+
for (const part of splitPyParams(args.text)) {
|
|
721
|
+
const param = parsePyParam(part);
|
|
722
|
+
if (!param || param.name === "_" || param.name.startsWith("_")) continue;
|
|
723
|
+
if (["params", "browser_session", "context", "self", "cls"].includes(param.name)) continue;
|
|
724
|
+
properties[param.name] = param.schema;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
tools.push({
|
|
728
|
+
name: def[1],
|
|
729
|
+
description: description || pythonDocstring(lines, j),
|
|
730
|
+
parameters: { type: "object", properties }
|
|
731
|
+
});
|
|
732
|
+
i = args?.endLine ?? signature.endLine;
|
|
733
|
+
}
|
|
734
|
+
return tools;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function firstPythonString(text) {
|
|
738
|
+
const triple = text.match(/(?:[rRuU])?("""|''')([\s\S]*?)\1/);
|
|
739
|
+
if (triple) {
|
|
740
|
+
return triple[2].split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] ?? "";
|
|
741
|
+
}
|
|
742
|
+
const single = text.match(/(?:[rRuU])?["']([^"'\n]*)["']/);
|
|
743
|
+
return single ? single[1] : "";
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function pythonAttrString(text, attr) {
|
|
747
|
+
const triple = text.match(new RegExp(`^\\s*${attr}\\s*=\\s*(?:[rRuU])?("""|''')([\\s\\S]*?)\\1`, "m"));
|
|
748
|
+
if (triple) {
|
|
749
|
+
return triple[2].split(/\r?\n/).map((line) => line.trim()).filter(Boolean)[0] ?? "";
|
|
750
|
+
}
|
|
751
|
+
const single = text.match(new RegExp(`^\\s*${attr}\\s*=\\s*["']([^"']*)["']`, "m"));
|
|
752
|
+
return single ? single[1] : "";
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function pythonInputs(text) {
|
|
756
|
+
const start = text.search(/^\s*inputs\s*=\s*\{/m);
|
|
757
|
+
if (start === -1) return {};
|
|
758
|
+
const open = text.indexOf("{", start);
|
|
759
|
+
const block = balancedBraces(text, open);
|
|
760
|
+
if (!block) return {};
|
|
761
|
+
const properties = {};
|
|
762
|
+
for (const match of block.matchAll(/["']([\w-]+)["']\s*:\s*\{([\s\S]*?)\}/g)) {
|
|
763
|
+
const schema = {};
|
|
764
|
+
const type = match[2].match(/["']type["']\s*:\s*["']([^"']+)["']/);
|
|
765
|
+
const description = match[2].match(/["']description["']\s*:\s*["']([^"']*)["']/);
|
|
766
|
+
const mapped = type ? mapPythonInputType(type[1]) : null;
|
|
767
|
+
if (mapped) schema.type = mapped;
|
|
768
|
+
if (description) schema.description = description[1];
|
|
769
|
+
properties[match[1]] = schema;
|
|
770
|
+
}
|
|
771
|
+
return properties;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function balancedBraces(text, openIndex) {
|
|
775
|
+
if (openIndex < 0) return null;
|
|
776
|
+
let depth = 0;
|
|
777
|
+
let quote = null;
|
|
778
|
+
let triple = null;
|
|
779
|
+
for (let i = openIndex; i < text.length; i += 1) {
|
|
780
|
+
const char = text[i];
|
|
781
|
+
const rest = text.slice(i, i + 3);
|
|
782
|
+
if (triple) {
|
|
783
|
+
if (rest === triple) {
|
|
784
|
+
i += 2;
|
|
785
|
+
triple = null;
|
|
786
|
+
}
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (quote) {
|
|
790
|
+
if (char === "\\") {
|
|
791
|
+
i += 1;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (char === quote) quote = null;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (rest === '"""' || rest === "'''") {
|
|
798
|
+
i += 2;
|
|
799
|
+
triple = rest;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (char === '"' || char === "'") {
|
|
803
|
+
quote = char;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (char === "#") {
|
|
807
|
+
const newline = text.indexOf("\n", i);
|
|
808
|
+
if (newline === -1) break;
|
|
809
|
+
i = newline;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (char === "{") depth += 1;
|
|
813
|
+
else if (char === "}") {
|
|
814
|
+
depth -= 1;
|
|
815
|
+
if (depth === 0) return text.slice(openIndex + 1, i);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function mapPythonInputType(type) {
|
|
822
|
+
const value = String(type).toLowerCase();
|
|
823
|
+
if (["string", "str", "text"].includes(value)) return "string";
|
|
824
|
+
if (["integer", "int", "number", "float"].includes(value)) return "number";
|
|
825
|
+
if (["boolean", "bool"].includes(value)) return "boolean";
|
|
826
|
+
if (["array", "list"].includes(value)) return "array";
|
|
827
|
+
if (["object", "dict"].includes(value)) return "object";
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
653
831
|
function collectSignature(lines, startLine, offset) {
|
|
654
832
|
let depth = 1;
|
|
655
833
|
let out = "";
|
package/mf.js
CHANGED
|
@@ -76,7 +76,7 @@ Usage:
|
|
|
76
76
|
mf audit [--suite <file>] [--out <file>] [--json] # 静态审计:lint + 分组边界 + 用例覆盖 + 挂载
|
|
77
77
|
mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
|
|
78
78
|
mf check (--manifest <file> | --manifests <file>) [--installed <file>] [--json] # 安装门禁:新能力 × 已装能力(--manifests 支持一包)
|
|
79
|
-
mf compare <before.json> <after.json> [--json]
|
|
79
|
+
mf compare <before.json> <after.json> [--fail-on-regression] [--json]
|
|
80
80
|
mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--max-cost <amount>] [--max-tokens <n>] [--progress] [--json]
|
|
81
81
|
mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
|
|
82
82
|
mf exec --entry <file> (--input <json> | --input-json <json>) [--runtime node|python] [--python <cmd>] [--export run] [--allow-read <paths>] [--allow-write <paths>] [--allow-net <hosts>] [--timeout <ms>] [--warmup <n>] [--json]
|
|
@@ -659,6 +659,13 @@ export async function runCompare(options, io = console) {
|
|
|
659
659
|
const after = JSON.parse(await readFile(resolve(afterPath), "utf8"));
|
|
660
660
|
const report = compareScorecards(before, after);
|
|
661
661
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatCompare(report));
|
|
662
|
+
const regressions = report.models
|
|
663
|
+
.filter((model) => model.comparable && model.reviewed > 0)
|
|
664
|
+
.flatMap((model) => (model.regressions ?? []).map((item) => `${model.model}/${item.id}`));
|
|
665
|
+
if (options.failOnRegression && regressions.length > 0) {
|
|
666
|
+
io.error(`regressions: ${regressions.join(", ")}`);
|
|
667
|
+
return 2;
|
|
668
|
+
}
|
|
662
669
|
const changed = report.toolChanges.length > 0 || report.caseChanges.length > 0;
|
|
663
670
|
return changed ? 1 : 0;
|
|
664
671
|
}
|
|
@@ -1285,12 +1292,16 @@ if (isMain) {
|
|
|
1285
1292
|
} else if (argv[0] === "compare") {
|
|
1286
1293
|
const paths = argv.slice(1).filter((arg) => !arg.startsWith("--"));
|
|
1287
1294
|
if (paths.length !== 2) {
|
|
1288
|
-
process.stderr.write("usage: mf compare <before.json> <after.json> [--json]\n");
|
|
1295
|
+
process.stderr.write("usage: mf compare <before.json> <after.json> [--fail-on-regression] [--json]\n");
|
|
1289
1296
|
process.exit(1);
|
|
1290
1297
|
}
|
|
1291
1298
|
commandName = "compare";
|
|
1292
1299
|
try {
|
|
1293
|
-
const code = await runCompare({
|
|
1300
|
+
const code = await runCompare({
|
|
1301
|
+
paths,
|
|
1302
|
+
json: argv.includes("--json"),
|
|
1303
|
+
failOnRegression: argv.includes("--fail-on-regression")
|
|
1304
|
+
});
|
|
1294
1305
|
process.exit(code);
|
|
1295
1306
|
} catch (error) {
|
|
1296
1307
|
process.stderr.write(`${redactText(error?.message ?? error)}\n`);
|
package/package.json
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelfunction-cli",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "
|
|
6
|
+
"description": "Function-calling quality gate for AI agents: scan MCP tools/skills, lint description boundaries, and score model tool selection. Local-first, MCP-ready.",
|
|
7
7
|
"keywords": [
|
|
8
|
-
"llm",
|
|
9
8
|
"function-calling",
|
|
10
9
|
"tool-use",
|
|
10
|
+
"tool-selection",
|
|
11
11
|
"mcp",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
12
|
+
"mcp-server",
|
|
13
|
+
"ai-agents",
|
|
14
|
+
"agent-evaluation",
|
|
15
|
+
"llm-eval",
|
|
16
|
+
"quality-gate",
|
|
17
|
+
"developer-tools",
|
|
18
|
+
"ci",
|
|
19
|
+
"openai",
|
|
20
|
+
"claude"
|
|
14
21
|
],
|
|
15
22
|
"bin": {
|
|
16
23
|
"mf": "mf.js",
|