@migor/cli 0.10.2 → 0.10.4

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/dist/cli.js CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  /** agentia CLI 入口:手写参数解析 + 命令分发(零依赖) */
3
- import { readFileSync } from 'node:fs';
4
- import { fileURLToPath } from 'node:url';
5
3
  import { createProject } from './create.js';
6
4
  import { generateCapability } from './generate.js';
7
5
  import { isCapabilityType, isValidName, CAPABILITY_TYPES } from './templates.js';
@@ -13,22 +11,33 @@ import { reportCommand, USAGE as REPORT_USAGE } from './report.js';
13
11
  import { harvestCommand, USAGE as HARVEST_USAGE } from './harvest.js';
14
12
  import { diffCommand, USAGE as DIFF_USAGE } from './diff.js';
15
13
  import { exportCommand, USAGE as EXPORT_USAGE } from './export.js';
14
+ import { viewCommand, USAGE as VIEW_USAGE } from './view.js';
15
+ import { readCliVersion } from './version.js';
16
16
  const USAGE = `agentia —— Agentia 框架命令行工具
17
17
 
18
- 用法:
19
- agentia create <name> [--dir <parent>] 创建项目脚手架(目录 <parent|当前目录>/<name>/)
18
+ 核心工作流:
19
+ agentia create <name> [--dir <parent>] [--minimal]
20
+ 创建项目(默认完整服务;--minimal = 单文件 + 裸工具)
20
21
  agentia g <type> <name> 在当前目录生成能力(别名:generate)
21
22
  type: ${CAPABILITY_TYPES.join(' | ')}
22
23
  agentia dev [-- "问题"] 启动开发模式(常驻 runner + 本地 inspector 面板:
23
24
  在面板里选能力/工作目录、输入 prompt 驱动一次 run,
24
25
  并看它的调用树;改代码或 .md 自动重启)
25
26
  agentia doctor [--json] 装配体检(未登记/悬空能力/命名规范/重复条目)
26
- agentia report <trace.jsonl> [--json] 从 trace 落盘文件生成调优报告(能力耗时/成本/错误率排行)
27
+
28
+ 观测与调优:
29
+ agentia view <trace.jsonl> 落盘 trace → 单文件自包含 HTML(离线可开、可转发)
30
+ ([-o <out.html>] [--open] [--raw] [--limit N] [--trace-id <id>])
31
+ agentia report <trace.jsonl> [--json] 生成调优报告(能力耗时/成本/错误率排行)
32
+ agentia diff <a.jsonl> <b.jsonl> [--json] 两条 trace 的调用树 A/B 比对(有差异时退出码 1)
33
+
34
+ 评测与数据:
27
35
  agentia harvest <trace.jsonl> 把线上 trace 翻成 eval 用例骨架
28
36
  ([--out <file.ts>] [--force] [--failed] [--limit N])
29
- agentia diff <a.jsonl> <b.jsonl> [--json] 两条 trace 的调用树 A/B 比对(有差异时退出码 1)
30
37
  agentia export <trace.jsonl> trace 落盘文件 → 训练数据集(JSONL,一行一份 messages)
31
38
  ([--out <file>] [--ok-only] [--min-score n])
39
+
40
+ 能力包与其他:
32
41
  agentia add <pkg> 安装第三方能力包并登记到 src/registry.ts
33
42
  agentia --help 显示本帮助
34
43
  agentia --version 显示版本(等价 -v)
@@ -40,7 +49,7 @@ name 规则:小写字母开头的小写 kebab-case(如 hello、doc-reviewer
40
49
  `;
41
50
  // 各子命令自己的用法串 —— 同时供 fail() 与「子命令级 --help」使用(单源,不写两遍)。
42
51
  // report / harvest / diff 的用法串在各自模块里(那里本来就有),此处 import 复用。
43
- const CREATE_USAGE = '用法:agentia create <name> [--dir <parent>]';
52
+ const CREATE_USAGE = '用法:agentia create <name> [--dir <parent>] [--minimal]';
44
53
  const G_USAGE = `用法:agentia g <type> <name>(type: ${CAPABILITY_TYPES.join(' | ')})`;
45
54
  const ADD_USAGE = '用法:agentia add <pkg>(npm 包名或本地包路径)';
46
55
  /** 子命令 → 自己的用法串。`--help` 落在子命令上时打它。 */
@@ -54,6 +63,7 @@ const SUB_USAGE = {
54
63
  harvest: HARVEST_USAGE,
55
64
  diff: DIFF_USAGE,
56
65
  export: EXPORT_USAGE,
66
+ view: VIEW_USAGE,
57
67
  add: ADD_USAGE,
58
68
  };
59
69
  function fail(message) {
@@ -61,20 +71,6 @@ function fail(message) {
61
71
  process.exitCode = 1;
62
72
  return 1;
63
73
  }
64
- /**
65
- * CLI 版本 = 本包 package.json 的 `version`(**单源**:`scripts/check-release.mjs` 已保证它
66
- * 与根包一致,所以这里不另存一份常量 —— 常量会漂,读到的文件不会)。
67
- * 源码直跑(`src/cli.ts`)与发布物(`dist/cli.js`)的 `../package.json` 都是包根那一份。
68
- */
69
- function readCliVersion() {
70
- try {
71
- const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
72
- return pkg.version ?? 'unknown';
73
- }
74
- catch {
75
- return 'unknown';
76
- }
77
- }
78
74
  function checkName(name) {
79
75
  if (name === undefined)
80
76
  return false;
@@ -103,6 +99,7 @@ function main(argv) {
103
99
  if (command === 'create') {
104
100
  const name = rest[0];
105
101
  let parent;
102
+ let minimal = false;
106
103
  for (let i = 1; i < rest.length; i += 1) {
107
104
  if (rest[i] === '--dir') {
108
105
  parent = rest[i + 1];
@@ -110,6 +107,9 @@ function main(argv) {
110
107
  return fail('--dir 需要一个目录参数');
111
108
  i += 1;
112
109
  }
110
+ else if (rest[i] === '--minimal') {
111
+ minimal = true;
112
+ }
113
113
  else {
114
114
  return fail(`未知参数:${rest[i]}`);
115
115
  }
@@ -119,7 +119,7 @@ function main(argv) {
119
119
  if (!checkName(name)) {
120
120
  return fail(`非法项目名「${name}」:需匹配小写 kebab-case(如 my-app)`);
121
121
  }
122
- return createProject(name, parent);
122
+ return createProject(name, parent, { minimal });
123
123
  }
124
124
  if (command === 'g' || command === 'generate') {
125
125
  const [type, name, ...extra] = rest;
@@ -185,6 +185,14 @@ function main(argv) {
185
185
  });
186
186
  return 0;
187
187
  }
188
+ if (command === 'view') {
189
+ // 异步命令(读文件/读构建期资源/写文件):同 report 的受理模式
190
+ void viewCommand(rest).catch((e) => {
191
+ console.error(`错误:${e.message}`);
192
+ process.exitCode = 1;
193
+ });
194
+ return 0;
195
+ }
188
196
  if (command === 'add') {
189
197
  const [pkg, ...extra] = rest;
190
198
  if (pkg === undefined || extra.length > 0) {
@@ -0,0 +1,46 @@
1
+ /** create --minimal:单文件 + 裸工具的项目生成(与完整服务模板分文件,避免 create.ts 继续膨胀)。 */
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { minimalMainTs, minimalReadme, projectDotEnv, projectDotEnvExample, projectGitignore, projectPackageJson, projectTsconfig, } from './templates.js';
5
+ import { GUIDE_VERSION_MARKER, readCliVersion } from './version.js';
6
+ /** 与完整模板共享依赖和版本 pin,只收敛 scripts。 */
7
+ export function minimalPackageJson(name) {
8
+ const pkg = JSON.parse(projectPackageJson(name));
9
+ pkg.scripts = {
10
+ ...pkg.scripts,
11
+ dev: 'tsx src/main.ts',
12
+ build: 'tsc -p tsconfig.json',
13
+ start: 'node dist/main.js',
14
+ };
15
+ delete pkg.scripts['start:prod'];
16
+ delete pkg.scripts['start:batch'];
17
+ return `${JSON.stringify(pkg, null, 2)}\n`;
18
+ }
19
+ function write(dir, rel, content) {
20
+ const file = join(dir, rel);
21
+ mkdirSync(dirname(file), { recursive: true });
22
+ writeFileSync(file, content, 'utf8');
23
+ }
24
+ export function createMinimalProject(dir, name, guide) {
25
+ write(dir, 'package.json', minimalPackageJson(name));
26
+ write(dir, 'tsconfig.json', projectTsconfig());
27
+ write(dir, 'src/main.ts', minimalMainTs(name));
28
+ write(dir, 'README.md', minimalReadme(name));
29
+ write(dir, '.gitignore', projectGitignore());
30
+ write(dir, '.env', projectDotEnv());
31
+ write(dir, '.env.example', projectDotEnvExample());
32
+ write(dir, 'AGENTS.md', `${GUIDE_VERSION_MARKER(readCliVersion())}\n${guide}`);
33
+ console.log(`已创建最小项目 ${dir}
34
+
35
+ 后续步骤:
36
+ cd ${dir}
37
+ npm install
38
+ 把 API key 填进 .env(已生成,且已被 .gitignore 忽略)
39
+ npm run dev -- "你的问题" # 一个入口文件 + 一个裸工具,跑一次并退出
40
+
41
+ 构建后运行:npm run typecheck && npm run build && npm start -- "你的问题"
42
+
43
+ 需要 inspector、能力目录/registry 或 HTTP 服务时,用默认完整模板另建项目:
44
+ npx @migor/cli create my-service`);
45
+ return 0;
46
+ }
package/dist/create.js CHANGED
@@ -4,6 +4,8 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { appTs, batchTs, CAPABILITY_DIR_LIST, cleanMjs, copyAssetsMjs, devConfigTs, emptyRegistryTemplate, mainTs, projectDotEnv, projectDotEnvExample, projectGitignore, projectPackageJson, projectReadme, projectTsconfig, readFileToolIndexTs, REGISTRY_PATH, sessionStoreTs, toolIndexTs, } from './templates.js';
6
6
  import { registerCapability } from './registry.js';
7
+ import { GUIDE_VERSION_MARKER, readCliVersion } from './version.js';
8
+ import { createMinimalProject } from './create-minimal.js';
7
9
  function write(dir, rel, content) {
8
10
  const file = join(dir, rel);
9
11
  mkdirSync(dirname(file), { recursive: true });
@@ -27,7 +29,7 @@ function readUsageGuide() {
27
29
  }
28
30
  throw new Error(`找不到 AI 使用说明(docs/usage-guide.md)。查找过:\n ${candidates.join('\n ')}`);
29
31
  }
30
- export function createProject(name, parent) {
32
+ export function createProject(name, parent, options = {}) {
31
33
  const dir = resolve(parent ?? process.cwd(), name);
32
34
  let guide;
33
35
  try {
@@ -52,6 +54,8 @@ export function createProject(name, parent) {
52
54
  return 1;
53
55
  }
54
56
  mkdirSync(dir, { recursive: true });
57
+ if (options.minimal === true)
58
+ return createMinimalProject(dir, name, guide);
55
59
  write(dir, 'package.json', projectPackageJson(name));
56
60
  write(dir, 'tsconfig.json', projectTsconfig());
57
61
  // 装配(app.ts)与启动分离:dev 环要复用 app.ts 的工厂,
@@ -88,12 +92,18 @@ export function createProject(name, parent) {
88
92
  // 生成 .env 却不忽略它,等于把 key 直接送进用户的第一个 commit。
89
93
  write(dir, '.env', projectDotEnv());
90
94
  write(dir, '.env.example', projectDotEnvExample());
91
- // AI 使用说明:让 Claude Code / Cursor / Copilot 等一进项目就拿到权威 API 速查
92
- write(dir, 'AGENTS.md', guide);
95
+ // AI 使用说明:让 Claude Code / Cursor / Copilot 等一进项目就拿到权威 API 速查。
96
+ // ⚠️ 头部带一行**版本标记**(2026-10-16 ref-scaffold-observability-gap):这份说明是
97
+ // `docs/usage-guide.md` 的**快照**,而 CLI **没有任何命令会更新它** —— 升级框架后它会
98
+ // 静默过期,偏偏 AI 就是照它写代码的。有了标记,`agentia doctor` 才能把
99
+ // 「说明是 vX 写的、当前 CLI 是 vY」说出口(此前只能靠人记得对拍)。
100
+ write(dir, 'AGENTS.md', `${GUIDE_VERSION_MARKER(readCliVersion())}\n${guide}`);
93
101
  registerCapability(dir, 'hello', 'tool');
94
- // read-file 的构造器要一个工作目录(DI 注入),而 discover 自动注册的 provider
95
- // **没有 deps** ⇒ 它必须走显式注册(registry.ts 的 providers 里那个 WORKDIR 是它的依赖)。
96
- // 不登记的话 `agentia doctor` 会报「存在但未登记」—— 新生成的工程不该自带一条警告。
102
+ // read-file 的构造器要一个工作目录(DI 注入)。它的依赖声明**写在能力类上**
103
+ //(`static readonly deps = ['WORKDIR']`,discover 会带上),所以默认路由(discover)
104
+ // 下 `app.ts` 不必为它再注册一遍 —— 这里登记是给**显式装配路线**(registry.ts)
105
+ // 用的:那份 providers 里要带上 deps,顺带也让 `agentia doctor` 不报「存在但未登记」
106
+ //(新生成的工程不该自带一条警告)。
97
107
  registerCapability(dir, 'read-file', 'tool', ['WORKDIR']);
98
108
  console.log(`已创建项目 ${dir}
99
109
 
package/dist/doctor.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { CAPABILITY_DIRS, CAPABILITY_DIR_LIST, REGISTRY_PATH, isValidName } from './templates.js';
5
5
  import { legacyLayout, legacyMigrationHint } from './layout.js';
6
+ import { GUIDE_VERSION_RE, readCliVersion } from './version.js';
6
7
  /** 能力入口候选(须与框架 src/toolkit/discover.ts 的 ENTRY_CANDIDATES 一致) */
7
8
  const ENTRY_CANDIDATES = ['index.ts', 'index.mts', 'index.js', 'index.mjs'];
8
9
  /**
@@ -153,6 +154,45 @@ export function doctor(opts = {}) {
153
154
  warnings.push(`${REGISTRY_PATH} 条目 { provide: '${e.token}' } 指向 ${source},但该目录已不存在(悬空条目,请删除条目或恢复目录)`);
154
155
  }
155
156
  }
157
+ // trace 出口未接 → 警告(2026-10-16 ref-scaffold-observability-gap)。
158
+ // 框架**刻意不内建**任何出口(spec §9.3,出口只有 `sinks` 这条缝),所以「缝在那儿、
159
+ // 没人接」不会有任何报错:同步 `/run` 的 trace 随响应回去了,而异步 `/tasks` 的 trace
160
+ // 落进任务库里**没人导出** —— 等你要查「这个任务为什么慢」时它正好不在。
161
+ // 判据只认**代码里的** `sinks:`:整行注释掉的不算(那种情况下出口确实没接上)。
162
+ const appPath = join(cwd, 'src', 'app.ts');
163
+ if (existsSync(appPath)) {
164
+ const code = readFileSync(appPath, 'utf8')
165
+ .split('\n')
166
+ .filter((l) => !l.trimStart().startsWith('//'))
167
+ .join('\n');
168
+ if (/sinks\s*:/.test(code)) {
169
+ oks.push('src/app.ts:已配 trace 出口(sinks)');
170
+ }
171
+ else {
172
+ warnings.push('src/app.ts 未配 sinks ⇒ trace 没有任何出口(异步 /tasks 的 trace 只会躺在任务库里、没人导出);' +
173
+ '接一行 jsonlTraceSink 即可,配方见框架 docs/observability.md');
174
+ }
175
+ }
176
+ // 项目内 AGENTS.md 的版本漂移(2026-10-16 ref-scaffold-observability-gap):它是 `create` 时
177
+ // 拷进来的一份**快照**,之后没有任何命令更新它,而 AI 正是照它写代码 ⇒ 框架升级后
178
+ // 「权威入口」静默过期。有版本标记才对得出来;没标记(旧版脚手架 / 手写)也要说一声。
179
+ const agentsFile = join(cwd, 'AGENTS.md');
180
+ if (existsSync(agentsFile)) {
181
+ const agentsSrc = readFileSync(agentsFile, 'utf8');
182
+ const marker = agentsSrc.match(GUIDE_VERSION_RE);
183
+ const current = readCliVersion();
184
+ if (marker === null) {
185
+ warnings.push(`AGENTS.md 没有版本标记(旧版脚手架生成或手写)⇒ 无法判断它与当前 CLI(v${current})` +
186
+ '是否同一版;升级后它可能与框架 API 已经不一致');
187
+ }
188
+ else if (marker[1] !== current) {
189
+ warnings.push(`AGENTS.md 是 v${marker[1]} 写的,当前 CLI 是 v${current} ⇒ 说明可能已过期` +
190
+ '(AI 照它写代码):重新 create 一份,或对照 docs/usage-guide.md 手工更新');
191
+ }
192
+ else {
193
+ oks.push(`AGENTS.md:版本标记与 CLI 一致(v${current})`);
194
+ }
195
+ }
156
196
  if (json) {
157
197
  const payload = {
158
198
  ok: oks,
package/dist/export.js CHANGED
@@ -130,7 +130,7 @@ export function exportRun(trace) {
130
130
  messages.push({ role: 'user', content: results });
131
131
  }
132
132
  }
133
- // 收尾判定(2026-09-27 ⑪):整棵 trace 一行正文都没有、且本 run 没开 traceContent
133
+ // 收尾判定(2026-09-27 ref-export-training-data):整棵 trace 一行正文都没有、且本 run 没开 traceContent
134
134
  // ⇒ 缺的不只是「终端回合那句话」:**混合回合**(模型先说一句再调工具)的真实正文同样
135
135
  // 不在 trace 里,而只看「本回合有没有 tool_use」是判不出来的(那种回合按定义就是
136
136
  // 「本来没文本」)。两条合取才成立:有正文就不标(合成 / 老 trace 也照顾到),
@@ -138,7 +138,7 @@ export function exportRun(trace) {
138
138
  if (!anyTextRecorded && !textRecorded && !incomplete.includes('assistant-text')) {
139
139
  incomplete.push('assistant-text');
140
140
  }
141
- // SFT 形状(2026-09-27 ⑫):末条是 user ⇒ 这条样本**没有 loss 目标**(训练器会静默
141
+ // SFT 形状(2026-09-27 ref-export-training-data):末条是 user ⇒ 这条样本**没有 loss 目标**(训练器会静默
142
142
  // 丢掉它),而它恰恰是「run 没有终答」的形状(半截 run / 只剩工具往返)。标注出来,
143
143
  // 别让下游以为每条都能直接用:`--ok-only` 挡不住它 —— status ok ≠ 有终答。
144
144
  if (messages[messages.length - 1]?.role === 'user' &&
package/dist/harvest.js CHANGED
@@ -13,8 +13,13 @@
13
13
  import { existsSync } from 'node:fs';
14
14
  import { readFile, writeFile } from 'node:fs/promises';
15
15
  import { extractTrace } from './report.js';
16
- /** trace 不记 assistant 文本 —— 脚本 text 块的统一占位(与框架侧同一个词) */
16
+ /** trace 没开 `traceContent`(或该回合确实没说话)时,脚本 text 块的统一占位(与框架侧同一个词) */
17
17
  const ASSISTANT_TEXT_PLACEHOLDER = '[harvest] assistant 文本未入 trace';
18
+ /** 取本回合的真实 assistant 文本 —— 只认 opt-in 的 `output.text`(与框架侧逐字同形) */
19
+ function assistantTextOf(turn) {
20
+ const text = turn.attributes?.['output.text'];
21
+ return typeof text === 'string' && text !== '' ? text : null;
22
+ }
18
23
  /** 任意值 → 字符串(core/json.ts stringifySafe 的移植) */
19
24
  function stringifySafe(x) {
20
25
  if (typeof x === 'string')
@@ -96,9 +101,15 @@ export function harvestEvalCase(input) {
96
101
  let tuSeq = 0;
97
102
  const steps = mainTurns.map((turn, i) => {
98
103
  const inputs = toolInputsOf(turn);
99
- const content = [
100
- { type: 'text', text: ASSISTANT_TEXT_PLACEHOLDER },
101
- ];
104
+ const text = assistantTextOf(turn);
105
+ const content = [];
106
+ if (text !== null) {
107
+ content.push({ type: 'text', text });
108
+ }
109
+ else if (inputs.length === 0) {
110
+ // 只有「既没文本又没工具」的终端回合才放占位(与框架侧逐字同步)
111
+ content.push({ type: 'text', text: ASSISTANT_TEXT_PLACEHOLDER });
112
+ }
102
113
  for (const e of inputs) {
103
114
  content.push({
104
115
  type: 'tool_use',
@@ -117,11 +128,20 @@ export function harvestEvalCase(input) {
117
128
  });
118
129
  const toolSeq = mainTurns.flatMap((t) => toolInputsOf(t).map((e) => e.tool));
119
130
  const userText = lastUserText(input.messages);
131
+ const textTurns = mainTurns.filter((t) => assistantTextOf(t) !== null).length;
120
132
  const head = [
121
133
  `// ┄┄ harvest 用例骨架:${name}(trace ${oneLine(trace.traceId ?? '')}${input.source ? `,来源 ${oneLine(input.source)}` : ''})┄┄`,
122
134
  '// ⚠️ 脚手架,不是成品 —— 人工核对后再进 CI:',
123
- '// · trace 不记 assistant 文本(llm.turn 只记 usage/事件),脚本里的 text 块是占位;',
124
135
  ];
136
+ if (textTurns > 0) {
137
+ head.push(`// · assistant 文本取自 trace 的 output.text(该 run 开了 traceContent: 'full')` +
138
+ (textTurns < mainTurns.length
139
+ ? `;${mainTurns.length - textTurns} 个回合没有文本(纯 tool_use 回合本来就没文本);`
140
+ : ';'));
141
+ }
142
+ else {
143
+ head.push("// · trace 里没有 assistant 文本(该 run 未开 traceContent: 'full')—— 脚本只含工具调用,", '// 终端回合的 text 是占位;要真文本请在 app 里开 traceContent 后重跑再 harvest;');
144
+ }
125
145
  if (nestedCount > 0) {
126
146
  head.push(`// · 只含主循环 ${mainTurns.length} 个回合;${nestedCount} 个子 agent 嵌套回合已略去`, '// (嵌套回合不走主循环脚本,scriptedClient 驱动不了它们;要覆盖子 agent 请单独写 eval);');
127
147
  }
@@ -175,8 +195,9 @@ function renderEvalFile(opts) {
175
195
  (opts.badLines > 0 ? `,跳过无法解析 ${opts.badLines} 行` : '');
176
196
  const cases = opts.cases.map((c) => c.replace(/\n$/, '').replace(/^/gm, ' ')).join(',\n');
177
197
  return `// 由 \`agentia harvest\` 生成的 eval 用例脚手架(${note})
178
- // ⚠️ 这是脚手架:assistant 文本为占位(trace 不记文本);每个用例里的 expect 是
179
- // 从原 trace 抄录的「实际轨迹」—— 发生过 ≠ 应该发生,人工核对后再进 CI。
198
+ // ⚠️ 这是脚手架:assistant 文本视 trace 而定(run 开了 traceContent: 'full' 则取自
199
+ // output.text,否则只含工具调用);每个用例里的 expect 是
200
+ // 从原 trace 抄录的「实际轨迹」—— 发生过 ≠ 应该发生,人工核对后再进 CI。
180
201
  import assert from 'node:assert/strict';
181
202
  import { defineEval, scriptedClient } from '@migor/agentia';
182
203
 
@@ -276,9 +297,11 @@ export async function harvestCommand(args) {
276
297
  (badLines > 0 ? `;另有 ${badLines} 行无法解析` : ''));
277
298
  }
278
299
  const picked = rows.filter((r) => !onlyFailed || r.failed).slice(0, limit ?? rows.length);
279
- const cases = picked.map((r) => harvestEvalCase({
300
+ const cases = picked.map((r) =>
301
+ // messages 是 harvestEvalCase 的公共入参(签名不动)⇒ 调用点条件展开,不把 undefined 传进去
302
+ harvestEvalCase({
280
303
  trace: r.trace,
281
- messages: r.messages,
304
+ ...(r.messages !== undefined ? { messages: r.messages } : {}),
282
305
  name: `harvest-${r.label}`,
283
306
  source: file,
284
307
  }));
@@ -93,7 +93,15 @@ function eventFull(ev) {
93
93
  */
94
94
  export function playTrace(view, trace) {
95
95
  const spans = Array.isArray(trace?.spans) ? trace.spans : [];
96
- if (!spans.length) return false;
96
+ // ⚠️ 空 spans 也**必须 reset**:playTrace 的契约是「把这个 view 变成这棵树」——
97
+ // 直接 return 会把**上一条 trace 的树**留在页面上,而下拉里选中的是这一条
98
+ // (切换后看到别人的数据,比看到空白更坏;2026-10-21 在 `agentia view` 的多 trace
99
+ // 下拉里实测到:先选一个正常 trace,再选一个 0 spans 的,树原样不动)。
100
+ if (!spans.length) {
101
+ const label = trace?.traceId ? String(trace.traceId) : '空 trace';
102
+ view.reset(`run · ${label}(无 spans)`);
103
+ return false;
104
+ }
97
105
 
98
106
  const byId = new Map(spans.map((s) => [s.spanId, s]));
99
107
  const root = spans.find((s) => s.kind === 'run') || spans[0];
@@ -4,9 +4,22 @@
4
4
  * CLI inspector 与官网 playground 共用同一份实现,避免两处聚合口径漂移。
5
5
  *
6
6
  * 数据来源(与框架侧的具名约定一致):
7
- * - `capability` span(skill / subagent)→ 调用耗时与子孙 usage 聚合(tokens / costUsd);
7
+ * - `capability` span(skill / subagent)→ 调用耗时与子孙 usage 聚合(tokens / costUsd)。
8
+ * ⚠️ **usage 只认顶层 capability**(没有 capability 祖先的):框架侧 capability.usage
9
+ * 是其**子孙 llm.turn 的聚合**(`src/engine/tracer.ts`),嵌套时(`@SubAgent` 套
10
+ * `@Skill`)外层与内层带着同一批 token ⇒ 逐行累加会重复计数。嵌套行仍计
11
+ * calls / errors / 耗时(那是它自己这次调用的真账,不重复),tokens / costUsd
12
+ * 留给顶层行 —— 各行可加总,与 `src/core/trace.ts`「capability 聚合不进
13
+ * totalUsage(避免重复计数)」同一条纪律。没选「从 llm.turn 重新归账」:
14
+ * 手写 / 外来的 trace 可能只在 capability span 上带 usage(没有嵌套的 llm.turn
15
+ * 子孙),重算会把这类成本静默清零。
8
16
  * - `llm.turn` span 上的 `tool.output` 事件(普通工具不建 span)→ 工具耗时与成败,
9
- * 读 `body.durationMs` / `body.ok`。
17
+ * 读 `body.durationMs` / `body.ok`;
18
+ * - `approval.decided` 事件(**审批不建 span**,G7 护栏)→ `guardrail:<工具名>` 一类的
19
+ * 等审批时长与被拒次数,读 `body.tool` / `body.approved` / `body.waitedMs`。
20
+ * 扫面**不限 span kind**:落点因段而异(首段 llm.turn、恢复段 run 根 —— 续跑入口把
21
+ * run 根 id 当 turnId),限 kind = 真实 HITL 主流程(挂起 → 人批 → 恢复)零产出;
22
+ * 不双计:记账点只有一处(executeOneTool),一次决定只记一笔。
10
23
  *
11
24
  * ⚠️ 单条 run 内样本常 < 5,分位没有意义 —— 排行以 total/max 为主,不给 p50/p95
12
25
  * (要看分位请用框架的 `buildRunReport` + `mergeRunReports`)。
@@ -41,6 +54,21 @@ export function summarizeTrace(trace) {
41
54
  };
42
55
 
43
56
  const spans = trace?.spans || [];
57
+ const byId = new Map();
58
+ for (const s of spans) if (s && s.spanId != null) byId.set(s.spanId, s);
59
+ // 该 span 是否嵌套在另一个 capability 之下(沿 parentSpanId 上爬;seen 防手写 trace 的环)
60
+ const hasCapabilityAncestor = (span) => {
61
+ let p = span.parentSpanId;
62
+ const seen = new Set();
63
+ while (p != null && !seen.has(p)) {
64
+ seen.add(p);
65
+ const ps = byId.get(p);
66
+ if (!ps) return false;
67
+ if (ps.kind === 'capability') return true;
68
+ p = ps.parentSpanId;
69
+ }
70
+ return false;
71
+ };
44
72
  for (const span of spans) {
45
73
  if (span.kind === 'capability') {
46
74
  const a = get(`${kindOf(span)}:${span.name}`);
@@ -48,7 +76,7 @@ export function summarizeTrace(trace) {
48
76
  if (span.status === 'error') a.errors += 1;
49
77
  if (span.endedAt != null) addMs(a, span.endedAt - span.startedAt);
50
78
  const u = span.usage;
51
- if (u) {
79
+ if (u && !hasCapabilityAncestor(span)) {
52
80
  a.tokens =
53
81
  (a.tokens || 0) +
54
82
  (u.inputTokens || 0) +
@@ -59,9 +87,28 @@ export function summarizeTrace(trace) {
59
87
  }
60
88
  continue;
61
89
  }
62
- if (span.kind !== 'llm.turn') continue;
90
+ // approval.decided **不限 span kind**(落点因段而异:首段 llm.turn、恢复段 run 根 ——
91
+ // 续跑入口把 run 根 id 当 turnId,见头注);tool.output 只在 llm.turn 上。
92
+ const isTurn = span.kind === 'llm.turn';
63
93
  for (const e of span.events || []) {
64
- if (e.name !== 'tool.output') continue;
94
+ if (e.name === 'approval.decided') {
95
+ // G7 护栏 —— 审批**只是事件**(刻意不建 span),所以它天然不在任何 span 聚合里,
96
+ // 此前「哪个工具卡在审批上、被拒了几次」在排行上完全看不见。这一类补上它:
97
+ // calls = 走到「决定」这一步的次数(批准或被拒都算);
98
+ // errors = **被拒**次数(护栏真挡下了几次 —— 它不是系统错误,但那次工具确实没跑);
99
+ // total/max = 等审批时长(事件的 `waitedMs`)。⚠️ 只有宿主回填了 `requestedAt`
100
+ // 才有这个数 ⇒ 缺就记 0,**不编造**(框架侧刻意不记「没数据」的 0)。
101
+ // ⚠️ 口径边界:只聚 `approval.decided`。只有 `approval.requested` 而始终没恢复的
102
+ // 挂起**不产出** guardrail 行 —— 它没有结论、也没有时长,硬记一格等于编账。
103
+ const b = e.body;
104
+ if (!b || typeof b !== 'object' || typeof b.tool !== 'string') continue;
105
+ const ga = get(`guardrail:${b.tool}`);
106
+ ga.calls += 1;
107
+ if (b.approved === false) ga.errors += 1;
108
+ addMs(ga, typeof b.waitedMs === 'number' ? b.waitedMs : 0);
109
+ continue;
110
+ }
111
+ if (!isTurn || e.name !== 'tool.output') continue;
65
112
  const b = e.body;
66
113
  if (!b || typeof b !== 'object' || typeof b.tool !== 'string') continue;
67
114
  const a = get(`tool:${b.tool}`);