@migor/cli 0.10.0 → 0.10.1
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/CHANGELOG.md +148 -1
- package/dist/AGENTS.md +36 -0
- package/dist/dev-protocol.js +14 -0
- package/dist/dev-runner.js +49 -1
- package/dist/inspector-page.html +258 -6
- package/dist/inspector-routes.js +104 -1
- package/dist/panel-logic.js +152 -0
- package/dist/templates/package.json +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,152 @@
|
|
|
5
5
|
(0.x 阶段:minor 可含破坏性变更,每个破坏性变更都在对应版本的「迁移」小节里写明)。
|
|
6
6
|
决策的完整证据链在 `docs/spec.md` §10(带时间线的决策日志)。
|
|
7
7
|
|
|
8
|
+
## [0.10.1] - 2026-10-05
|
|
9
|
+
|
|
10
|
+
> 本版主题(窗口 `0.10.0 → 0.10.1`):**从「看得见一次 run」到「留得下、比得出」**,外加给两块
|
|
11
|
+
> 长期没有闸门的地方补上闸门。三块 —— ① dev 面板:每次 run 收尾把 trace 落盘,成为
|
|
12
|
+
> `report` / `diff` / `harvest` / `export` 四个命令的现成输入,并支持**并排比两条 run**
|
|
13
|
+
> (含「改一句 prompt 立刻重跑对照」的自动补对);② `packages/trace-view`:补规模棘轮 +
|
|
14
|
+
> 三条最该有的行为断言(树形状 / 错误行 / usage 汇总);③ `docs/usage-guide.md` §7 补登记
|
|
15
|
+
> 两条**有病例、可复现**的长跑边界。
|
|
16
|
+
> **无破坏性变更**;`@migor/agentia` 框架代码**零改动**(本轮发布面里唯一碰到 `src/` 的是
|
|
17
|
+
> 版本常量那一行)⇒ 既有使用者**不需要任何动作**。
|
|
18
|
+
> ⚠️ **一处新增的用户可见文件**:跑 `agentia dev` 会往工程根的 `.agentia/` 追加
|
|
19
|
+
> `traces.jsonl`,**只增不减**(`.agentia/` 已 gitignore;要清是用户自己 `rm`)。
|
|
20
|
+
|
|
21
|
+
### dev 调试工作台:trace 落盘 + 并排 A/B(2026-10-05)
|
|
22
|
+
|
|
23
|
+
- **是什么**:`agentia dev` 的本地面板从「看一条 run 的调用树」扩成「**比两条 run 差在哪**」。
|
|
24
|
+
两件事:① 每次 run 收尾把 trace 追加落盘到 `.agentia/traces.jsonl`;② run 列表支持
|
|
25
|
+
**⌘/Ctrl + 单击**选两条,右栏切**双栏**并排两棵调用树,顶部一条差异摘要
|
|
26
|
+
(run 级 summary + 逐 span 字段级差异)。设计稿 `docs/plans/2026-10-05-dev-workbench.md`。
|
|
27
|
+
- **落盘换来什么**:那个文件的格式**恰好就是** `agentia report` / `diff` / `harvest` /
|
|
28
|
+
`export` 四个命令的输入(`jsonlTraceSink` 的契约)⇒ 面板里跑过的每一次 run,
|
|
29
|
+
立刻变成「哪个能力慢/贵/爱失败」「改 prompt 后轨迹漂没漂」的现成输入。
|
|
30
|
+
开发期最贵的一件事是「拿不到刚才那次 run 的数据」,这一行就解决它。
|
|
31
|
+
- **并排 A/B 的判据(三档,显式不静默)**:能力选择与工作目录是 **app 级**的
|
|
32
|
+
(换它们要重启进程)⇒ 两条跨了能力选择的 run,`runConfigSnapshot` 整个变了,
|
|
33
|
+
diff 出来的差异**多半与「我改的那句话」无关**。所以跨能力 / 跨目录 / 元信息缺失
|
|
34
|
+
都给**显式降级提示**(含可执行的出路),而不是静默给一份没意义的 diff。
|
|
35
|
+
可比时**不显示**提示条 —— 每次都飘噪声等于没有提示。
|
|
36
|
+
- **三条如实标注的边界**:① `traces.jsonl` **只增不减**(`.agentia/` 已 gitignore;
|
|
37
|
+
要清是用户自己 `rm`);② 落盘是**同步写**(`appendFileSync`),大 trace 会占 runner
|
|
38
|
+
事件循环 —— dev 场景单条以百 KB 计,且 `traceLimits.maxEvents` 本来兜上限;
|
|
39
|
+
③ **并排是「事后回看」,不是「同时跑」** —— 单飞闸没解除(解除会连带改变
|
|
40
|
+
「文件变更延后重启」的语义,而那条判据是刻意单源化的),真并发另议。
|
|
41
|
+
- **守卫**:compare 路由三态(顺序 / 可比性 / 归一化)与判据六条都有单测,且做过
|
|
42
|
+
**变异反向验证**(路由顺序反了、判据写死、`rootSpanId` 缺失 —— 三个变异都被咬住)。
|
|
43
|
+
另给 e2e 加了落盘断言(逐行可解析 + **traceId 不重复**:重复行 = sink 被注册两次)。
|
|
44
|
+
- **一处自认的债**:`normalizeForDiff` 存在只因 `diffTraces` 的 `pushAttrDiffs` /
|
|
45
|
+
`pushEventDiffs` 不接受 `undefined`,而 `packages/cli/src/diff.ts` 是框架
|
|
46
|
+
`src/engine/trace-diff.ts` 的**逐字同形移植副本** ⇒ 根治要框架侧一起改
|
|
47
|
+
(`?? {}` / `?? []`),属 `src/` 语义变更。本次取「消费侧归一化」的零核心改动解。
|
|
48
|
+
**零 `src/` 改动** ⇒ 按 `CONTRIBUTING.md` 的判据不写 spec §10。
|
|
49
|
+
- **顺带补的面板测试债**:上面这批面板代码里**能单测的判断已从 HTML 搬进
|
|
50
|
+
`panel-logic`**(`compareView` 三态视图 / `diffSideLabel` 缺侧标签 /
|
|
51
|
+
`diffValueText` 取值 / `diffFetchErrorText` 失败文案)—— 搬**不是**新写,
|
|
52
|
+
是把已存在的行为从「无测试区」挪进 `panel-logic.test.mjs` 的射程。
|
|
53
|
+
⚠️ **剩下的 DOM 接线补不了**(本仓无 jsdom,硬加会引入 DOM 依赖)⇒ 只有
|
|
54
|
+
`scripts/e2e-dev.ts` 间接兜。如实标注,不假装已覆盖。
|
|
55
|
+
- **入库前评审修掉三处**:① **并排态下普通单击切 run 时不复位 DOM** —— 只清了选择,
|
|
56
|
+
右栏与差异摘要**留在屏幕上**(内容还是上一对的)而选中标记已经消失,用户按看到的读
|
|
57
|
+
会张冠李戴。这是本档**唯一用户可见**的缺陷;修法 = 清空必须走 `exitCompare()`,
|
|
58
|
+
并抽出 `resetCompareDom()` 消掉三处逐字重复的复位序列;**另补一条文本层守卫 W4**
|
|
59
|
+
(`state.compare = []` 只许出现在 `exitCompare` 里)—— 面板无 jsdom 单测,
|
|
60
|
+
这是唯一能防它回归的机制。② `refreshCompare` 读不到某条 run 时**静默留白**
|
|
61
|
+
(看起来像「这条没有调用树」,真相是它已被内存 ring 淘汰)⇒ 改为显式说出来。
|
|
62
|
+
③ 删掉只写不读的 `state.diff` 字段 —— 它的注释还承诺了一个**并不存在**的「读取中…」
|
|
63
|
+
渲染。另修:`comparability` 的长 JSDoc 错挂到 `capsKey` 头上(它自己反而没有注释)、
|
|
64
|
+
一条与上一条**同一对 run 同一断言**的死断言(`bothFull`)。
|
|
65
|
+
- **A/B 闭环补最后一跳**:上面那条「改一句 prompt 就能**立刻重跑对照**」此前要手动
|
|
66
|
+
⌘ 点两条才进并排(三步)。现在 —— **已选一条、还没选第二条**时,新 run 一收尾
|
|
67
|
+
就自动补上第二条并进双栏,**并播一条 notice** 说清屏幕为什么变了。判定
|
|
68
|
+
`autoPairTarget` 在 `panel-logic`(6 个断言 + 变异验证),三种情况**刻意不自动**:
|
|
69
|
+
空选择(替用户决定)、已选两条(会挤掉刚选的)、同一条。⚠️ 这是「事后回看」的
|
|
70
|
+
自动化,**单飞闸仍未解除**(真并发要改「文件变更延后重启」那条单源化判据,
|
|
71
|
+
见设计稿 §8)。DOM 接线本身**无自动化覆盖**(无 jsdom),只有 e2e 间接兜。
|
|
72
|
+
|
|
73
|
+
### `packages/trace-view`:补规模守卫 + 补三条最该有的行为断言(2026-10-05)
|
|
74
|
+
|
|
75
|
+
- **规模棘轮**(新增 `test/structure.test.js`):逐文件 `LINE_BUDGET` + **反向**检查
|
|
76
|
+
(表里有、盘上没了 ⇒ 基线该调低,否则登记项失效后守卫「看起来在管」而实际不管)+
|
|
77
|
+
一条「基线表不许有重复键」的自检。**这道守卫买的不是「今天的健康」**(实测
|
|
78
|
+
`src/` 846 行、测试 904 行、体量完全健康),**是「它开始变大时有人被叫醒」** ——
|
|
79
|
+
2026-09-23 刚在 CLI 上治过一模一样的病(`dev.ts` 长到 1148 行、长出隐式状态机,
|
|
80
|
+
靠人工回看才发现)。
|
|
81
|
+
- **三条行为断言**(新增 `test/tree.test.js`,9 个用例):补的是 `renderTrace`
|
|
82
|
+
里**注释最密、却一直没有断言**的三块 ——
|
|
83
|
+
① **树形状**:三层嵌套的树形前缀(`└─` / `├─` / 竖线缩进)与「事件与子 span 按发生
|
|
84
|
+
顺序混排」(拍平成「先全部事件、再全部子 span」会让用户看到因果倒置);
|
|
85
|
+
② **错误行**:失败 span 的类名 / `meta` 带 `error.type` / **原文单独起一行**(只塞
|
|
86
|
+
`title` 等于没显示 —— 首次运行最常见的「没配 API key」就靠这一行定位)、run 根失败
|
|
87
|
+
时根行也标红(已修过的缺陷,防回退)、**正常 span 不加 error 类**(防「三态塌两态」
|
|
88
|
+
导致满屏红);
|
|
89
|
+
③ **usage 汇总**:**只累加 `llm.turn`**(capability span 的 usage 是子 span 聚合,
|
|
90
|
+
重复计入会双算,而页面上看不出异常 —— 最坏的静默错),且**成对**钉「两个 turn 累加成
|
|
91
|
+
两倍」(否则「压根不累加」也能绿);另钉「每行显示自己那份 usage」与「根未结束不收尾」。
|
|
92
|
+
- ⚠️ **新写的断言里三条是我自己写错的**(都被现有代码正确地打红、查证后是断言错不是代码错):
|
|
93
|
+
① 以为 `m1` 是根的独子(实际它还有兄弟 `sibling` ⇒ 只能是 `├─`);
|
|
94
|
+
② 以为「混排」是事件行插到**别的** span 行之间(实际混排发生在**同一个父节点的孩子
|
|
95
|
+
列表**里,事件是所属 span 的子节点);③ 根失败的 status 写在了 trace 顶层
|
|
96
|
+
(渲染器读的是**根 span 自己**的 `status`)。⇒ 三条都已写进注释,说明读的是哪个字段。
|
|
97
|
+
- **两处变异反向验证**(都被咬住):usage 去掉 `llm.turn` 判据 ⇒ 双算 200 被抓到;
|
|
98
|
+
树形缩进恒空 ⇒ 平铺被抓到。**零 `src/` 改动。**
|
|
99
|
+
- **同批复核又修掉两处**:① 结构守卫里那条关于重复键的注释**事实错误** ——
|
|
100
|
+
原文说「本文件是 `.mjs`(ESM 严格模式)⇒ 重复键是 SyntaxError、import 就炸」,
|
|
101
|
+
**实测证伪**:`{ a: 1, a: 2 }` 写进 `.mjs` 是 exit 0、输出 `{ a: 2 }`(ES6 起对象
|
|
102
|
+
字面量的重复键是合法的)。⇒ 那条自检**不是备份,是唯一防线** —— 变异实测:把重复键
|
|
103
|
+
补在**表尾**时「单文件不超基线」那条**照样绿**(后写的值覆盖前者,反而更宽松),
|
|
104
|
+
只有它红。② `tree.test.js` 里一条断言消息夹着**两个 U+FFFD 替换字符** ——
|
|
105
|
+
assert 失败时唯一给线索的那句话是乱码。
|
|
106
|
+
|
|
107
|
+
### 文档 · `§7 已知边界` 补登记两条长跑边界(2026-10-02)
|
|
108
|
+
|
|
109
|
+
- **是什么**:`docs/usage-guide.md` §7 补两行 —— ① 缺省**不裁剪**上下文:不注入 `contextPolicy`
|
|
110
|
+
就一个回合都不裁,长跑成本随**回合数平方增长**(框架给了缝与参考实现 `createBudgetPolicy`,
|
|
111
|
+
但**不替你决定**,因为裁剪有损);② 回给模型的 `tool_result` **不截断、也没有大小闸**
|
|
112
|
+
(`maxEventChars` / `traceContent` 只管**记账侧**),长跑需要一个**宿主侧的输出上限**,
|
|
113
|
+
否则下一回合以 api 错误收尾。
|
|
114
|
+
- **为什么该补**:§7 是「使用者判断『我能不能用这个框架』的唯一依据」,这两条**有病例、
|
|
115
|
+
可复现、可量化**却没登记。更糟的是第 ② 条**已登记了一半**(「`contextPolicy` 不进子循环 ⇒
|
|
116
|
+
撞上限以 api 错误收尾」)—— **一半的边界比没有更危险**:读表的人会推出「那主循环是安全的」。
|
|
117
|
+
- **病例(TB 4.0 实跑,286 个 trial)**:① 上下文**一次都没被裁剪过**(发生过回落的
|
|
118
|
+
trial = **0 / 286**);单次请求的输入 token 从 ~1000 涨到 **619,119**,且
|
|
119
|
+
`sum(steps.prompt_tokens) == final_metrics.total_prompt_tokens` ⇒ 每回合把整段历史**重发**一遍。
|
|
120
|
+
② 有一条 PTY 回显洪水在 trace 里落了 **1.69 亿字符**(单条 `tool_result` 的 content;
|
|
121
|
+
JSON 序列化后 1.84 亿、整个 `trajectory.json` **184 MB**)—— 该 trial 共 **9 步**、
|
|
122
|
+
总输入仅 **10252 token**,洪水恰好在收尾前,**下一步没有真实请求**才侥幸过关。
|
|
123
|
+
- ⚠️ **这两个数不是一回事**(本次差点读错):**trace 里记了多少 ≠ 模型看到了多少**。
|
|
124
|
+
`maxEventChars` / `traceContent` 都在**记账侧**。拿 trace 体积推断上下文之前,先与 usage 对账。
|
|
125
|
+
- **登记形态是 1 pin + 1 gap**:`tool_result` 那条有真门禁 —— `tests/engine/eventChars.test.ts`
|
|
126
|
+
的用例「回给模型的 tool_result 不受截断影响(截断只在记账侧)」正是为它立的 ⇒ `pin`;
|
|
127
|
+
缺省不裁剪那条只有**配置读数**(`run-config.test.ts` 钉 `config.contextPolicy === false`),
|
|
128
|
+
把 `turn.ts` 里的判据改掉它**照旧绿** ⇒ `gap`(「变假时没有用例会红」就是 gap 的定义)。
|
|
129
|
+
- **刻意不做两件事**:① **不写**独立的「工具输出截断配方」—— 目前只有一个使用方(TB),
|
|
130
|
+
且它的诉求恰恰是「**不要**截断」(评测要原始输出);此刻写配方必然是两边折中的空话
|
|
131
|
+
(本仓判据:先有病例,再有设备)。② **不改**「缺省不裁剪」的行为 —— 改缺省 = 替宿主做
|
|
132
|
+
**有损**决策 + 逼核心变厚,与「只给缝、不给策略」冲突。框架这里是对的,欠的只是说明书。
|
|
133
|
+
- **顺手订正一处长期没人发现的读数**:`docs/guards.md` §1.4 的登记行写着「§7 的 **88 行**」,
|
|
134
|
+
而它当时就是错的(实际 90)⇒ 改为 **92**。`guards-registry.test.ts` 只查「清单里的路径
|
|
135
|
+
真实存在」+ 条目密度下限,**不查数字** —— 这类读数没有守卫盯。
|
|
136
|
+
- **去掉一个会腐烂的数**:`examples/terminal-bench/README.md` 原写「`cat` 大文件会让 trace 到
|
|
137
|
+
**几 MB**」,实测上界 **184 MB**(差两个数量级)⇒ 改成**定性 + 现算路径**,
|
|
138
|
+
而不是换一个新的会腐烂的数。
|
|
139
|
+
- **零语义变更 ⇒ 不新增 `docs/spec.md` §10 节**:按 `CONTRIBUTING.md`「**语义变更**要同步 §10」,
|
|
140
|
+
本轮只补登记**既存事实**、未改任何行为(`src/` 零改动)⇒ 不写决策条目。
|
|
141
|
+
- 影响面:`docs/usage-guide.md` §7、`tests/docs/boundary-table.test.ts`(`REGISTRY` +2 条 +
|
|
142
|
+
5 处写死读数)、`docs/guards.md` §1.4、`examples/terminal-bench/README.md`、本节。
|
|
143
|
+
**框架代码零改动。**
|
|
144
|
+
|
|
145
|
+
### 依赖
|
|
146
|
+
|
|
147
|
+
- 开发依赖:`@anthropic-ai/sdk` → 0.131.0 —— **不影响运行时面**:`src/` 不 import 它,
|
|
148
|
+
它只在 devDependencies 里做「core 消息类型族 ↔ SDK 结构兼容」那道门禁(`src/index.ts`
|
|
149
|
+
有说明),而那道门禁在本版是绿的。
|
|
150
|
+
- 官网(不随包发布):`astro` → 7.3.5。构建与产物形状由 `verify-all` 第 8 步
|
|
151
|
+
(`build:website` + 按产物形状核 404 / robots / sitemap / llms.txt)在 PR 阶段验证,
|
|
152
|
+
真实部署只在 main 上跑 —— 本版已随 main 的 CI 部署通过。
|
|
153
|
+
|
|
8
154
|
## [0.10.0] - 2026-09-29
|
|
9
155
|
|
|
10
156
|
> 本版主题(窗口 `0.9.5 → 0.10.0`):**从「跑得起来」到「敢上线、也找得到」**。
|
|
@@ -2733,7 +2879,8 @@ toolkit 142 / runtime 55 / engine 403 / container 10 / store 55 —— 全绿。
|
|
|
2733
2879
|
首个公开发布:`@migor/agentia` + `@migor/cli`(scope `@migor/*`),两包版本同步。
|
|
2734
2880
|
框架本体单包;CLI 独立成包(workspaces)。
|
|
2735
2881
|
|
|
2736
|
-
[Unreleased]: https://github.com/retrychx/agentia/compare/v0.10.
|
|
2882
|
+
[Unreleased]: https://github.com/retrychx/agentia/compare/v0.10.1...HEAD
|
|
2883
|
+
[0.10.1]: https://github.com/retrychx/agentia/releases/tag/v0.10.1
|
|
2737
2884
|
[0.10.0]: https://github.com/retrychx/agentia/releases/tag/v0.10.0
|
|
2738
2885
|
[0.9.5]: https://github.com/retrychx/agentia/releases/tag/v0.9.5
|
|
2739
2886
|
[0.9.4]: https://github.com/retrychx/agentia/releases/tag/v0.9.4
|
package/dist/AGENTS.md
CHANGED
|
@@ -198,6 +198,40 @@ token 的作用是挡住**本机其它进程**,别把它当网络边界:这
|
|
|
198
198
|
当前用哪个 id 记在 `.agentia/dev-session-id`(**不是** `session.json` 的一部分):只放内存的话,
|
|
199
199
|
重启 `npm run dev` 之后刚清空的对话会**自己回来**。有 run 在飞时不能清空(会 409)。
|
|
200
200
|
|
|
201
|
+
**每次 run 的 trace 都落盘**:收尾时追加一行到 `.agentia/traces.jsonl`(已被 `.gitignore` 挡住)。
|
|
202
|
+
那个格式**恰好就是** `agentia report` / `diff` / `harvest` / `export` 四个命令的输入 ——
|
|
203
|
+
所以面板里跑过的每一次 run,都可以直接:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
agentia report .agentia/traces.jsonl # 哪个能力慢 / 贵 / 爱失败
|
|
207
|
+
agentia diff .agentia/traces.jsonl ... # 改 prompt 前后逐 span 比对(有差异时退出码 1)
|
|
208
|
+
agentia harvest .agentia/traces.jsonl --failed # 失败的 run → eval 用例骨架
|
|
209
|
+
agentia export .agentia/traces.jsonl # → 训练数据集 JSONL
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
落盘**不替代**右栏的实时树:那是两条缝(落盘管「事后能查」,`onTraceEvent` 管「此刻看到」)。
|
|
213
|
+
三条如实标注:① 文件**只增不减**(要清是你自己 `rm`);② 落盘是**同步写**,单条 trace 以百 KB
|
|
214
|
+
计、且 `traceLimits.maxEvents` 本来兜上限;③ 面板的 run 列表是**内存** ring(50 条),
|
|
215
|
+
重启就空了 —— 落盘让数据**没丢**,但面板不会替你回看历史。
|
|
216
|
+
|
|
217
|
+
**并排 A/B(比两条 run差在哪)**:run 列表里 **⌘/Ctrl + 单击**选两条(再点一次取消、
|
|
218
|
+
选第三条挤掉最早那条),右栏切**双栏**并排两棵调用树,顶部一条差异摘要
|
|
219
|
+
(run 级 summary + 逐 span 字段级差异,与 `agentia diff` 逐字同源)。
|
|
220
|
+
|
|
221
|
+
⚠️ **可比性分三档,显式不静默**:能力选择与工作目录是 **app 级**的(换它们要重启进程)⇒
|
|
222
|
+
两条跨了能力选择的 run,配置整个变了,diff 出来的差异**多半与你改的那句话无关**。
|
|
223
|
+
所以跨能力 / 跨目录 / 元信息缺失都给出**显式降级提示**(含可执行的出路:建议只改 prompt 后重跑),
|
|
224
|
+
而不是静默给一份没意义的 diff。可比时**不显示**提示条(每次都飘噪声等于没有提示)。
|
|
225
|
+
|
|
226
|
+
⚠️ **并排是「事后回看」,不是「同时跑」**:单飞闸没解除 —— 解除会连带改变「文件变更延后重启」
|
|
227
|
+
的语义(两条判据今天刻意共用同一条忙闲判据),且 A/B 的收益全在「事后并排看差在哪」,
|
|
228
|
+
同时跑还多烧一倍 token。普通单击任意一条即退出并排。
|
|
229
|
+
|
|
230
|
+
**重跑后自动补对**:已经选了一条(还没选第二条)时,新 run 一收尾就**自动**并排 ——
|
|
231
|
+
面板会播一条 notice 说清「为什么屏幕变双栏了」。三种情况**不**自动:空选择(没表达
|
|
232
|
+
对比意图,自动切双栏是替用户决定)、已选两条(再塞一条会挤掉你刚选的)、同一条。
|
|
233
|
+
所以「改一句 prompt → 重跑 → 看差在哪」是**两步**,不是三步。
|
|
234
|
+
|
|
201
235
|
**中止在飞的 run**:run 跑着的时候输入条上会多一个**「■ 中止」**按钮(没在飞时不显示)。
|
|
202
236
|
点了之后:
|
|
203
237
|
- run 在**回合边界**以 `stopReason='aborted'` **正常收尾**,**trace 照常落盘**(面板也会说「已中止」,
|
|
@@ -1672,6 +1706,7 @@ const callable = {
|
|
|
1672
1706
|
| 任务进度流的边界(内存 / 跨进程) | `GET /tasks/:id/stream` 的事件缓冲在**跑任务的进程内存**里:每任务最近 500 条(可用 `AsyncRunner` 的 `streamBufferEvents` 调,须为正整数,坏值构造期抛错),超限丢**最旧**并先发一帧 `stream.truncated`;终态流只留最近 16 条。**没跑完**的流另有一道**缓冲配额**(`streamNonTerminalBuffers`,缺省 32 条流;`0` = 不留):超出即从最旧、**没有订阅者**的流开始回收**回放历史** —— 表项与序号都留着,连上来的读者先收一帧 `stream.truncated` 再转实时(**不会**变成 `stream.unavailable`,那是对别的进程说的话);正在被订阅的流不受配额约束(不抽走正在读的数据)。**跨进程**(队列消费者在别的进程)时没有实时流:发一帧 `stream.unavailable` 后**立即收口** —— 任务已终态则补 `task.end`(随后关连接);**非终态**则补一帧 `stream.closed`(流级收尾,不是伪造终态)并关连接,客户端此后应转去轮询 `GET /tasks/:id`。要跨进程实时请用 `onTraceEvent` 把事件转发到宿主自己的总线(Redis Streams / Kafka) |
|
|
1673
1707
|
| 异步任务的**排队段**边界(内存) | `concurrency` 管「同时在跑几个」,**不管**「跑不上、排在后面」那一段 —— 缺省**排队段无上限**:`submit` 总是收单、`POST /tasks` 总是 202,灌多少就在内存里排多少(每条 = 一条记录 + 一棵悬挂的 `#execute` promise + 一个槽位等待者)。要背压就配 `AsyncRunner({ maxQueued })`:超限 `submit` 抛 `TaskQueueFullError`、HTTP 回 **503 + `Retry-After`**(**只在 `submit` 判** —— 恢复路径推进的是已受理任务,拦下来等于把它搁死在 store 里;幂等键命中的重复提交也不吃 503)。`runner.queued` 是实时读数(与闸同一判据)。⚠️ 进程内拒单**不等于**集群级背压:多副本各算各的,要全局就得上队列 |
|
|
1674
1708
|
| `traceLimits` 与 `maxEventChars` 各管一头 | `maxEventChars` 管**单个事件正文多长**(既有),`traceLimits.maxEvents` 管**整条 trace 记多少条记录事件**(本版)。两者正交、都「不设 = 不限」;超限即**停止记账**,并在 run 根留**两笔** `trace.truncated`:**起点标记** `{limit}`(断点在这里)+ **收尾摘要** `{droppedEvents, limit}`(丢了多少)—— 两笔**同时**进交付的 trace 与 `onTraceEvent` 增量流(⇒ 按 `seq` 折回仍逐字等于收尾那份;两笔簿记**不占** `maxEvents` 配额)。⚠️ 实测 `maxEventChars: false` + 大出参会让 trace 放大 **13.7×**(`npm run bench:trace` 可复现)—— 先收长度再谈采样,收益顺序比反过来大 |
|
|
1709
|
+
| 回给模型的 `tool_result` **不截断、也没有大小闸** | `maxEventChars` / `traceContent` 管的是**记账侧**(trace 记多长、记不记正文)—— 回给模型的工具结果**永远完整**,框架里没有「单条工具结果超过多大就裁」的那条缝。一次 `cat` 大文件、一次刷屏回显就能让**下一回合**的输入爆掉上下文窗口,以 api 错误收尾(长跑里尤其容易撞)。要设输出上限请在**工具实现内部**做(按行数 / 字节截断并附「已截断」标记)—— 框架不替你兜 |
|
|
1675
1710
|
| **采样是导出决策,不是记账决策** | 采样在 `sink` 外做(配方见 `docs/observability.md` 2.3):被采样掉的 trace 在框架内**仍然完整记账**,只是没发给下游。所以别拿「有采样」当「可以少记账」;也正因如此,出站 `traceparent` 的 flags 恒 `00`(记录/导出决策发生在收尾之后,运行期不可知——不替下游声明) |
|
|
1676
1711
|
| 菜单漂移只**标记**、不判失败 | 续跑时未决 tool_use 的工具不在当前菜单 ⇒ 三处信号(`menu.drift` 事件 / 父 span attribute / `console.warn`),run **照常收尾**(那些 tool_use 以 `unknown tool` 回给模型)。**射程如实标注**:判据只看**名字** —— 「工具还在、schema 变了」能被 `tools.menuHash` 比出来,但**不会**在续跑时报 `menu.drift`;**只改 `description`** 连签名都不变(有意:润色文案不算漂移)。严格模式(带具名 error 的收尾)与「结果级字段」记在 spec §11 开放项 |
|
|
1677
1712
|
| `approve` 只收**待决的** id(多一个 → 400 整批拒) | `decisions` 的键必须都在该任务的 `pendingApprovals` 里(调用方从记录的 `pendingApprovals` 读)。多出任何一个 ⇒ **400 + 整批拒 + 记录一字不动**。不校验的代价(已修,2026-09-28 外部深评):多出来的键会写进 `TaskRecord.approvals`,随每次 `save` 全文重写、且终态也不清 ⇒ 一个**认证调用方**单次请求就能把记录撑大(实证:一次 5000 个无关键 = +419 KB),反复调用可无限叠加 |
|
|
@@ -1745,6 +1780,7 @@ const callable = {
|
|
|
1745
1780
|
| Scheduler 调度表不落库 | `every` / `at` 的调度本身只在内存:已 submit 的任务记录能经 `resumePending` 续跑,但「未来某刻再触发」的调度在重启后不存在(远期单发由宿主自己的 cron 驱动)。另:`drain()` 不停 Scheduler —— 停机窗口内到点的 tick 会打一条触发失败日志(无害但吵),介意就 `scheduler.stop()` 先行 |
|
|
1746
1781
|
| file store 的撕裂写只在启动时自愈 | 写入中途失败(磁盘满等)留下的残行由 `healTail` 在**构造期**修复;同进程内继续 append 会把新记录粘在残行尾部、下次启动时一起丢弃 —— 磁盘满告警后先恢复写入能力再继续依赖它 |
|
|
1747
1782
|
| 终态落库失败 ⇒ 重启会重跑 | AsyncRunner 终态 `save` 失败**不遮罩主流程**(「不击穿业务」的代价):store 抖动时任务可能永远停在 `running`,重启后 `resumePending` 会重跑一个**实际已成功**(副作用已发生)的任务 —— 所以副作用工具必须自身幂等。**但失败本身不再静默**:`new AsyncRunner(app, { onPersistError })` 会收到 `{ record, error, phase }`(`phase: 'initial' \| 'outcome'`,后者就是这条)。⚠️ 框架**修不了**它(写不进去就是写不进去)—— 出口的职责是让你能对账、让「记录无声丢失」不再是默认行为(与 `createOtlpExporter({ onExportError })` 同因同形) |
|
|
1783
|
+
| 缺省**不裁剪**上下文(长跑每回合重发全量历史 ⇒ 成本随回合数平方增长) | `contextPolicy` 不注入就一个回合都不裁:每回合把**整段历史**重发给模型,于是回合数翻倍、总输入约翻两番。框架给了缝与一份参考实现(`createBudgetPolicy`),但**不替你决定** —— 裁剪有损(截在哪、留什么会影响答案),是宿主的策略。长任务(几十回合以上)请在 `createApp` 里显式注入 `contextPolicy` |
|
|
1748
1784
|
| `contextPolicy` 不进子循环 | 应用级 `contextPolicy` / `onText` 只对主循环生效:@SubAgent / @Skill 的子运行不做上下文裁剪(长跑子 agent 撞上下文上限会以 api 错误收尾)。预算护栏(`maxTotalTokens` / `maxCostUsd`)正常透传 |
|
|
1749
1785
|
| 记忆没有删除语义 | `MemoryStore` 只有 load/save:run 内 `ctx.delete` 掉的键回写时不会从 store 移除(下一轮水合会复活)。要真删请直接操作 store 实现 |
|
|
1750
1786
|
| 同 keys 的并发回写不合并 | CAS(`loadWithRev` + `saveIfRev`)只做到「冲突时**不写 + 出声**」:框架**不合并、不重试**(合并语义是 store 与宿主的决定,配方见 §6.5)。只有 `load` / `save` 的 store 连冲突都**看不见** —— 版本号是唯一依据,没有它就检测不了覆盖;`InMemoryMemoryStore` 的版本粒度是**整店一个计数**(宁可多报冲突,不漏报真实覆盖) |
|
package/dist/dev-protocol.js
CHANGED
|
@@ -29,5 +29,19 @@ export const SESSION_REL = '.agentia/session.json';
|
|
|
29
29
|
* 用户刚「清空」掉的对话会**自己回来**(那是最典型的静默不一致)。
|
|
30
30
|
*/
|
|
31
31
|
export const SESSION_ID_REL = '.agentia/dev-session-id';
|
|
32
|
+
/**
|
|
33
|
+
* dev 环**落盘 trace** 的位置(相对项目根)。
|
|
34
|
+
*
|
|
35
|
+
* 为什么与 `session.json` 同目录:`.agentia/` 已被脚手架 gitignore(`templates/gitignore`),
|
|
36
|
+
* 它就是 dev 环的本机状态目录 —— 落在这里的东西不进版本库、不用再问一次「要不要 ignore」。
|
|
37
|
+
*
|
|
38
|
+
* 文件格式 = `jsonlTraceSink` 的格式(一行一条**裸** `Trace`),而那正是 CLI 四个命令的
|
|
39
|
+
* **输入**:`agentia report` / `diff` / `harvest` / `export`(`src/integrations/file-sink.ts`
|
|
40
|
+
* 的头注逐字点名了前三个,`export` 是R8-P3b 加的同族)。⇒ dev 环跑过的 run 直接可被它们读。
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **只增不减**:刻意不轮转、不自动清(dev 单条 trace 以百 KB 计;真要清是用户自己
|
|
43
|
+
* `rm`)。要长期开着一个 dev 环的得知道这件事 —— 写在 `usage-guide.md` §2.2。
|
|
44
|
+
*/
|
|
45
|
+
export const TRACE_LOG_REL = '.agentia/traces.jsonl';
|
|
32
46
|
/** dev 环**初始** sessionId(面板「清空」后依次变成 `dev-2` / `dev-3` …) */
|
|
33
47
|
export const DEV_SESSION_ID = 'dev';
|
package/dist/dev-runner.js
CHANGED
|
@@ -20,7 +20,7 @@ import { createRequire } from 'node:module';
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { pathToFileURL } from 'node:url';
|
|
22
22
|
import { createInspectEventSink, createInspectSink, } from './inspector-sink.js';
|
|
23
|
-
import { APP_ENTRY_REL, DEFAULT_BUDGET, DEV_CONFIG_REL, } from './dev-protocol.js';
|
|
23
|
+
import { APP_ENTRY_REL, DEFAULT_BUDGET, DEV_CONFIG_REL, TRACE_LOG_REL, } from './dev-protocol.js';
|
|
24
24
|
const root = process.env.AGENTIA_DEV_ROOT || process.cwd();
|
|
25
25
|
function send(msg) {
|
|
26
26
|
process.send?.(msg);
|
|
@@ -82,6 +82,52 @@ function registerEventSink() {
|
|
|
82
82
|
const token = process.env.AGENTIA_INSPECT_TOKEN || undefined;
|
|
83
83
|
eventSink = createInspectEventSink({ port, ...(token ? { token } : {}) });
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* ③ **落盘** trace(档A / `docs/plans/2026-10-05-dev-workbench.md` §2):每次 run 收尾往
|
|
87
|
+
* `.agentia/traces.jsonl` 追加一行裸Trace。
|
|
88
|
+
*
|
|
89
|
+
* 为什么值得挂:那个文件的格式**恰好就是** CLI 三个命令的输入(`jsonlTraceSink` 的头注
|
|
90
|
+
* 逐字点名了 `agentia report` / `diff` / `harvest`,加上 `export` 是四个)⇒ 挂上之后
|
|
91
|
+
* 面板里跑过的每一次 run,立刻变成「`agentia report` 看哪个能力慢/贵/爱失败」、
|
|
92
|
+
* 「`agentia diff` 改 prompt 后轨迹漂没漂」这两件事的**现成输入**。开发期最贵的
|
|
93
|
+
* 一件事是「拿不到刚才那次 run 的数据」,这一行就把它解决了。
|
|
94
|
+
*
|
|
95
|
+
* 三条纪律(每条都有代价,如实标注):
|
|
96
|
+
* - **写失败不阻断 run**(`catch` + `console.warn`):与 `registerTraceSink` 同款 ——
|
|
97
|
+
* 观测是增强项,磁盘满 / 目录只读不该让dev 起不来。
|
|
98
|
+
* - **不轮转、不清空**:dev 单条 trace 以百 KB 计,而 `.agentia/` 已被脚手架 gitignore。
|
|
99
|
+
* 代价是**长跑 dev 环会攒一个只增不减的文件**;要清就是用户自己 `rm`(文档里写一句)。
|
|
100
|
+
* - **单进程写者前提天然满足**:该 sink 的契约是「多进程写同一文件会交错」,而 dev环
|
|
101
|
+
* 只有 runner 这一个写者(父进程走HTTP 收,不碰这个文件)。
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ **两条缝,不是替代关系**:落盘管「事后能查」,`onTraceEvent` 管「此刻看到」。
|
|
104
|
+
* 挂落盘**不碰** `opts.onTraceEvent`(那行仍在 `runOnce` 里),反之亦然。
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ **顺序**:`createApp` 构造期对 `defaultSinks` 取快照 ⇒ 任何 sink 都必须早于
|
|
107
|
+
* 用户 app 的 import(`main()` 里 `registerTraceSink` 那条注释已为这件事把关)。
|
|
108
|
+
*/
|
|
109
|
+
async function registerJsonlSink() {
|
|
110
|
+
try {
|
|
111
|
+
const req = createRequire(join(root, 'package.json'));
|
|
112
|
+
const entry = req.resolve('@migor/agentia');
|
|
113
|
+
const mod = (await import(pathToFileURL(entry).href));
|
|
114
|
+
if (typeof mod.jsonlTraceSink !== 'function') {
|
|
115
|
+
throw new Error('@migor/agentia 未导出 jsonlTraceSink(版本过旧?)');
|
|
116
|
+
}
|
|
117
|
+
// ⚠️ 两个导出都要,缺一不可:`jsonlTraceSink` 只是**工厂**(返回 `TraceSink`),
|
|
118
|
+
// 真正让框架每run 收尾调它的是 `registerDefaultTraceSink`(与上面 inspector 那条同款)。
|
|
119
|
+
// 只写前者 = 建了个 sink 变量没人用 ⇒ 静默不落盘(本仓最忌讳的那类假绿)。
|
|
120
|
+
if (typeof mod.registerDefaultTraceSink !== 'function') {
|
|
121
|
+
throw new Error('@migor/agentia 未导出 registerDefaultTraceSink(版本过旧?)');
|
|
122
|
+
}
|
|
123
|
+
mod.registerDefaultTraceSink(mod.jsonlTraceSink({ path: join(root, TRACE_LOG_REL) }));
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
// 同 registerTraceSink:挂不上是增强项缺失,不阻断 dev。**不静默** —— 打在终端上,
|
|
127
|
+
// 否则「跑了一堆 run 却发现 report 没数据」会查不到原因。
|
|
128
|
+
console.warn(`[agentia] trace 未落盘(${TRACE_LOG_REL}):${e.message}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
85
131
|
// ---------- dev.config.ts(**数据**,不是逻辑) ----------
|
|
86
132
|
/**
|
|
87
133
|
* 读 `src/dev.config.ts`。
|
|
@@ -429,6 +475,8 @@ async function runOnce(msg) {
|
|
|
429
475
|
async function main() {
|
|
430
476
|
// ⚠️ 顺序不能换:sink 必须早于用户 app 的 import(createApp 构造期对 defaultSinks 快照)
|
|
431
477
|
await registerTraceSink();
|
|
478
|
+
// 落盘 sink(档 A):同样必须早于 import —— 它也是 defaultSinks 的一员。
|
|
479
|
+
await registerJsonlSink();
|
|
432
480
|
// 增量出口只是个 HTTP 出口(不碰框架注册表),但同样在这里建:一个进程一次。
|
|
433
481
|
registerEventSink();
|
|
434
482
|
let built;
|
package/dist/inspector-page.html
CHANGED
|
@@ -72,6 +72,28 @@
|
|
|
72
72
|
#ttl { flex: none; }
|
|
73
73
|
#trace { flex: 1 1 auto; min-height: 140px; overflow: auto; }
|
|
74
74
|
#summary, #reply, .usage { flex: none; max-height: 38vh; overflow: auto; }
|
|
75
|
+
/* 并排 A/B:两棵树各占一半、**各自独立滚**(一棵树很长时不该把另一棵推出屏幕 ——
|
|
76
|
+
与「树是唯一无界部分」那条纪律同款,只是现在无界的部分有两块)。
|
|
77
|
+
`#trace2` 只在并排时存在(单run 时 hidden),此时 `#trace` 让出 50%。 */
|
|
78
|
+
.cmp-on #trace { flex: 1 1 50%; }
|
|
79
|
+
.tr-compare {
|
|
80
|
+
flex: 1 1 50%; min-height: 140px; overflow: auto;
|
|
81
|
+
border-left: 1px solid var(--border);
|
|
82
|
+
}
|
|
83
|
+
#diffbar { flex: none; max-height: 30vh; overflow: auto; padding-bottom: 6px; }
|
|
84
|
+
#diff-note {
|
|
85
|
+
padding: 5px 8px; margin-bottom: 6px; border-left: 2px solid var(--warn);
|
|
86
|
+
background: var(--pop); color: var(--text); border-radius: 0 4px 4px 0;
|
|
87
|
+
}
|
|
88
|
+
#diff-body .dfield { padding: 1px 0; color: var(--faint); }
|
|
89
|
+
#diff-body .dfield b { color: var(--text); font-weight: 500; }
|
|
90
|
+
#diff-body .dspan { padding: 1px 0 1px 12px; color: var(--faint); }
|
|
91
|
+
#diff-body .dpath { color: var(--ice); }
|
|
92
|
+
#diff-body .dempty { padding: 4px 0; color: var(--faint); }
|
|
93
|
+
/* run 列表里被选进对比的两条:给出**独立于 active** 的标记(active 是「正在看的那条」,
|
|
94
|
+
两者可以不同 —— 正在看的可能不在对比里)。用左边框双线区分,不靠颜色。 */
|
|
95
|
+
.run.cmp { box-shadow: inset 2px 0 0 var(--warn); }
|
|
96
|
+
.run.cmp.active { box-shadow: inset 2px 0 0 var(--ice), inset 4px 0 0 var(--warn); }
|
|
75
97
|
/* 窄屏:两栏并排已经太窄 ⇒ 回到单列 + 整页滚动(此时「整页滚」才是唯一合理的形态) */
|
|
76
98
|
@media (max-width: 860px) {
|
|
77
99
|
html, body { height: auto; }
|
|
@@ -79,9 +101,10 @@
|
|
|
79
101
|
main { grid-template-columns: minmax(0, 1fr); }
|
|
80
102
|
aside { overflow: visible; }
|
|
81
103
|
section { overflow: visible; }
|
|
82
|
-
#runs, #chat, #trace { overflow: visible; }
|
|
83
|
-
#trace { min-height: 320px; }
|
|
84
|
-
|
|
104
|
+
#runs, #chat, #trace, .tr-compare { overflow: visible; }
|
|
105
|
+
#trace, .tr-compare { min-height: 320px; }
|
|
106
|
+
.cmp-on #trace { flex: 1 1 auto; }
|
|
107
|
+
#summary, #reply, .usage, #diffbar { max-height: none; }
|
|
85
108
|
}
|
|
86
109
|
.run {
|
|
87
110
|
display: block; width: 100%; text-align: left; cursor: pointer;
|
|
@@ -259,7 +282,21 @@
|
|
|
259
282
|
</aside>
|
|
260
283
|
<section>
|
|
261
284
|
<div id="ttl"></div>
|
|
262
|
-
|
|
285
|
+
<!--
|
|
286
|
+
并排 A/B 的差异摘要(档 B)。默认 hidden,只有选满两条 run 才出现。
|
|
287
|
+
三段结构:可比性提示(不可比时才有 —— 可比时**不显示**,见 comparability 的注释)、
|
|
288
|
+
run 级 summary、逐span 差异。
|
|
289
|
+
-->
|
|
290
|
+
<div id="diffbar" hidden>
|
|
291
|
+
<div id="diff-note" class="diff-note" hidden></div>
|
|
292
|
+
<div id="diff-body"></div>
|
|
293
|
+
</div>
|
|
294
|
+
<!--
|
|
295
|
+
双栏容器:并排时这里是**两个** trace 视图,单run 时只有一个。
|
|
296
|
+
⚠️ 不用「同一棵树改CSS 成两半」—— trace-view 的树是自绘的,两棵树必须两个容器。
|
|
297
|
+
-->
|
|
298
|
+
<div id="trace" class="tr-single"><div class="tr-empty">// 选一个 run 查看调用树</div></div>
|
|
299
|
+
<div id="trace2" class="tr-compare" hidden></div>
|
|
263
300
|
<!-- 能力排行(G2):哪个能力慢 / 贵 / 爱失败 —— 调优从这里下手 -->
|
|
264
301
|
<div id="summary"></div>
|
|
265
302
|
<pre id="reply" hidden></pre>
|
|
@@ -320,9 +357,16 @@
|
|
|
320
357
|
HISTORY_DRAFT,
|
|
321
358
|
applyTraceEvent,
|
|
322
359
|
browseTarget,
|
|
360
|
+
autoPairTarget,
|
|
323
361
|
capabilityBadge,
|
|
324
362
|
chatViewVisible,
|
|
325
363
|
collapseDecision,
|
|
364
|
+
comparability,
|
|
365
|
+
COMPARE_MAX,
|
|
366
|
+
compareView,
|
|
367
|
+
diffSideLabel,
|
|
368
|
+
diffValueText,
|
|
369
|
+
diffFetchErrorText,
|
|
326
370
|
emptyTraceAccumulator,
|
|
327
371
|
filterSelected,
|
|
328
372
|
formatToolSources,
|
|
@@ -339,6 +383,7 @@
|
|
|
339
383
|
runDoneNotice,
|
|
340
384
|
runIsFailure,
|
|
341
385
|
shortenPath,
|
|
386
|
+
toggleCompare,
|
|
342
387
|
turnKey,
|
|
343
388
|
} from './panel-logic.js';
|
|
344
389
|
|
|
@@ -349,6 +394,10 @@
|
|
|
349
394
|
const chatSidEl = $('chat-sid');
|
|
350
395
|
const chatClearEl = $('chat-clear');
|
|
351
396
|
const traceEl = $('trace');
|
|
397
|
+
const trace2El = $('trace2');
|
|
398
|
+
const diffbarEl = $('diffbar');
|
|
399
|
+
const diffNoteEl = $('diff-note');
|
|
400
|
+
const diffBodyEl = $('diff-body');
|
|
352
401
|
const summaryEl = $('summary');
|
|
353
402
|
const ttlEl = $('ttl');
|
|
354
403
|
const liveEl = $('live');
|
|
@@ -376,6 +425,167 @@
|
|
|
376
425
|
usage: { in: $('u-in'), out: $('u-out') },
|
|
377
426
|
});
|
|
378
427
|
|
|
428
|
+
// 并排 A/B 的**第二棵**树。刻意不共用 `view`:`createTraceView` 绑一个容器 +
|
|
429
|
+
// 一个 usage 出口,两棵树塞进同一容器会互相清空(trace-view 的 `playTrace` 是
|
|
430
|
+
// 整容器替换式的)。共用 `u-in`/`u-out` 也无意义 —— 那两个读数属于「正在看的那条」。
|
|
431
|
+
const view2 = createTraceView(trace2El);
|
|
432
|
+
|
|
433
|
+
// ---------- 并排 A/B(档 B) ----------
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 复位并排的 **DOM**(**不动** `state.compare`)—— 让 `exitCompare` 与
|
|
437
|
+
* `refreshCompare` 的 picking 分支共用同一份复位序列。
|
|
438
|
+
*
|
|
439
|
+
* 抽出来不是为了省行数:这两处一旦分叉,就只剩一处是对的 —— 评审实证的缺陷
|
|
440
|
+
* 正是「清了状态、没复位 DOM」的形态(`trace2` 留着上一对的树 + 差异摘要还是
|
|
441
|
+
* 旧的,而列表里的选中标记已经没了 ⇒ 用户按看到的读,读到的是张冠李戴)。
|
|
442
|
+
* ⚠️ 面板里**只许这一处**做复位序列;guard 在 `test/structure.test.mjs`(W4)。
|
|
443
|
+
*/
|
|
444
|
+
function resetCompareDom() {
|
|
445
|
+
trace2El.replaceChildren();
|
|
446
|
+
trace2El.hidden = true;
|
|
447
|
+
diffbarEl.hidden = true;
|
|
448
|
+
document.body.classList.remove('cmp-on');
|
|
449
|
+
renderList();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 退出并排,回到单 run 视图(清选择 + 复位 DOM)。
|
|
454
|
+
*
|
|
455
|
+
* 为什么必须把 `trace2` **清空**而不是只 `hidden`:它内部是 trace-view 自己建的
|
|
456
|
+
* DOM,留着旧树而下一轮 `hidden` ⇒ 再进并排时若新树没画成功,用户看到的是
|
|
457
|
+
* **上一轮别的 run 的调用树**,却以为在比当前两条 —— 静默张冠李戴,最坏的一种。
|
|
458
|
+
*/
|
|
459
|
+
function exitCompare() {
|
|
460
|
+
state.compare = [];
|
|
461
|
+
resetCompareDom();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 选满两条 ⇒ 拉差异 + 双栏;不足两条 ⇒ 回单 run 视图(三态判定在 panel-logic) */
|
|
465
|
+
async function refreshCompare() {
|
|
466
|
+
const mode = compareView(state.compare);
|
|
467
|
+
if (mode === 'single') {
|
|
468
|
+
exitCompare();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (mode === 'picking') {
|
|
472
|
+
// 只选了一条:双栏显示一条是误导(「并排」却只有一栏)⇒ 回单run 视图,
|
|
473
|
+
// 但**保留选择**(用户正在挑第二条,别把第一条也清了)。
|
|
474
|
+
resetCompareDom();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const [a, b] = state.compare;
|
|
478
|
+
document.body.classList.add('cmp-on');
|
|
479
|
+
trace2El.hidden = false;
|
|
480
|
+
diffbarEl.hidden = false;
|
|
481
|
+
renderList();
|
|
482
|
+
for (const [id, v, el] of [
|
|
483
|
+
[a, view, traceEl],
|
|
484
|
+
[b, view2, trace2El],
|
|
485
|
+
]) {
|
|
486
|
+
const res = await fetch('/api/runs/' + encodeURIComponent(id));
|
|
487
|
+
if (!res.ok) {
|
|
488
|
+
// ⚠️ **不能 `continue`(静默留白)**:那一栏会空着,看起来像「这条没有调用树」,
|
|
489
|
+
// 而真相是「这条 run 已不在内存 ring 里了」(`/api/runs` 只回 50 条)。
|
|
490
|
+
// 面板不解释 ⇒ 用户把它归因到别处(改坏了?)—— 与本档「不静默」口径同源。
|
|
491
|
+
el.replaceChildren();
|
|
492
|
+
const miss = document.createElement('div');
|
|
493
|
+
miss.className = 'tr-empty';
|
|
494
|
+
miss.textContent = `读不到这条 run 的调用树(可能已被淘汰):${id}`;
|
|
495
|
+
el.appendChild(miss);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const t = await res.json();
|
|
499
|
+
el.replaceChildren();
|
|
500
|
+
playTrace(v, t);
|
|
501
|
+
}
|
|
502
|
+
await loadDiff(a, b);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 拉并渲染差异摘要。
|
|
507
|
+
*
|
|
508
|
+
* ⚠️ **可比性提示必须先于差异显示** —— 用户看到一堆 span 差异却不知道这两条
|
|
509
|
+
* 根本不可比(跨了能力选择),结论会归因到错的地方。顺序即语义。
|
|
510
|
+
* 且可比时**不显示**提示条(`message` 为空串)—— 每次都飘一条噪声等于没有提示。
|
|
511
|
+
*/
|
|
512
|
+
async function loadDiff(a, b) {
|
|
513
|
+
diffBodyEl.replaceChildren();
|
|
514
|
+
const res = await fetch(
|
|
515
|
+
`/api/runs/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`,
|
|
516
|
+
);
|
|
517
|
+
if (!res.ok) {
|
|
518
|
+
// ⚠️ 解析失败要**单独说**(`parseFailed`)—— 折成「HTTP 500」会把
|
|
519
|
+
// 「响应不是 JSON」(网关插页 / token 过期返 HTML)说成服务端故障。
|
|
520
|
+
let body = null;
|
|
521
|
+
let parseFailed = false;
|
|
522
|
+
try {
|
|
523
|
+
body = await res.json();
|
|
524
|
+
} catch {
|
|
525
|
+
parseFailed = true;
|
|
526
|
+
}
|
|
527
|
+
const d = document.createElement('div');
|
|
528
|
+
d.className = 'dempty';
|
|
529
|
+
d.textContent = diffFetchErrorText(res.status, body, parseFailed);
|
|
530
|
+
diffBodyEl.appendChild(d);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const payload = await res.json();
|
|
534
|
+
if (payload.comparability.message) {
|
|
535
|
+
diffNoteEl.textContent = payload.comparability.message;
|
|
536
|
+
diffNoteEl.hidden = false;
|
|
537
|
+
} else {
|
|
538
|
+
diffNoteEl.textContent = '';
|
|
539
|
+
diffNoteEl.hidden = true;
|
|
540
|
+
}
|
|
541
|
+
renderDiff(payload.diff);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 差异摘要的渲染。**没有差异时如实说「无差异」**,不显示空区块。 */
|
|
545
|
+
function renderDiff(diff) {
|
|
546
|
+
diffBodyEl.replaceChildren();
|
|
547
|
+
if (diff.equal) {
|
|
548
|
+
const d = document.createElement('div');
|
|
549
|
+
d.className = 'dempty';
|
|
550
|
+
d.textContent = '两条 trace 等价(无结构与字段差异)';
|
|
551
|
+
diffBodyEl.appendChild(d);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
for (const s of diff.summary) {
|
|
555
|
+
const row = document.createElement('div');
|
|
556
|
+
row.className = 'dfield';
|
|
557
|
+
const name = document.createElement('b');
|
|
558
|
+
name.textContent = s.field;
|
|
559
|
+
row.append(
|
|
560
|
+
name,
|
|
561
|
+
document.createTextNode(` ${diffValueText(s.a)} → ${diffValueText(s.b)}`),
|
|
562
|
+
);
|
|
563
|
+
diffBodyEl.appendChild(row);
|
|
564
|
+
}
|
|
565
|
+
for (const sp of diff.spans) {
|
|
566
|
+
const row = document.createElement('div');
|
|
567
|
+
row.className = 'dspan';
|
|
568
|
+
const path = document.createElement('span');
|
|
569
|
+
path.className = 'dpath';
|
|
570
|
+
path.textContent = sp.path;
|
|
571
|
+
row.appendChild(path);
|
|
572
|
+
if (sp.missing) {
|
|
573
|
+
row.appendChild(
|
|
574
|
+
document.createTextNode(` (只在 ${diffSideLabel(sp.missing)} 侧)`),
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
for (const [k, v] of Object.entries(sp.fields)) {
|
|
578
|
+
const f = document.createElement('div');
|
|
579
|
+
f.className = 'dfield';
|
|
580
|
+
const key = document.createElement('b');
|
|
581
|
+
key.textContent = k;
|
|
582
|
+
f.append(key, document.createTextNode(` ${diffValueText(v.a)} → ${diffValueText(v.b)}`));
|
|
583
|
+
row.appendChild(f);
|
|
584
|
+
}
|
|
585
|
+
diffBodyEl.appendChild(row);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
379
589
|
// ---------- 状态 ----------
|
|
380
590
|
// 只放「渲染要用到的东西」;所有判断都走 panel-logic(那份有单测,这份没有)
|
|
381
591
|
const state = {
|
|
@@ -391,6 +601,13 @@
|
|
|
391
601
|
tab: 'runs',
|
|
392
602
|
list: [],
|
|
393
603
|
current: null,
|
|
604
|
+
/**
|
|
605
|
+
* **并排 A/B 的选择**(最多 `COMPARE_MAX` 条traceId):⌘/Ctrl + 单击 run 条目
|
|
606
|
+
* 往里加、再点一次取消、超过两块挤掉最早那条(规则全在 panel-logic 的
|
|
607
|
+
* `toggleCompare`,那份有单测)。选满两条 ⇒ 右栏切双栏 + 顶部一条差异摘要。
|
|
608
|
+
*空数组 = 单run 视图(`state.current` 那一套照旧)。
|
|
609
|
+
*/
|
|
610
|
+
compare: [],
|
|
394
611
|
reply: '',
|
|
395
612
|
/**
|
|
396
613
|
* 这段回复是**哪个 traceId** 的(`run-done` 带回来的)。`open()` 靠它判断
|
|
@@ -539,7 +756,11 @@
|
|
|
539
756
|
// runIsFailure,与通知条同一条语义)—— 给中止的 run 标红会让用户以为
|
|
540
757
|
// 是自己把工程改坏了,而那正是「aborted 也带 error」这条引擎约定造成的误读。
|
|
541
758
|
const tone = runIsFailure(r) ? ' err' : r.stopReason === 'aborted' ? ' aborted' : '';
|
|
542
|
-
b.className =
|
|
759
|
+
b.className =
|
|
760
|
+
'run' +
|
|
761
|
+
tone +
|
|
762
|
+
(r.traceId === state.current ? ' active' : '') +
|
|
763
|
+
(state.compare.includes(r.traceId) ? ' cmp' : '');
|
|
543
764
|
b.type = 'button';
|
|
544
765
|
|
|
545
766
|
const r1 = document.createElement('span');
|
|
@@ -565,7 +786,23 @@
|
|
|
565
786
|
r3.textContent = `${short(r.note.workdir)} · ${caps}${r.note.multiTurn ? ' · 多轮' : ''}`;
|
|
566
787
|
b.appendChild(r3);
|
|
567
788
|
}
|
|
568
|
-
|
|
789
|
+
// 普通单击 = 打开那一条;**⌘/Ctrl + 单击** = 进出并排 A/B 的选择。
|
|
790
|
+
// 为什么不做成「勾选框」:run 条目整行是 button,塞 checkbox 要改点击语义
|
|
791
|
+
//(点勾选框不该打开那条)—— 而「修饰键 + 单击」是列表多选的通用手势。
|
|
792
|
+
b.addEventListener('click', (ev) => {
|
|
793
|
+
if (ev.metaKey || ev.ctrlKey) {
|
|
794
|
+
state.compare = toggleCompare(state.compare, r.traceId);
|
|
795
|
+
void refreshCompare();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
// 普通单击切到别的 run ⇒ 退出并排(否则右栏两棵树与左栏高亮不一致,
|
|
799
|
+
// 用户点谁就以为在看谁)。
|
|
800
|
+
// ⚠️ **必须走 `exitCompare()`,不能只清 `state.compare`** —— 它顺带复位 DOM。
|
|
801
|
+
// 只清状态会让右栏与差异摘要**留在屏幕上**(内容还是上一对的),而列表里的选中
|
|
802
|
+
// 标记已经没了 ⇒ 用户按看到的读,读到的是张冠李戴(评审实证的形态)。
|
|
803
|
+
if (state.compare.length > 0) exitCompare();
|
|
804
|
+
open(r.traceId);
|
|
805
|
+
});
|
|
569
806
|
runsEl.appendChild(b);
|
|
570
807
|
}
|
|
571
808
|
}
|
|
@@ -1174,6 +1411,21 @@
|
|
|
1174
1411
|
await refreshSession();
|
|
1175
1412
|
renderChat();
|
|
1176
1413
|
await open(s.traceId);
|
|
1414
|
+
// 「改 prompt → 重跑 → 对照」这一跳(档 B 收尾):用户已经选了一条(picking 态),
|
|
1415
|
+
// 而新 run 刚收尾 ⇒ **自动**补上第二条并进双栏。判定在 panel-logic(带单测),
|
|
1416
|
+
// `null` 表示「不该配对」(空选择 / 已选两条 / 同一 id)。
|
|
1417
|
+
// ⚠️ **不静默**:播一条 notice 说清屏幕为什么变双栏了;退出 = 普通单击任意一条。
|
|
1418
|
+
const paired = autoPairTarget(state.compare, s.traceId);
|
|
1419
|
+
if (paired) {
|
|
1420
|
+
state.compare = paired;
|
|
1421
|
+
// ⚠️ `refreshCompare` 的排布是「左栏 = 第一条」⇒ 列表高亮必须跟着走,
|
|
1422
|
+
// 否则又出现「点谁以为在看谁」的失真(与守卫 W4 守的是同一类)。
|
|
1423
|
+
// 而 `#ttl` 与右上角 usage 读数**留在最新那条**(用户最关心刚跑的那次)——
|
|
1424
|
+
// 这个分工是有意的,别在「统一」的名义下把它抹平。
|
|
1425
|
+
state.current = paired[0];
|
|
1426
|
+
setNotice('已把新 run 与上一条并排(普通单击任意一条退出对比)', false);
|
|
1427
|
+
void refreshCompare();
|
|
1428
|
+
}
|
|
1177
1429
|
};
|
|
1178
1430
|
es.addEventListener('dev', async (m) => {
|
|
1179
1431
|
const ev = JSON.parse(m.data);
|
package/dist/inspector-routes.js
CHANGED
|
@@ -25,6 +25,52 @@ import { readdirSync, statSync } from 'node:fs';
|
|
|
25
25
|
import { dirname, join, resolve } from 'node:path';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
import { DEFAULT_BUDGET, } from './dev-protocol.js';
|
|
28
|
+
// 并排 A/B:复用 `diff.ts` 那份**去类型移植副本**(CLI 零运行时依赖、不能 import 框架)
|
|
29
|
+
// ⇒ 面板看到的差异与 `agentia diff a.jsonl b.jsonl` 逐字相同。
|
|
30
|
+
// 可比性判据放 panel-logic(不是 diff.ts):它判的是**面板四个输入**的差异,
|
|
31
|
+
// 与 span 配对算法无关;放一起会让两个不同的关注点混在一个文件里。
|
|
32
|
+
//
|
|
33
|
+
// ⚠️ 严格 Trace 类型用 `Parameters<typeof diffTraces>[0]` 取,**不从 diff.ts 导出它**:
|
|
34
|
+
// 那边的 `interface Trace` 是「逐字同形移植」的**内部**声明,导出它等于给这份副本开
|
|
35
|
+
// 公共面(旁人会开始 import 它,于是改算法时它也成了要同步的第二个副本 —— 正是
|
|
36
|
+
// 逐字对拍要防的那件事)。用 Parameters 取,签名改了这里自动跟着变,零漂。
|
|
37
|
+
import { diffTraces } from './diff.js';
|
|
38
|
+
import { comparability } from './panel-logic.js';
|
|
39
|
+
/**
|
|
40
|
+
* 宽松 `TraceLike` → `diffTraces` 能吃的形状,**补上缺省的空容器**。
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ **为什么不直接强转**(`ta as unknown as StrictTrace`,本仓的坏习惯):
|
|
43
|
+
* `diffTraces` 内部有 `pushAttrDiffs` / `pushEventDiffs`,两者都**直接** `Object.keys(x)` /
|
|
44
|
+
* `x.length`,参数为 `undefined` 时抛 `Cannot convert undefined or null to object`。
|
|
45
|
+
* 而 `validateTrace`(入站校验)**只**要求 `spanId` / `name` / `startedAt` ——
|
|
46
|
+
* `attributes` 与 `events` 不在必填之列 ⇒ 一条裁剪过的 trace 能合法进 `/ingest`,
|
|
47
|
+
* 于是 compare 路由会500。
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ **为什么不改 `diff.ts`**:它是 `src/engine/trace-diff.ts` 的**逐字同形移植副本**
|
|
50
|
+
* (注释与 `structure.test.mjs` 都声明「改算法必须两边同步」)⇒ 在CLI 侧单方面加固会让
|
|
51
|
+
* 两侧**分叉**,而逐字对拍守护(`test/diff.test.mjs`)会当场红。那条路的正解是**框架侧
|
|
52
|
+
* 一起改**(`attributes ?? {}` / `events ?? []`),属`src/` 语义变更,要走 spec §10 ——
|
|
53
|
+
* **不在本稿范围**。这里在**消费侧**归一化,是零核心改动的正解。
|
|
54
|
+
*
|
|
55
|
+
* 归一化只补「缺省空容器」,**不改任何真值**:span 树、usage、rootSpanId、以及
|
|
56
|
+
* **已存在的 events 一律原样保留**(⚠️ 把 `events` 清空是静默失真 —— 那样「事件序列
|
|
57
|
+
* 差异」永远测不出来,而 diff 看起来仍然「正常」,比抛错更坏)。
|
|
58
|
+
*/
|
|
59
|
+
function normalizeForDiff(t) {
|
|
60
|
+
return {
|
|
61
|
+
...t,
|
|
62
|
+
spans: (t.spans ?? []).map((s) => ({
|
|
63
|
+
...s,
|
|
64
|
+
attributes: s.attributes ?? {},
|
|
65
|
+
// ⚠️ `TraceLike`(面板的宽松投影)**漏了`events` 字段** —— 运行时它是有的
|
|
66
|
+
//(`POST /ingest` 收到的是裸 `Trace`),但类型上访问不到 ⇒ 只能按索引读。
|
|
67
|
+
// `Array.isArray` 守卫是必要的:缺失时补 `[]`,**存在时原样保留**。
|
|
68
|
+
events: Array.isArray(s.events)
|
|
69
|
+
? s.events
|
|
70
|
+
: [],
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
28
74
|
/** token 的 cookie 名(面板首帧带 `?t=` 拿到它之后,后续请求自动带上) */
|
|
29
75
|
export const TOKEN_COOKIE = 'agentia_dev_token';
|
|
30
76
|
/** token 的自定义头名(非浏览器客户端 / 脚本用;不进 URL、不留痕) */
|
|
@@ -294,6 +340,55 @@ function handleRunDetail(ctx) {
|
|
|
294
340
|
}
|
|
295
341
|
ctx.json(ctx.res, 200, t);
|
|
296
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* 并排 A/B:`GET /api/runs/compare?a=<id>&b=<id>` → `{ diff, comparability }`。
|
|
345
|
+
*
|
|
346
|
+
* **为什么在服务端算diff,不在浏览器里算**:`diffTraces` 是框架的纯函数,而 CLI 侧
|
|
347
|
+
* 已有一份**去类型移植副本**(`diff.ts`;CLI 零运行时依赖、不能 import 框架,注释
|
|
348
|
+
* 声明「改算法必须两边同步」、逐字对拍守护在 `test/diff.test.mjs`)。复用那一份
|
|
349
|
+
* 才能保证「面板里看到的差异」与「`agentia diff a.jsonl b.jsonl` 得到的差异」**逐字
|
|
350
|
+
* 相同** —— 另写一份就是本仓反复踩的「两处口径会漂」。
|
|
351
|
+
*
|
|
352
|
+
* ⚠️ **可比性判据一起返回**(档 B §3.3):`diffTraces` 不知道面板四个输入里哪些变了,
|
|
353
|
+
* 跨了能力选择(app 级、重启进程)的两条 run 比出来的差异多半与用户改的那句话无关。
|
|
354
|
+
* 服务端算完一起给,面板**必须**把它显示出来 —— 静默给一份没意义的 diff 比不给更糟。
|
|
355
|
+
*/
|
|
356
|
+
function handleRunCompare(ctx) {
|
|
357
|
+
const a = ctx.url.searchParams.get('a');
|
|
358
|
+
const b = ctx.url.searchParams.get('b');
|
|
359
|
+
if (!a || !b) {
|
|
360
|
+
ctx.json(ctx.res, 400, { error: '缺少 a 或 b 参数(各给一个 traceId)' });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (a === b) {
|
|
364
|
+
// 不是「无害的重复」:同一条跟自己比恒等于无差异,面板会显示一份「没有差异」的
|
|
365
|
+
// 结论,而用户以为自己选了两条不同的 run。
|
|
366
|
+
ctx.json(ctx.res, 400, { error: 'a 与 b 是同一条 run —— 选两条不同的才能比出差异' });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const { runs, notes } = ctx.state;
|
|
370
|
+
// ⚠️ 两个 `get` 都要,**且都要用 `!` 显式收窄** —— 只查一次(`const ta = ...; if (!ta) 404`)
|
|
371
|
+
// 之后 TS 不会把「另一个也非空」这件事带过 `return`(Map.get 的返回是 `| undefined`,
|
|
372
|
+
// 与局部变量的控制流分析无关)。写成 `!ta || !tb` 才既短又能让 TS 收窄。
|
|
373
|
+
const ta = runs.get(a);
|
|
374
|
+
const tb = runs.get(b);
|
|
375
|
+
if (!ta || !tb) {
|
|
376
|
+
const missing = ta ? b : a;
|
|
377
|
+
ctx.json(ctx.res, 404, { error: `run 不存在(可能已被淘汰):${missing}` });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const noteA = notes.get(a) ?? null;
|
|
381
|
+
const noteB = notes.get(b) ?? null;
|
|
382
|
+
// 形状转换与 `diff.ts` 的 `readFirstTrace`(`:356`)**同款思路**(宽松投影 → 严格源类型),
|
|
383
|
+
// 但**多一步 `normalizeForDiff`** —— 直接强转会在裁剪过的 trace 上 500(那里讲清了原因)。
|
|
384
|
+
ctx.json(ctx.res, 200, {
|
|
385
|
+
diff: diffTraces(normalizeForDiff(ta), normalizeForDiff(tb)),
|
|
386
|
+
comparability: comparability(noteA, noteB),
|
|
387
|
+
// note 一起回:面板要显示「这两条当时用了什么能力 / 在哪个目录」,
|
|
388
|
+
// 否则一个「不可比」的结论没有上下文(用户不知道为什么这次比对不可信)。
|
|
389
|
+
notes: { a: noteA, b: noteB },
|
|
390
|
+
});
|
|
391
|
+
}
|
|
297
392
|
function handleDevState(ctx) {
|
|
298
393
|
ctx.json(ctx.res, 200, ctx.dev ? ctx.dev.state() : unavailableDevState());
|
|
299
394
|
}
|
|
@@ -490,7 +585,11 @@ async function handleStatic(ctx) {
|
|
|
490
585
|
* 的实现抛出,如 `pickFolder` 的 409/501)会**穿过本函数**冒泡给调用方的 catch,
|
|
491
586
|
* 由那里统一映射成状态码。
|
|
492
587
|
*
|
|
493
|
-
* ⚠️ 顺序即优先级:`/api/runs` 必须排在 `/api/runs/`
|
|
588
|
+
* ⚠️ 顺序即优先级:`/api/runs` 必须排在 `/api/runs/` 之前(后者是前缀匹配),
|
|
589
|
+
* **而 `/api/runs/compare` 必须排在 `/api/runs/` 之前** —— 它是那条前缀的**子路径**,
|
|
590
|
+
* 放后面会被 `handleRunDetail` 先吃掉,而那个 handler 会把 `compare?a=…&b=…`
|
|
591
|
+
* 整段当成 traceId 去查表 ⇒ 恒 404,且报错信息是「run 不存在」,完全指错方向。
|
|
592
|
+
* (`ctx.path` 是 `url.pathname`、**不含** query,所以前缀匹配按 `/api/runs/compare` 判。)
|
|
494
593
|
*/
|
|
495
594
|
export async function handleRoutes(ctx) {
|
|
496
595
|
const { req, path } = ctx;
|
|
@@ -500,6 +599,10 @@ export async function handleRoutes(ctx) {
|
|
|
500
599
|
return handleIngestEvent(ctx);
|
|
501
600
|
if (req.method === 'GET' && path === '/api/runs')
|
|
502
601
|
return handleListRuns(ctx);
|
|
602
|
+
// ⚠️ 顺序咬人:`compare` **必须**在下一行之前 —— 它是 `/api/runs/` 的子路径,
|
|
603
|
+
// 放后面会被 `handleRunDetail` 先吃掉(traceId 变成 "compare" ⇒ 恒 404 且报错指错方向)。
|
|
604
|
+
if (req.method === 'GET' && path === '/api/runs/compare')
|
|
605
|
+
return handleRunCompare(ctx);
|
|
503
606
|
if (req.method === 'GET' && path.startsWith('/api/runs/'))
|
|
504
607
|
return handleRunDetail(ctx);
|
|
505
608
|
if (req.method === 'GET' && path === '/api/dev')
|
package/dist/panel-logic.js
CHANGED
|
@@ -241,6 +241,158 @@ export function rememberNote(notes, traceId, note, max) {
|
|
|
241
241
|
notes.delete(oldest);
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
|
+
// ---------- 并排 A/B 的可比性判据(档 B / plans/2026-10-05-dev-workbench.md §3.3) ----------
|
|
245
|
+
/** 对比选择态的**上限**:并排就是并排两块,第三块没有版面(也读不过来) */
|
|
246
|
+
export const COMPARE_MAX = 2;
|
|
247
|
+
/**
|
|
248
|
+
* 点 run 条目时**对比选择**该怎么变(⌘/Ctrl + 单击;普通单击仍是「打开」)。
|
|
249
|
+
*
|
|
250
|
+
* 三条口径:
|
|
251
|
+
* - **再点已选中的那条 ⇒ 取消它**(而不是「凑不满两块就不动」)—— 用户要能改主意;
|
|
252
|
+
* - **超过两块 ⇒ 挤掉最早选的那条**(FIFO):不静默丢弃后来的选择;
|
|
253
|
+
* - **单选状态下重复点同一条** ⇒ 原样返回(幂等,不产生 `[a, a]`)。
|
|
254
|
+
*
|
|
255
|
+
* 为什么不设「未满两块时点了就退出对比」:那会让「我想退出对比」需要额外的按钮
|
|
256
|
+
* (面板已经有「打开」这个天然出口了 —— 普通单击就回到单run 视图)。
|
|
257
|
+
*/
|
|
258
|
+
export function toggleCompare(selected, id) {
|
|
259
|
+
if (selected.includes(id))
|
|
260
|
+
return selected.filter((x) => x !== id);
|
|
261
|
+
if (selected.length < COMPARE_MAX)
|
|
262
|
+
return [...selected, id];
|
|
263
|
+
return [selected[1], id];
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 「全量」的归一化键:空/null/undefined **都是**全量(与 `normalizeToolSources` 同口径)。
|
|
267
|
+
*
|
|
268
|
+
* ⚠️ **分隔符用 `'\\n'`,不是 `'\u0000'`** —— 别照抄 `dev-logic.sameToolSources` 的 NUL:
|
|
269
|
+
* 它比的是**拼接后的字符串**(NUL 不可能出现在能力名里,安全),而这里若把**字面 NUL
|
|
270
|
+
* 字符**打进 `.ts` 源文件,整个文件会被 `file`/grep 判成 **binary**(实测踩过:
|
|
271
|
+
* `grep` 报「Binary file matches」、`tsc` 照过,但源码审查工具与 diff 全废)。
|
|
272
|
+
* 能力名走 `^[A-Za-z0-9_-]{1,64}$`(框架装配期校验),换行同样不可能出现。
|
|
273
|
+
*/
|
|
274
|
+
function capsKey(toolSources) {
|
|
275
|
+
if (toolSources === null || toolSources === undefined || toolSources.length === 0)
|
|
276
|
+
return '*';
|
|
277
|
+
return [...toolSources].sort().join('\n');
|
|
278
|
+
}
|
|
279
|
+
export function compareView(compare) {
|
|
280
|
+
if (compare.length === 0)
|
|
281
|
+
return 'single';
|
|
282
|
+
return compare.length < COMPARE_MAX ? 'picking' : 'dual';
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 新 run 收尾后**自动**把它补进对比(把「改 prompt → 重跑 → 对照」这一跳补上)。
|
|
286
|
+
*
|
|
287
|
+
* 为什么要有:档 B 落地时那条承诺是「改一句 prompt 就能**立刻重跑对照**」,可配对要用户
|
|
288
|
+
* 手动 ⌘ 点两条 —— 三步操作不是「立刻」。这个判定把中间那一步吞掉。
|
|
289
|
+
*
|
|
290
|
+
* 为什么**只在 picking 态**(已选一条、还没选第二条)触发:
|
|
291
|
+
* - 空选择 ⇒ 用户还没表达对比意图,自动切双栏是**替用户决定**(会被读成面板在乱跳);
|
|
292
|
+
* - 已选两条 ⇒ 用户已经选好了,再塞一条等于**挤掉**他刚选的那条;
|
|
293
|
+
* - dev 环是**单飞**的 ⇒ picking 态下新收尾的那条必然是用户刚跑的那条,而他在 picking
|
|
294
|
+
* 态下的意图几乎必然是「拿它跟刚跑的这条比」⇒ 误伤概率极低。
|
|
295
|
+
*
|
|
296
|
+
* ⚠️ **不静默**:触发时面板播一条 notice(调用侧负责),所以「为什么屏幕变双栏了」用户
|
|
297
|
+
* 立刻知道;退出路径 = 普通单击任意一条(那条路径有单测,且本批刚补过「必须走
|
|
298
|
+
* `exitCompare()`」的守卫 W4)。
|
|
299
|
+
*
|
|
300
|
+
* ⚠️ **不动单飞**:这是「事后回看」的自动化,不是「同时跑」—— 真并发要改
|
|
301
|
+
* `shouldDeferRestart` 那条单源化判据(设计稿 §8 的「不做」)。
|
|
302
|
+
*
|
|
303
|
+
* @returns 新的选择数组;`null` = 不该自动配对(调用侧保持原样,别自己改成 `[]`)
|
|
304
|
+
*/
|
|
305
|
+
export function autoPairTarget(selected, incomingId) {
|
|
306
|
+
if (selected.length !== 1)
|
|
307
|
+
return null;
|
|
308
|
+
const base = selected[0];
|
|
309
|
+
if (base === incomingId)
|
|
310
|
+
return null;
|
|
311
|
+
return [base, incomingId];
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* 一条差异该怎么显示(`renderDiff` 里的分支,抽出来以便单测)。
|
|
315
|
+
*
|
|
316
|
+
* 为什么值得抽:`sp.missing` 那条判错的后果是**静默指错方向** —— 缺侧记录会显示成
|
|
317
|
+
* 「B侧少一个能力」,而真相是 A 侧独有。`diff-a` / `diff-b` 是**展示用**的标签
|
|
318
|
+
* (`diffTraces` 的 `missing` 真值是 `'a' | 'b'`,由 `packages/cli/src/diff.ts` 定)。
|
|
319
|
+
*/
|
|
320
|
+
export function diffSideLabel(missing) {
|
|
321
|
+
return missing === 'a' ? 'A' : 'B';
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 差异值的一行文本(`null` / `undefined` ⇒「(无)」)。
|
|
325
|
+
*
|
|
326
|
+
* ⚠️ **不能用 `??` 把缺省折成空串或 0**:那会让「字段不存在」显示成「值是 0/空」——
|
|
327
|
+
* 本仓反复踩的那类假读数(读数与「读不出」在业务上可分时,不许拿缺省值糊过去)。
|
|
328
|
+
*/
|
|
329
|
+
export function diffValueText(v) {
|
|
330
|
+
if (v === null || v === undefined)
|
|
331
|
+
return '(无)';
|
|
332
|
+
if (typeof v === 'string')
|
|
333
|
+
return v;
|
|
334
|
+
try {
|
|
335
|
+
return JSON.stringify(v) ?? String(v);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return String(v);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* compare 端点**失败**时的那句提示(档 B)。
|
|
343
|
+
*
|
|
344
|
+
* ⚠️ **不能用 `body.error || res.status`**:`||` 把「服务端返回了空串 / 没有 error 字段」
|
|
345
|
+
* 一律折成状态码 —— 那是本仓反复踩的「读不出伪装成读数」同族(`?? 0` 那条纪律的字符串版)。
|
|
346
|
+
* 口径:`error` 是**非空字符串**才用它,否则回落 HTTP 状态码(那个一定是真的)。
|
|
347
|
+
* ⚠️ 也不要把「读body 失败」折成空对象就完事 —— 那样显示的是「读差异失败:500」而真相是
|
|
348
|
+
* 「响应不是 JSON」(网关插了页 / token 过期返回 HTML)。
|
|
349
|
+
*/
|
|
350
|
+
export function diffFetchErrorText(status, body, parseFailed) {
|
|
351
|
+
if (parseFailed)
|
|
352
|
+
return `读差异失败:响应不是合法 JSON(HTTP ${status})`;
|
|
353
|
+
const e = body?.error;
|
|
354
|
+
return `读差异失败:${typeof e === 'string' && e.length > 0 ? e : `HTTP ${status}`}`;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 两条 run 的差异**能不能归因到「我改了什么」**。
|
|
358
|
+
*
|
|
359
|
+
* ⚠️ **这是本档最容易做错的地方**:`diffTraces` 比的是 span 路径与字段,它**不知道**
|
|
360
|
+
* 面板的四个输入里哪些变了。能力选择与工作目录是 **app 级**的(换它们要重启进程,
|
|
361
|
+
* `RunRequest` 的注释已写明)⇒ 两条跨了能力选择的 run,`runConfigSnapshot` 整个变了,
|
|
362
|
+
* diff 出来的差异**大部分与「我改的那句话」无关**。此时静默给一份 diff = 让用户
|
|
363
|
+
* 误读(本仓最忌讳的那类假绿)。所以口径是**显式分三档**:
|
|
364
|
+
*
|
|
365
|
+
* - `comparable` —— 同能力 + 同工作目录 ⇒ 差异可归因(**推荐路径**:只改 prompt / 多轮);
|
|
366
|
+
* - `workdir-differs` —— 能力同、目录不同 ⇒ 可比,但摘要必须显示目录不同,
|
|
367
|
+
* 否则「差在哪」会被读成「模型变了」;
|
|
368
|
+
* - `capabilities-differ` —— 能力选择不同(**跨了进程重启**)⇒ 提示比对意义有限。
|
|
369
|
+
*
|
|
370
|
+
* 判据用 `capsKey` 归一化:`null`(`RunNote` 的全量)、`undefined`(面板侧
|
|
371
|
+
* `normalizeToolSources` 的全量)、`[]`(被它归一化成全量的那个陷阱值)**三者同义**。
|
|
372
|
+
* 直接比数组会把「全量 vs 全量」判成不同 ⇒ 每次正常 A/B 都飘一条假警告。
|
|
373
|
+
*/
|
|
374
|
+
export function comparability(a, b) {
|
|
375
|
+
// note 缺失(trace 落盘后重启面板、或 note 先于 trace 被淘汰)⇒ **不猜**,按不可比处理
|
|
376
|
+
if (a === null || b === null) {
|
|
377
|
+
return {
|
|
378
|
+
level: 'capabilities-differ',
|
|
379
|
+
message: '这两条 run 里有一条缺少开发期元信息(能力选择 / 工作目录不可知),比对意义有限',
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
if (capsKey(a.toolSources) !== capsKey(b.toolSources)) {
|
|
383
|
+
return {
|
|
384
|
+
level: 'capabilities-differ',
|
|
385
|
+
message: '这两条 run 的能力选择不同(跨了进程重启),比对意义有限 —— 建议只改 prompt 后重跑',
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (a.workdir !== b.workdir) {
|
|
389
|
+
return {
|
|
390
|
+
level: 'workdir-differs',
|
|
391
|
+
message: `注意:这两条 run 的工作目录不同(${a.workdir} vs ${b.workdir})`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return { level: 'comparable', message: '' };
|
|
395
|
+
}
|
|
244
396
|
// ---------- run 收尾的反馈语 ----------
|
|
245
397
|
/**
|
|
246
398
|
* `run-done` → 面板底部那句操作反馈。
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@migor/agentia": "^0.10.
|
|
12
|
+
"@migor/agentia": "^0.10.1"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@migor/cli": "^0.10.
|
|
15
|
+
"@migor/cli": "^0.10.1",
|
|
16
16
|
"tsx": "^4.19.0",
|
|
17
17
|
"typescript": "^7.0.2",
|
|
18
18
|
"@types/node": "^22.0.0"
|