@migor/cli 0.4.2 → 0.5.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/dist/AGENTS.md +72 -3
- package/dist/cli.js +11 -0
- package/dist/harvest.js +279 -0
- package/dist/report.js +2 -2
- package/dist/templates.js +1 -1
- package/package.json +1 -1
package/dist/AGENTS.md
CHANGED
|
@@ -132,6 +132,7 @@ npx @migor/cli doctor # 静态体检(未登记/悬空/命名/重复
|
|
|
132
132
|
| `description` | 描述**何时该拉取**这段文本 |
|
|
133
133
|
| `name` | 缺省取方法名 |
|
|
134
134
|
| `schema` | 模板化入参 schema;缺省空对象(无参资产) |
|
|
135
|
+
| `version` | 资产版本号(git hash / `'v3'` 等);装配期随能力名收集,每次 run 落 run 根 span 的 `prompts.versions` attribute —— 见 §6「提示词版本化」。缺省(无版本)则该能力不进表 |
|
|
135
136
|
|
|
136
137
|
`@Prompt` **只支持方法形态**(标准装饰器下字段拿不到值/类引用)。实例方法与静态方法都**沿继承链**收集(父类的 `@Prompt` 资产子类自动带上)。方法体内用 `asset(import.meta.url, './x.md')` 读同目录长文本。
|
|
137
138
|
|
|
@@ -423,6 +424,18 @@ process.on('SIGTERM', async () => {
|
|
|
423
424
|
| `createOtlpExporter` | OTLP/JSON 导出,零依赖;选项 `OtlpExporterOptions`:`endpoint` / `headers` / `serviceName` / `timeoutMs`(单次导出超时,缺省 10000,非正数 = 不限 —— 裸 fetch 无超时,collector 半开连接会让 run 收尾永久挂起;超时按导出失败处理,不击穿 run) |
|
|
424
425
|
| `metricsSink` | 指标累加器(Prometheus 文本 / OTLP metrics),满足 `TraceSink` 即接入 —— 见 §6「指标」 |
|
|
425
426
|
| `buildRunReport` | 从一条 trace 生成**调优报告**(能力/模型的耗时、token、成本、错误率排行)—— 见 §6「调优报告」 |
|
|
427
|
+
| `Score` | 质量评分:`{ name; value; source?; comment? }` —— LLM-judge / 人工标注 / eval 结论挂到 trace 上;约定 `value` 为 0–1(布尔结论用 0/1),`source` 记评分来源(eval 名 / `'human'` / judge 模型 id) |
|
|
428
|
+
| `attachScore` | `attachScore(trace, score)`:把评分挂到 run 根 span(一条 `score` 事件,body 即 `Score`)。评分通常来自 run **之外**(跑完才评),所以走事件而非 span 字段;trace 找不到根 span 时静默忽略(观测不击穿业务) |
|
|
429
|
+
|
|
430
|
+
**评分链路**(R7 质量闭环):`attachScore` 写 run 根 `score` 事件 → OTLP 导出时译为 `gen_ai.evaluation.result`
|
|
431
|
+
(`gen_ai.evaluation.score.name` / `.value`,`source` / `comment` 走自有 `agentia.score.*` 键)→
|
|
432
|
+
`metricsSink` 聚合成 `agentia_score` 指标族(见 §6「指标」)。eval / 在线评估怎么用见 §6「evals」与「在线评估采样」。
|
|
433
|
+
|
|
434
|
+
**OTLP 的 `gen_ai.*` 对齐**(R7,对齐 OTel GenAI semconv **v1.37**,**additive** —— 只追加 `gen_ai.*` 键,既有 `usage.*` 等键一律保留):
|
|
435
|
+
run 根 → `gen_ai.operation.name=invoke_agent` + `gen_ai.agent.name`(attributes 有 `session.id` 时另发 `gen_ai.conversation.id`);
|
|
436
|
+
`llm.turn` → `gen_ai.operation.name=chat` + `gen_ai.request.model` + `gen_ai.usage.input_tokens` / `output_tokens`;
|
|
437
|
+
capability span 按前缀分:`subagent:*` → `invoke_agent` + `gen_ai.agent.name`,`skill:*` → `execute_tool` + `gen_ai.tool.name`;
|
|
438
|
+
`score` 事件 → `gen_ai.evaluation.result`。映射集中在 `createOtlpExporter` 一处,下游(Langfuse / Grafana / Datadog)按 1.37+ 识别这批键做 GenAI 专项视图。
|
|
426
439
|
|
|
427
440
|
> **生产落地**(按 runId 落库检索 / 日志关联 / 采样 / 脱敏)见 `docs/observability.md` ——
|
|
428
441
|
> 框架只保证 trace 出口,这些都在缝外用 sink 组合;四条现成 sink 的实码在
|
|
@@ -552,6 +565,7 @@ const app = createApp({ system, providers: [...], tools });
|
|
|
552
565
|
- 断言源是既有 `Trace`:「先 `search` 才 `summarize`」这类顺序断言全从 trace 读,框架不为此新增埋点。
|
|
553
566
|
- `run()` **不抛**(用例失败进报告,一次跑完能看到所有回归,而不是修一个跑一次);只有「应用建不起来」才冒泡 —— 那是环境错误,不是回归。失败 case 带 `trace`,直接看现场。
|
|
554
567
|
- `scriptedClient` 的步骤**在 `finalMessage()` 成功返回后才前进**:抛错的步骤(函数步骤 `throw` 模拟 429)会在重试时**重放同一步**,想验重试就这么写。
|
|
568
|
+
- **用例结论自动落 score**(R7):每个用例跑完,结论以 `{ name: 'eval', value: 0|1, source: eval 名, comment: 失败原因 }` 自动 `attachScore` 到该用例的 trace —— eval 的 trace 自带质量结论,下游 sink / `metricsSink` 可直接聚合「这个 eval 的通过率」(`app.run` 抛错拿不到 trace 时不挂)。
|
|
555
569
|
|
|
556
570
|
```ts
|
|
557
571
|
const ev = defineEval<{ summary: string }>({
|
|
@@ -571,6 +585,23 @@ const report = await ev.run();
|
|
|
571
585
|
if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
|
|
572
586
|
```
|
|
573
587
|
|
|
588
|
+
### 线上 trace 回流 eval 数据集(`agentia harvest`)
|
|
589
|
+
|
|
590
|
+
线上事故 → 回归用例:CLI 把 trace 落盘文件翻成 eval 用例脚手架。
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
agentia harvest trace.jsonl # 全部记录 → 脚手架打到 stdout
|
|
594
|
+
agentia harvest trace.jsonl --failed --limit 5 --out evals/harvested.ts
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
- 输入同 `agentia report`:每行一个 JSON(裸 Trace,或含 `result.trace` / `trace` 的 TaskRecord,如 `FileTaskStore` 的导出);`--failed` 只留失败记录。
|
|
598
|
+
- 产物是**可粘贴进 eval 文件的用例字面量**:`client: scriptedClient([...])` 按 trace 的主循环 llm.turn 逐回合重建,`expect` 预填「主循环工具序列」的轨迹断言(文件顶部附跑法注释)。
|
|
599
|
+
- ⚠️ **脚手架不是成品,人工核对后再进 CI**:
|
|
600
|
+
- **trace 不记 assistant 文本**(llm.turn 只记 usage/事件),脚本里的 text 块是占位 `'[harvest] assistant 文本未入 trace'`;
|
|
601
|
+
- 只重建**直属 run 根**的主循环回合 —— 子 agent 的嵌套回合不走主循环脚本(要覆盖子 agent 请单独写 eval);
|
|
602
|
+
- 预填的 `expect` 是从原 trace **抄录的实际轨迹** —— 发生过 ≠ 应该发生;
|
|
603
|
+
- `EvalCase` 没有 `expect` 字段,粘贴时把断言搬进 `defineEval({ expect })`(脚手架注释会教)。
|
|
604
|
+
|
|
574
605
|
### 指标(从 trace 派生)
|
|
575
606
|
|
|
576
607
|
| API | 说明 |
|
|
@@ -578,7 +609,7 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
|
|
|
578
609
|
| `metricsSink` | 进程内累加 + Prometheus 文本 / OTLP metrics;**天然满足 `TraceSink`** → `createApp({ sinks: [metricsSink()] })` 即接入,零新出口 |
|
|
579
610
|
| `DEFAULT_BUCKETS` | 时长直方图的缺省桶边界(毫秒),可用 `buckets` 覆盖 |
|
|
580
611
|
|
|
581
|
-
|
|
612
|
+
四个维度,全部从既有 trace 派生,**不需要在业务代码里埋点**:
|
|
582
613
|
|
|
583
614
|
- **run 级** —— 总数 / 失败数 /四类 token / 成本 / 时长;
|
|
584
615
|
- **能力级** —— 每个 `tool` / `skill` / `subagent` 的**调用次数、失败次数、耗时、token、成本**。
|
|
@@ -586,6 +617,10 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
|
|
|
586
617
|
来自 `capability` span。**`@Prompt` 不建 span、无独立耗时,因此不产出能力指标**(如实缺省,不硬凑)。
|
|
587
618
|
- **模型级** —— 按模型(`llm.turn` 的 span name)归因 turn 数 / token / 成本 / 耗时,并单独给出
|
|
588
619
|
`model_unpriced_turns_total`(算不出成本的 turn 数 —— **成本护栏失效的显式信号**)。
|
|
620
|
+
- **评分级**(R7)—— 来自 run 根 span 的 `score` 事件(`attachScore` 写入):
|
|
621
|
+
`agentia_score{name,source}` gauge 记**最近一次**值(分数不是累加量),`agentia_score_total{name,source}` counter 记条数;
|
|
622
|
+
`snapshot().scores` 以 `name@source` 为键(source 缺省时裸 name)暴露 `{ value, count, sum }`(平均 = sum/count),
|
|
623
|
+
OTLP metrics payload 同样带这两个家族,`reset()` 一并清空。
|
|
589
624
|
|
|
590
625
|
接完 `GET /metrics` 直接回 `render()` 即可 —— 交给 `createHttpHandler({ metrics })` 就是一行的事
|
|
591
626
|
(见 §6 HTTP 端点速查)。
|
|
@@ -618,7 +653,7 @@ if (!report.ok) console.error(report.cases.filter((c) => !c.ok));
|
|
|
618
653
|
| 成员 | 说明 |
|
|
619
654
|
|---|---|
|
|
620
655
|
| `export` | `TraceSink` 的实现(run 收尾投递)—— 也是接进 `sinks` 的形状 |
|
|
621
|
-
| `snapshot` | `{ runs, failed, latencyP50, latencyP95, tokens, costUsd, capabilities, models, droppedCapabilities }` |
|
|
656
|
+
| `snapshot` | `{ runs, failed, latencyP50, latencyP95, tokens, costUsd, capabilities, models, scores, droppedCapabilities }` |
|
|
622
657
|
| `render` | Prometheus 文本(`/metrics` 直接回它) |
|
|
623
658
|
| `flush` | 主动导出一次(`export:'otlp'` 时有意义;prometheus 模式为空操作) |
|
|
624
659
|
| `stop` | 停掉定时导出(进程收尾 / 测试用) |
|
|
@@ -695,12 +730,44 @@ const app = await createApp({ /* … */ sinks: [jsonl] });
|
|
|
695
730
|
函数型选项(`summarize` / `confirm` 之类)只记「配没配」,不记函数体。
|
|
696
731
|
截断关掉时记的是 `'off'` 而不是 `false` —— 后者在日志/看板里会被读成「上限为 0」。
|
|
697
732
|
|
|
698
|
-
###
|
|
733
|
+
### 提示词版本化与会话标记(run 根 attribute)
|
|
699
734
|
|
|
700
735
|
- `new SystemPrompt({ version: 'git-abc123' })` → 自动写到 **run 根 span 的 `system.version` attribute**:trace 里能查出「这个结果是哪个版本的提示词产出的」(换 prompt 前后对比、排查回归都靠它)。
|
|
701
736
|
- 版本号怎么来(git sha / 语义版本 / 手工)由你决定 —— 框架**不做**版本库与回滚平台。
|
|
702
737
|
- 单次 `app.run(..., { system })` 覆盖时,版本**跟当次那个 `SystemPrompt` 走**;`system` 传已拼好的 `SystemParam` 则无版本可记(不写空串冒充实有版本)。
|
|
703
738
|
- 直连 `runAgent` / `executeRun` 时可用引擎级选项 `systemVersion` 显式给。
|
|
739
|
+
- **`@Prompt` 资产版本**(R7):`@Prompt({ version })` 声明后,装配期把菜单里全部带版本的 @Prompt 收集成 `{ 能力名: 版本 }` 表(与主菜单同一条收集路径,`toolSources` 收窄同样生效),每次 run 落 run 根 span 的 `prompts.versions` attribute(`name@ver` 逗号拼接、按名排序、空表不记)—— 质量回归能定位到具体资产版本。直连 `runAgent` 时用引擎级选项 `promptVersions` 显式给。
|
|
740
|
+
- **会话标识**(R7 thread 维度):`app.run(..., { session })` / `executeRun` 给了 `session` 时,session id 自动落 run 根 span 的 `session.id` attribute(OTLP 导出时映射 `gen_ai.conversation.id`)—— 多轮对话的 run 由此可按会话聚合,不用手填。直连 `runAgent` 时用引擎级选项 `sessionId` 显式给。
|
|
741
|
+
|
|
742
|
+
### 在线评估采样(recipe,不是框架功能)
|
|
743
|
+
|
|
744
|
+
生产流量按 N% 采样跑 LLM-judge、把分数回挂 trace —— 用**现有 sink 机制**拼:采样(`examples/observability` 的 `sampleSink` 配方)+ 对抽中的 run 调一次 judge(一次 `app.run` 或裸 client 调用)+ `attachScore` 回挂 + `metricsSink` 聚合。框架不提供 judge 子系统 —— 评什么、用什么模型评、采样率多少,都是你的策略:
|
|
745
|
+
|
|
746
|
+
```ts
|
|
747
|
+
import { attachScore, createAnthropicClient, metricsSink } from '@migor/agentia';
|
|
748
|
+
import { sampleSink } from '@migor/agentia-observability'; // examples/observability
|
|
749
|
+
|
|
750
|
+
const metrics = metricsSink();
|
|
751
|
+
const judge = createAnthropicClient(); // judge 也可以就是同一个 app 的一次 run
|
|
752
|
+
|
|
753
|
+
const onlineEval = sampleSink({
|
|
754
|
+
rate: 0.05, // 抽 5%(失败 run 永不采样掉,见 observability.md 配方 2.3)
|
|
755
|
+
sinks: [{
|
|
756
|
+
async export(trace) {
|
|
757
|
+
// 你的 judge:读 trace 给个 0–1 分(一次 app.run / 裸 client 调用随你)
|
|
758
|
+
const { value, comment } = await runMyJudge(judge, trace);
|
|
759
|
+
// 回挂到这条 trace 的根 span:score 事件 → OTLP gen_ai.evaluation.result / metrics agentia_score
|
|
760
|
+
attachScore(trace, { name: 'faithfulness', value, source: 'judge:claude-opus-5', comment });
|
|
761
|
+
},
|
|
762
|
+
}],
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
createApp({ /* … */ sinks: [onlineEval, metrics] }); // metrics 必须同链,才聚合得到分
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
- 顺序要紧:**judge sink 在 `metricsSink` 之前**,分数事件才进指标(sink 数组顺序即投递顺序)。
|
|
769
|
+
- judge 本身的 run 也会产生 trace —— 给它单独一个 app / runName,或按 run 名在 judge sink 里跳过自己,避免「评估评估的评估」。
|
|
770
|
+
- 成本自控:judge 调一次模型就是一份钱,采样率与 judge 模型档位是你的旋钮(judge 的 run 同样受 `maxCostUsd` 等护栏约束)。
|
|
704
771
|
|
|
705
772
|
### 多租户配额(组合既有缝,不是子系统)
|
|
706
773
|
|
|
@@ -844,6 +911,8 @@ const callable = {
|
|
|
844
911
|
| `@Prompt` 没有能力指标 | 资产类能力不建 span、无独立耗时,故不出现在能力排行里(这是刻意的:硬凑一个假耗时会误导调优) |
|
|
845
912
|
| 能力标签有基数上限 | `labelMode:'capability'`(缺省)+ `maxCapabilities`(缺省 200),超出的能力归入 `capability="__other__"`;`snapshot().droppedCapabilities` 给出被归并的能力个数。要完整明细请用 `buildRunReport`(不设上限) |
|
|
846
913
|
| 提示词版本只是标记 | 框架不存版本库、不回滚:`version` 只落 run 根 attribute;`system` 传已拼好的 `SystemParam` 时无版本可记 |
|
|
914
|
+
| `agentia harvest` 的产物是轨迹骨架 | trace **不记 assistant 文本**(llm.turn 只记 usage/事件),故 harvest 用例脚本里的 text 块是占位、预填 `expect` 是从原 trace 抄录的实际轨迹 —— 脚手架不是成品,人工核对后再进 CI(见 §6「线上 trace 回流」) |
|
|
915
|
+
| 评分来自 run 之外 | `Score` 走 run 根 `score` **事件**而非 span 字段(评分通常在 run 跑完后才产生);`attachScore` 找不到根 span 时静默忽略,多次调用即多条事件(不同维度各记各的) |
|
|
847
916
|
| 配额不是框架子系统 | 只给缝(middleware + TraceSink + BudgetGuard),计数放哪(内存 / Redis / DB)与超限怎么办都是你的策略 |
|
|
848
917
|
| 人工介入只到「闸门」 | `middleware` 能 `await` 审批决策再放行;**跨进程挂起/续跑框架不做** —— `RunStatus` 无「待批准」态、循环位置不落库,`traceToMessages` 重放有损,不能拿它假装续跑(要跨重启审批请上工作流引擎)|
|
|
849
918
|
| 内容护栏不给实现 | 同「配额」:只给缝(入参包 `app.run` / 工具前 `middleware` / 出参包返回值或 `sinks`),策略(正则 / 分类器 / 外部 API)是你的 |
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { devServer } from './dev.js';
|
|
|
8
8
|
import { doctor } from './doctor.js';
|
|
9
9
|
import { addPackage } from './add.js';
|
|
10
10
|
import { reportCommand } from './report.js';
|
|
11
|
+
import { harvestCommand } from './harvest.js';
|
|
11
12
|
const USAGE = `agentia —— Agentia 框架命令行工具
|
|
12
13
|
|
|
13
14
|
用法:
|
|
@@ -17,6 +18,8 @@ const USAGE = `agentia —— Agentia 框架命令行工具
|
|
|
17
18
|
agentia dev 启动开发模式(tsx watch 热重载 + 本地 inspector 面板)
|
|
18
19
|
agentia doctor 装配体检(未登记/悬空能力/命名规范/重复条目)
|
|
19
20
|
agentia report <trace.jsonl> 从 trace 落盘文件生成调优报告(能力耗时/成本/错误率排行)
|
|
21
|
+
agentia harvest <trace.jsonl> 把线上 trace 翻成 eval 用例骨架
|
|
22
|
+
([--out <file.ts>] [--failed] [--limit N])
|
|
20
23
|
agentia add <pkg> 安装第三方能力包并登记到 src/registry.ts
|
|
21
24
|
agentia --help 显示本帮助
|
|
22
25
|
|
|
@@ -98,6 +101,14 @@ function main(argv) {
|
|
|
98
101
|
});
|
|
99
102
|
return 0;
|
|
100
103
|
}
|
|
104
|
+
if (command === 'harvest') {
|
|
105
|
+
// 异步命令(读文件/写文件):同 report 的受理模式
|
|
106
|
+
void harvestCommand(rest).catch((e) => {
|
|
107
|
+
console.error(`错误:${e.message}`);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
});
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
101
112
|
if (command === 'add') {
|
|
102
113
|
const [pkg, ...extra] = rest;
|
|
103
114
|
if (pkg === undefined || extra.length > 0) {
|
package/dist/harvest.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentia harvest <file.jsonl>` —— 线上 trace 回流 eval 用例(R7 质量闭环的 CLI 薄壳)。
|
|
3
|
+
*
|
|
4
|
+
* 读 trace 落盘文件(裸 Trace 或 TaskRecord,同 `agentia report` 的两种输入),每条
|
|
5
|
+
* 记录翻成一个「可粘贴进 eval 文件」的用例骨架(`client: scriptedClient([...])` 按
|
|
6
|
+
* trace 的 llm.turn 重建),汇总成一个 eval TS 脚手架写到 --out(缺省 stdout)。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 用例骨架生成器与框架侧 `src/eval/harvest.ts` 的 `harvestEvalCase` **同源同形** ——
|
|
9
|
+
* CLI 零运行时依赖、不能 import 框架,此处是逐行移植(packages/cli/test/harvest.test.mjs
|
|
10
|
+
* 里有一条「与框架产物逐字一致」的对拍,改生成格式必须两边同步)。
|
|
11
|
+
*/
|
|
12
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { extractTrace } from './report.js';
|
|
14
|
+
/** trace 不记 assistant 文本 —— 脚本 text 块的统一占位(与框架侧同一个词) */
|
|
15
|
+
const ASSISTANT_TEXT_PLACEHOLDER = '[harvest] assistant 文本未入 trace';
|
|
16
|
+
/** 任意值 → 字符串(core/json.ts stringifySafe 的移植) */
|
|
17
|
+
function stringifySafe(x) {
|
|
18
|
+
if (typeof x === 'string')
|
|
19
|
+
return x;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.stringify(x) ?? String(x);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return String(x);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function toolInputsOf(turn) {
|
|
28
|
+
return (turn.events ?? [])
|
|
29
|
+
.filter((e) => e.name === 'tool.input')
|
|
30
|
+
.map((e) => {
|
|
31
|
+
const b = (e.body ?? {});
|
|
32
|
+
return {
|
|
33
|
+
tool: typeof b.tool === 'string' ? b.tool : 'unknown',
|
|
34
|
+
toolUseId: typeof b.tool_use_id === 'string' ? b.tool_use_id : undefined,
|
|
35
|
+
input: b.input === undefined ? undefined : stringifySafe(b.input),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function parseToolInput(raw) {
|
|
40
|
+
const s = raw ?? '';
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(s);
|
|
43
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : { _raw: s };
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { _raw: s };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function toMessageUsage(u) {
|
|
50
|
+
return {
|
|
51
|
+
input_tokens: u?.inputTokens ?? 0,
|
|
52
|
+
output_tokens: u?.outputTokens ?? 0,
|
|
53
|
+
cache_read_input_tokens: u?.cacheReadTokens ?? 0,
|
|
54
|
+
cache_creation_input_tokens: u?.cacheCreationTokens ?? 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function lastUserText(messages) {
|
|
58
|
+
if (!messages)
|
|
59
|
+
return null;
|
|
60
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
61
|
+
const m = messages[i];
|
|
62
|
+
if (m.role !== 'user')
|
|
63
|
+
continue;
|
|
64
|
+
const text = typeof m.content === 'string'
|
|
65
|
+
? m.content
|
|
66
|
+
: (m.content ?? [])
|
|
67
|
+
.filter((b) => b.type === 'text')
|
|
68
|
+
.map((b) => b.text ?? '')
|
|
69
|
+
.join('\n');
|
|
70
|
+
if (text.trim())
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** 与框架侧 harvestEvalCase 逐字同形的生成器(见文件头注释的对拍测试) */
|
|
76
|
+
export function harvestEvalCase(input) {
|
|
77
|
+
const { trace } = input;
|
|
78
|
+
const name = input.name ?? `harvest-${trace.traceId}`;
|
|
79
|
+
const spans = trace.spans ?? [];
|
|
80
|
+
const mainTurns = spans
|
|
81
|
+
.filter((s) => s.kind === 'llm.turn' && s.parentSpanId === trace.rootSpanId)
|
|
82
|
+
.sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
|
|
83
|
+
const nestedCount = spans.filter((s) => s.kind === 'llm.turn' && s.parentSpanId !== trace.rootSpanId).length;
|
|
84
|
+
let tuSeq = 0;
|
|
85
|
+
const steps = mainTurns.map((turn, i) => {
|
|
86
|
+
const inputs = toolInputsOf(turn);
|
|
87
|
+
const content = [
|
|
88
|
+
{ type: 'text', text: ASSISTANT_TEXT_PLACEHOLDER },
|
|
89
|
+
];
|
|
90
|
+
for (const e of inputs) {
|
|
91
|
+
content.push({
|
|
92
|
+
type: 'tool_use',
|
|
93
|
+
id: e.toolUseId ?? `harvest_tu_${++tuSeq}`,
|
|
94
|
+
name: e.tool,
|
|
95
|
+
input: parseToolInput(e.input),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
id: `harvest_m_${i + 1}`,
|
|
100
|
+
model: turn.name,
|
|
101
|
+
stop_reason: inputs.length > 0 ? 'tool_use' : 'end_turn',
|
|
102
|
+
usage: toMessageUsage(turn.usage),
|
|
103
|
+
content,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
const toolSeq = mainTurns.flatMap((t) => toolInputsOf(t).map((e) => e.tool));
|
|
107
|
+
const userText = lastUserText(input.messages);
|
|
108
|
+
const head = [
|
|
109
|
+
`// ┄┄ harvest 用例骨架:${name}(trace ${trace.traceId}${input.source ? `,来源 ${input.source}` : ''})┄┄`,
|
|
110
|
+
'// ⚠️ 脚手架,不是成品 —— 人工核对后再进 CI:',
|
|
111
|
+
'// · trace 不记 assistant 文本(llm.turn 只记 usage/事件),脚本里的 text 块是占位;',
|
|
112
|
+
];
|
|
113
|
+
if (nestedCount > 0) {
|
|
114
|
+
head.push(`// · 只含主循环 ${mainTurns.length} 个回合;${nestedCount} 个子 agent 嵌套回合已略去`, '// (嵌套回合不走主循环脚本,scriptedClient 驱动不了它们;要覆盖子 agent 请单独写 eval);');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
head.push(`// · 含主循环全部 ${mainTurns.length} 个回合(无子 agent 嵌套回合);`);
|
|
118
|
+
}
|
|
119
|
+
head.push('// · EvalCase 没有 expect 字段:下面的 expect 是草稿,粘贴时把它搬进', '// defineEval({ expect })。其中的工具序列是从原 trace 抄录的「实际轨迹」——', '// 发生过 ≠ 应该发生,核对过再留作断言。');
|
|
120
|
+
const body = ['{', ` name: ${JSON.stringify(name)},`];
|
|
121
|
+
if (userText === null) {
|
|
122
|
+
body.push(' // ⚠️ 未提供原始输入(TaskRecord 的 spec.messages)—— input 是占位,请人工补写:', ` input: '[harvest] 原始输入未知,请人工补写',`);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
body.push(` input: ${JSON.stringify(userText)},`);
|
|
126
|
+
}
|
|
127
|
+
if (steps.length === 0) {
|
|
128
|
+
body.push(' // ⚠️ trace 里没有主循环 llm.turn —— 脚本为空,请人工补写:');
|
|
129
|
+
body.push(' client: scriptedClient([]),');
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
body.push(' client: scriptedClient([');
|
|
133
|
+
body.push(steps
|
|
134
|
+
.map((s) => JSON.stringify(s, null, 2)
|
|
135
|
+
.split('\n')
|
|
136
|
+
.map((l) => ` ${l}`)
|
|
137
|
+
.join('\n'))
|
|
138
|
+
.join(',\n'));
|
|
139
|
+
body.push(' ]),');
|
|
140
|
+
}
|
|
141
|
+
body.push(' expect: (result, { trace }) => {', ' // 主循环实际调用过的工具名序列(直属 run 根的 llm.turn 上的 tool.input 事件)', ' const tools = trace.spans', " .filter((s) => s.kind === 'llm.turn' && s.parentSpanId === trace.rootSpanId)", ' .sort((a, b) => a.startedAt - b.startedAt)', ' .flatMap((s) =>', " s.events.filter((e) => e.name === 'tool.input').map((e) => (e.body || {}).tool),", ' );', ` assert.deepEqual(tools, ${JSON.stringify(toolSeq)});`, ' },', '}');
|
|
142
|
+
return `${head.join('\n')}\n${body.join('\n')}\n`;
|
|
143
|
+
}
|
|
144
|
+
/** 一行 JSON → harvest 行(裸 Trace 没有 spec.messages/status,按 trace.status 判失败) */
|
|
145
|
+
function toRow(v, index) {
|
|
146
|
+
const t = extractTrace(v);
|
|
147
|
+
if (!t)
|
|
148
|
+
return null;
|
|
149
|
+
const bare = t === v;
|
|
150
|
+
const o = bare ? {} : v;
|
|
151
|
+
const spec = o.spec;
|
|
152
|
+
return {
|
|
153
|
+
trace: t,
|
|
154
|
+
messages: Array.isArray(spec?.messages) ? spec.messages : undefined,
|
|
155
|
+
failed: o.status === 'failed' || t.status === 'error',
|
|
156
|
+
label: t.traceId ?? `row-${index + 1}`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const USAGE = '用法:agentia harvest <trace.jsonl> [--out <file.ts>] [--failed] [--limit N]';
|
|
160
|
+
function renderEvalFile(opts) {
|
|
161
|
+
const note = `来源:${opts.file},${opts.cases.length} 条记录${opts.onlyFailed ? '(仅失败)' : ''}` +
|
|
162
|
+
(opts.badLines > 0 ? `,跳过无法解析 ${opts.badLines} 行` : '');
|
|
163
|
+
const cases = opts.cases.map((c) => c.replace(/\n$/, '').replace(/^/gm, ' ')).join(',\n');
|
|
164
|
+
return `// 由 \`agentia harvest\` 生成的 eval 用例脚手架(${note})
|
|
165
|
+
// ⚠️ 这是脚手架:assistant 文本为占位(trace 不记文本);每个用例里的 expect 是
|
|
166
|
+
// 从原 trace 抄录的「实际轨迹」—— 发生过 ≠ 应该发生,人工核对后再进 CI。
|
|
167
|
+
import assert from 'node:assert/strict';
|
|
168
|
+
import { defineEval, scriptedClient } from '@migor/agentia';
|
|
169
|
+
|
|
170
|
+
export const harvestedCases = [
|
|
171
|
+
${cases}
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
// ── 跑法(核对完用例后取消注释,并按你的装配改 app)────────────────────
|
|
175
|
+
// import { createApp } from '@migor/agentia';
|
|
176
|
+
//
|
|
177
|
+
// defineEval({
|
|
178
|
+
// name: 'harvested-regression',
|
|
179
|
+
// app: () => createApp({ /* …你的装配… */ }),
|
|
180
|
+
// // EvalCase 没有 expect 字段:喂进 defineEval 前先把各用例骨架里的 expect 拆掉
|
|
181
|
+
// // (断言搬进下面 defineEval 的 expect 里,可按用例分支)。
|
|
182
|
+
// cases: harvestedCases.map(({ expect: _draft, ...c }) => c),
|
|
183
|
+
// expect: (result, { trace }) => {
|
|
184
|
+
// // 例:assert.equal(result.stopReason, 'end_turn');
|
|
185
|
+
// },
|
|
186
|
+
// })
|
|
187
|
+
// .run()
|
|
188
|
+
// .then((r) => {
|
|
189
|
+
// console.log(r.ok ? \`✓ \${r.passed}/\${r.total} 通过\` : r);
|
|
190
|
+
// if (!r.ok) process.exit(1);
|
|
191
|
+
// });
|
|
192
|
+
`;
|
|
193
|
+
}
|
|
194
|
+
/** 失败一律**抛错**(同 report 命令:异步命令就地设 exitCode 会被 cli.ts 末尾覆盖) */
|
|
195
|
+
export async function harvestCommand(args) {
|
|
196
|
+
let file;
|
|
197
|
+
let out;
|
|
198
|
+
let onlyFailed = false;
|
|
199
|
+
let limit;
|
|
200
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
201
|
+
const a = args[i];
|
|
202
|
+
if (a === '--failed') {
|
|
203
|
+
onlyFailed = true;
|
|
204
|
+
}
|
|
205
|
+
else if (a === '--out') {
|
|
206
|
+
out = args[i + 1];
|
|
207
|
+
if (out === undefined)
|
|
208
|
+
throw new Error(`--out 需要一个文件参数\n${USAGE}`);
|
|
209
|
+
i += 1;
|
|
210
|
+
}
|
|
211
|
+
else if (a === '--limit') {
|
|
212
|
+
const raw = args[i + 1];
|
|
213
|
+
limit = Number(raw);
|
|
214
|
+
if (raw === undefined || !Number.isInteger(limit) || limit < 1) {
|
|
215
|
+
throw new Error(`--limit 需要一个正整数\n${USAGE}`);
|
|
216
|
+
}
|
|
217
|
+
i += 1;
|
|
218
|
+
}
|
|
219
|
+
else if (a.startsWith('--')) {
|
|
220
|
+
throw new Error(`未知参数:${a}\n${USAGE}`);
|
|
221
|
+
}
|
|
222
|
+
else if (file === undefined) {
|
|
223
|
+
file = a;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
throw new Error(USAGE);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (file === undefined)
|
|
230
|
+
throw new Error(USAGE);
|
|
231
|
+
let raw;
|
|
232
|
+
try {
|
|
233
|
+
raw = await readFile(file, 'utf8');
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
throw new Error(`读不到文件 ${file}(${e.message})`);
|
|
237
|
+
}
|
|
238
|
+
const rows = [];
|
|
239
|
+
let badLines = 0;
|
|
240
|
+
let lineNo = 0;
|
|
241
|
+
for (const line of raw.split('\n')) {
|
|
242
|
+
const s = line.trim();
|
|
243
|
+
if (!s)
|
|
244
|
+
continue;
|
|
245
|
+
try {
|
|
246
|
+
const row = toRow(JSON.parse(s), lineNo);
|
|
247
|
+
if (row)
|
|
248
|
+
rows.push(row);
|
|
249
|
+
else
|
|
250
|
+
badLines += 1;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
badLines += 1;
|
|
254
|
+
}
|
|
255
|
+
lineNo += 1;
|
|
256
|
+
}
|
|
257
|
+
if (rows.length === 0) {
|
|
258
|
+
throw new Error(`${file} 里没有可识别的 trace(支持裸 Trace 或含 result.trace / trace 的记录)` +
|
|
259
|
+
(badLines > 0 ? `;另有 ${badLines} 行无法解析` : ''));
|
|
260
|
+
}
|
|
261
|
+
const picked = rows.filter((r) => !onlyFailed || r.failed).slice(0, limit ?? rows.length);
|
|
262
|
+
const cases = picked.map((r) => harvestEvalCase({
|
|
263
|
+
trace: r.trace,
|
|
264
|
+
messages: r.messages,
|
|
265
|
+
name: `harvest-${r.label}`,
|
|
266
|
+
source: file,
|
|
267
|
+
}));
|
|
268
|
+
const content = renderEvalFile({ file, cases, onlyFailed, badLines });
|
|
269
|
+
if (out === undefined) {
|
|
270
|
+
process.stdout.write(content);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
await writeFile(out, content, 'utf8');
|
|
274
|
+
console.log(`已写出 ${out}:${picked.length} 个用例骨架` +
|
|
275
|
+
(onlyFailed ? '(仅失败记录)' : '') +
|
|
276
|
+
` —— 核对占位文本与断言后再进 CI`);
|
|
277
|
+
}
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
package/dist/report.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* 且与 inspector 面板/官网 playground 共用同一份聚合口径(避免两处漂移)。
|
|
14
14
|
*/
|
|
15
15
|
import { readFile } from 'node:fs/promises';
|
|
16
|
-
/** 从一行 JSON 里把 trace 抠出来(裸 Trace 或包在记录里的 Trace
|
|
17
|
-
function extractTrace(v) {
|
|
16
|
+
/** 从一行 JSON 里把 trace 抠出来(裸 Trace 或包在记录里的 Trace)。harvest 命令也复用它。 */
|
|
17
|
+
export function extractTrace(v) {
|
|
18
18
|
if (!v || typeof v !== 'object')
|
|
19
19
|
return null;
|
|
20
20
|
const o = v;
|
package/dist/templates.js
CHANGED
|
@@ -60,7 +60,7 @@ export function projectPackageJson(name) {
|
|
|
60
60
|
dev: 'tsx src/main.ts',
|
|
61
61
|
typecheck: 'tsc --noEmit -p tsconfig.json',
|
|
62
62
|
},
|
|
63
|
-
dependencies: { '@migor/agentia': '^0.
|
|
63
|
+
dependencies: { '@migor/agentia': '^0.5.0' },
|
|
64
64
|
devDependencies: {
|
|
65
65
|
tsx: '^4.19.0',
|
|
66
66
|
typescript: '^7.0.2',
|