@migor/cli 0.10.9 → 0.10.10
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 +62 -1
- package/dist/dev-machine.js +8 -1
- package/dist/inspector/fromTrace.js +64 -37
- package/dist/inspector/trace-view.bundle.js +283 -40
- package/dist/inspector/trace-view.css +55 -0
- package/dist/inspector/view.js +219 -3
- package/dist/inspector-page.html +555 -45
- package/dist/inspector-routes.js +4 -1
- package/dist/panel-logic.js +275 -8
- package/dist/templates/package.json +2 -2
- package/package.json +1 -1
package/dist/inspector-routes.js
CHANGED
|
@@ -349,7 +349,10 @@ function handleRunDetail(ctx) {
|
|
|
349
349
|
ctx.json(ctx.res, 404, { error: 'run 不存在(可能已被淘汰)' });
|
|
350
350
|
return;
|
|
351
351
|
}
|
|
352
|
-
|
|
352
|
+
// 明细**带上 CLI 侧记账**(顶层 `note`):`finalText` 只活在那里 —— trace 上没有它。
|
|
353
|
+
// 不带的话,面板一离开这一轮就再也拿不回回复(见 `RunNote.finalText`)。
|
|
354
|
+
// 形状是 `Trace + { note }`,`playTrace` 只读 `spans` / `traceId`,多一个键无影响。
|
|
355
|
+
ctx.json(ctx.res, 200, { ...t, note: ctx.state.notes.get(id) ?? null });
|
|
353
356
|
}
|
|
354
357
|
/**
|
|
355
358
|
* 并排 A/B:`GET /api/runs/compare?a=<id>&b=<id>` → `{ diff, comparability }`。
|
package/dist/panel-logic.js
CHANGED
|
@@ -119,6 +119,45 @@ export function historyText(history, cursor, draft) {
|
|
|
119
119
|
return draft;
|
|
120
120
|
return history[history.length - 1 - cursor] ?? draft;
|
|
121
121
|
}
|
|
122
|
+
export function stopReasonClass(r) {
|
|
123
|
+
const sr = r.stopReason ?? null;
|
|
124
|
+
if (sr === 'aborted')
|
|
125
|
+
return 'aborted';
|
|
126
|
+
// suspended 先于 ok 判:引擎的 `isSuccessStopReason` 不把它算成功,而 trace 状态记 ok
|
|
127
|
+
// (挂起段本身执行无误)⇒ 只看 ok 就漏了「这一轮其实还等着人」。
|
|
128
|
+
if (sr === 'suspended')
|
|
129
|
+
return 'suspended';
|
|
130
|
+
return r.ok ? 'ok' : 'failed';
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 收尾原因的**中文短语**(面板显示用)。
|
|
134
|
+
*
|
|
135
|
+
* 未知串**原样返回**(不回落成「完成」、也不回落成空串):面板不认识的
|
|
136
|
+
* `stop_reason` 只能说明面板落后于框架,把原文亮出来才能一眼看出是哪个新原因。
|
|
137
|
+
* 要覆盖的成员由 `test/stop-reason.test.mjs` 对着 `src/engine/types.ts` 的
|
|
138
|
+
* `AgentStopReason` 逐个数 —— 框架加了成员而这里没表态,那条守卫就红。
|
|
139
|
+
*/
|
|
140
|
+
const STOP_REASON_TEXT = {
|
|
141
|
+
end_turn: '模型自然收尾',
|
|
142
|
+
stop_sequence: '命中停止序列',
|
|
143
|
+
max_tokens: '撞到输出上限',
|
|
144
|
+
model_context_window_exceeded: '撞到上下文窗口',
|
|
145
|
+
refusal: '模型拒绝回答',
|
|
146
|
+
pause_turn: '模型请求暂停',
|
|
147
|
+
max_iterations: '撞到迭代上限',
|
|
148
|
+
result_missing: '没提交结构化结果',
|
|
149
|
+
budget_exceeded: '撞到预算护栏',
|
|
150
|
+
aborted: '已中止',
|
|
151
|
+
tool_use_no_blocks: '回合里没有可执行块',
|
|
152
|
+
suspended: '等人审批',
|
|
153
|
+
unknown_stop_reason: '未识别的收尾原因',
|
|
154
|
+
error: '出错',
|
|
155
|
+
};
|
|
156
|
+
export function stopReasonText(sr) {
|
|
157
|
+
if (!sr)
|
|
158
|
+
return '';
|
|
159
|
+
return STOP_REASON_TEXT[sr] ?? sr;
|
|
160
|
+
}
|
|
122
161
|
/**
|
|
123
162
|
* 这个 run 算不算**失败**?
|
|
124
163
|
*
|
|
@@ -128,12 +167,12 @@ export function historyText(history, cursor, draft) {
|
|
|
128
167
|
* - run 列表把它标成红点(用户会以为是自己把工程改坏了);
|
|
129
168
|
* - 对话视图给这一轮打红边「(这一轮失败了…)」,而同一个 run 的通知条写着「已中止」。
|
|
130
169
|
*
|
|
131
|
-
*
|
|
170
|
+
* 判别顺序归 `stopReasonClass`(**先 stopReason 后 ok**),与 `runDoneNotice` 同一条语义。
|
|
171
|
+
* 这里只是它的一元投影 —— 别在两处各写一遍:
|
|
172
|
+
* `suspended` 也不是失败(挂着等人,`ok` 同样可能是 false),旧实现漏掉它。
|
|
132
173
|
*/
|
|
133
174
|
export function runIsFailure(r) {
|
|
134
|
-
|
|
135
|
-
return false;
|
|
136
|
-
return !r.ok;
|
|
175
|
+
return stopReasonClass(r) === 'failed';
|
|
137
176
|
}
|
|
138
177
|
/** `MessageParam.content` 可能是 string,也可能是 content block 数组 —— 只取文本块 */
|
|
139
178
|
export function textOfContent(content) {
|
|
@@ -194,7 +233,7 @@ export function mergeConversation(messages, runs, sessionId) {
|
|
|
194
233
|
continue;
|
|
195
234
|
if ((r.sessionId ?? null) !== sessionId)
|
|
196
235
|
continue;
|
|
197
|
-
const prompt = (r.prompt ?? '').trim();
|
|
236
|
+
const prompt = (r.note?.prompt ?? '').trim();
|
|
198
237
|
if (prompt.length === 0 || seen.has(prompt))
|
|
199
238
|
continue;
|
|
200
239
|
seen.add(prompt);
|
|
@@ -402,16 +441,192 @@ export function comparability(a, b) {
|
|
|
402
441
|
* 但原因要可查)⇒ 中止时 `ok` 也是 **false**。所以必须**先**认 `stopReason`:
|
|
403
442
|
* 反过来先看 `ok`,用户按的「中止」就会被显示成一句吓人的「run 失败」。
|
|
404
443
|
* 这个顺序错一次很难在 code review 里看出来,所以钉在单测里。
|
|
444
|
+
*
|
|
445
|
+
* 四类模板(`stopReasonClass`):原文一起给 —— 「撞到预算护栏」是给人读的,
|
|
446
|
+
* `budget_exceeded` 是给人 grep 的,两者都要。
|
|
405
447
|
*/
|
|
406
448
|
export function runDoneNotice(ev) {
|
|
407
|
-
|
|
449
|
+
const cls = stopReasonClass(ev);
|
|
450
|
+
const text = stopReasonText(ev.stopReason);
|
|
451
|
+
// 中文短语与标识符不同才拼(未识别的串 `text === sr`,只显示一次)
|
|
452
|
+
const why = text && text !== ev.stopReason ? `${text} · ${ev.stopReason}` : ev.stopReason;
|
|
453
|
+
if (cls === 'aborted') {
|
|
408
454
|
return { text: 'run 已中止(trace 保住了)', isError: false };
|
|
409
455
|
}
|
|
410
|
-
if (
|
|
411
|
-
|
|
456
|
+
if (cls === 'suspended') {
|
|
457
|
+
// 挂起不是结束:run 带着完整消息历史等人裁决,之后从原处继续。
|
|
458
|
+
// 旧文案把它写成「run 完成(suspended)」—— 那是在说一件没发生的事。
|
|
459
|
+
return { text: `run 停在审批(${why}):裁决之后从挂起处继续`, isError: false };
|
|
460
|
+
}
|
|
461
|
+
if (cls === 'failed') {
|
|
462
|
+
return { text: `run 失败(${why}):${ev.error || '未知原因'}`, isError: true };
|
|
412
463
|
}
|
|
413
464
|
return { text: `run 完成(${ev.stopReason})`, isError: false };
|
|
414
465
|
}
|
|
466
|
+
function ledNum(n) {
|
|
467
|
+
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* 一条 run 的账:`3 轮 · 7 次工具 · 2 失败 · in 8.1k / out 1.5k · cache 6.2k · 77% · $0.014`。
|
|
471
|
+
*
|
|
472
|
+
* 为什么值得单独一条纯函数:这五个数此前**一个都没有**(页面上只有 footer 的 in / out),
|
|
473
|
+
* 而它们全都在 `Trace.totalUsage` 与 span 上。其中**cache 命中率**是这类面板最该给、又最常
|
|
474
|
+
* 缺的一个数 —— 它直接对应账单(缓存读的单价通常只有未命中的 1/10)。
|
|
475
|
+
*
|
|
476
|
+
* 三条口径(都是「不给假读数」):
|
|
477
|
+
* - 分母为 0 / 字段缺失的那一项**整项不出现**,不显示 `0%` / `$0.0000`。
|
|
478
|
+
* 「成本是 0」与「没有单价表」是两件事,折成同一个 `$0` 就是在说谎。
|
|
479
|
+
* - cache 命中率 = 读 /(读 + 写 + 未命中输入)。**分母含缓存写入**:只算读除以输入会让
|
|
480
|
+
* 「刚写了一大块缓存」的那次 run 显示成 100% 命中 —— 那正是最容易误读的时刻。
|
|
481
|
+
* - 轮次/工具数**从 span 与事件里数**,不读 `AgentRunResult.iterations`:后者不在 trace 上
|
|
482
|
+
* (`engine/loop.ts` 只把它写进返回值),而面板手上只有 trace。数出来的口径写在 title 里。
|
|
483
|
+
*/
|
|
484
|
+
export function runLedger(trace, budget) {
|
|
485
|
+
const items = [];
|
|
486
|
+
if (!trace)
|
|
487
|
+
return items;
|
|
488
|
+
const spans = Array.isArray(trace.spans) ? trace.spans : [];
|
|
489
|
+
const turns = spans.filter((s) => s && s.kind === 'llm.turn').length;
|
|
490
|
+
if (turns > 0)
|
|
491
|
+
items.push({
|
|
492
|
+
key: '轮次',
|
|
493
|
+
value: `${turns} 轮`,
|
|
494
|
+
title: '按 llm.turn span 数(一次模型往返 = 一轮)',
|
|
495
|
+
});
|
|
496
|
+
let toolCalls = 0;
|
|
497
|
+
for (const s of spans) {
|
|
498
|
+
for (const ev of s?.events ?? []) {
|
|
499
|
+
if (ev && ev.name === 'tool.input')
|
|
500
|
+
toolCalls += 1;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (toolCalls > 0) {
|
|
504
|
+
items.push({
|
|
505
|
+
key: '工具',
|
|
506
|
+
value: `${fmtCount(toolCalls)} 次`,
|
|
507
|
+
title: '按 tool.input 事件数(普通工具不建 span)',
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
const errors = spans.filter((s) => s && s.status === 'error').length;
|
|
511
|
+
if (errors > 0) {
|
|
512
|
+
items.push({ key: '失败', value: `${fmtCount(errors)} 个 span`, tone: 'err' });
|
|
513
|
+
}
|
|
514
|
+
const u = trace.totalUsage ?? null;
|
|
515
|
+
if (u) {
|
|
516
|
+
const inTok = ledNum(u.inputTokens);
|
|
517
|
+
const outTok = ledNum(u.outputTokens);
|
|
518
|
+
if (inTok + outTok > 0) {
|
|
519
|
+
items.push({ key: 'in / out', value: `${fmtCount(inTok)} / ${fmtCount(outTok)}` });
|
|
520
|
+
}
|
|
521
|
+
const cr = ledNum(u.cacheReadTokens);
|
|
522
|
+
const cc = ledNum(u.cacheCreationTokens);
|
|
523
|
+
const miss = inTok;
|
|
524
|
+
const base = cr + cc + miss;
|
|
525
|
+
if (cr + cc > 0 && base > 0) {
|
|
526
|
+
const rate = Math.round((cr / base) * 100);
|
|
527
|
+
items.push({
|
|
528
|
+
key: 'cache',
|
|
529
|
+
value: `${fmtCount(cr)} 读 · ${rate}% 命中`,
|
|
530
|
+
title: `缓存读 ${cr} / 缓存写 ${cc} / 未命中输入 ${miss};命中率 = 读 ÷(读 + 写 + 未命中)`,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
// 成本:只有框架给得出估算时才显示(无公开单价的服务商给不出 —— 那是「不知道」,
|
|
534
|
+
// 不是「花了 0 元」)
|
|
535
|
+
if (typeof u.costEstimate === 'number' && Number.isFinite(u.costEstimate)) {
|
|
536
|
+
const cap = ledNum(budget?.maxCostUsd);
|
|
537
|
+
items.push({
|
|
538
|
+
key: '成本',
|
|
539
|
+
value: cap > 0 ? `$${u.costEstimate.toFixed(4)} / $${cap}` : `$${u.costEstimate.toFixed(4)}`,
|
|
540
|
+
title: '由 token × 单价估算(框架的总账,不是账单)',
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const root = spans.find((s) => s && s.kind === 'run') ?? spans[0];
|
|
545
|
+
const model = root?.attributes?.model;
|
|
546
|
+
if (typeof model === 'string' && model.length > 0)
|
|
547
|
+
items.push({ key: '模型', value: model });
|
|
548
|
+
return items;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* 一条 run 是否命中关键词。大小写不敏感、纯子串(不做正则:`(` 之类会让输入直接炸)。
|
|
552
|
+
*
|
|
553
|
+
* 搜的字段包含 `stopReasonText`(中文短语)—— 用户想找「撞预算那条」时会打「预算」,
|
|
554
|
+
* 而数据里存的是 `budget_exceeded`。只搜原文等于逼人记住标识符。
|
|
555
|
+
*/
|
|
556
|
+
export function matchRunQuery(r, q) {
|
|
557
|
+
const needle = q.trim().toLowerCase();
|
|
558
|
+
if (needle.length === 0)
|
|
559
|
+
return true;
|
|
560
|
+
const fields = [
|
|
561
|
+
r.note?.prompt ?? '',
|
|
562
|
+
r.name ?? '',
|
|
563
|
+
r.note?.workdir ?? '',
|
|
564
|
+
r.stopReason ?? '',
|
|
565
|
+
stopReasonText(r.stopReason ?? null),
|
|
566
|
+
r.traceId,
|
|
567
|
+
];
|
|
568
|
+
return fields.some((f) => String(f).toLowerCase().includes(needle));
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* 列表过滤:关键词 **且**(可选)「只看异常」。
|
|
572
|
+
*
|
|
573
|
+
* 「异常」= `stopReasonClass !== 'ok'`(失败 / 中止 / 等审批三种都算)。为什么这三种都算:
|
|
574
|
+
* 用户点「只看异常」时想找的是「没正常跑完的那些」,而中止与挂起恰恰是最需要回看的两类 ——
|
|
575
|
+
* 只过滤 `ok === false` 会把挂起的 run(trace 状态是 ok)漏掉。
|
|
576
|
+
*/
|
|
577
|
+
export function filterRuns(runs, q, opts = {}) {
|
|
578
|
+
const onlyAbnormal = opts.onlyAbnormal === true;
|
|
579
|
+
return runs.filter((r) => {
|
|
580
|
+
if (onlyAbnormal && stopReasonClass({ ok: r.ok, stopReason: r.stopReason ?? null }) === 'ok') {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
return matchRunQuery(r, q);
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
// ---------- ⑦ 深链 / 重跑 ----------
|
|
587
|
+
/**
|
|
588
|
+
* `location.search` → 要打开的 run id(`?run=<traceId>`)。
|
|
589
|
+
*
|
|
590
|
+
* 解析失败 / 没有这个参数 / 空串一律 `null`(= 照旧打开最新那条)。不抛:一个手改坏的
|
|
591
|
+
* URL 不该让面板白屏。
|
|
592
|
+
*/
|
|
593
|
+
export function runIdFromSearch(search) {
|
|
594
|
+
const s = String(search ?? '').trim();
|
|
595
|
+
if (s.length === 0)
|
|
596
|
+
return null;
|
|
597
|
+
let params;
|
|
598
|
+
try {
|
|
599
|
+
params = new URLSearchParams(s.startsWith('?') ? s.slice(1) : s);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
const id = (params.get('run') ?? '').trim();
|
|
605
|
+
return id.length > 0 ? id : null;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* 「重跑」要回填的那四个输入(`RunNote` 里全都有 —— 这正是它值得存的原因)。
|
|
609
|
+
*
|
|
610
|
+
* 三条口径:
|
|
611
|
+
* - **不自动提交**:回填之后由用户按运行。一个自己跑起来的按钮会在误触时花钱。
|
|
612
|
+
* - 能力选择**过滤掉已失效的 token**(能力被删过),过滤后为空则回落全量 ——
|
|
613
|
+
* `toolSources: []` 在框架侧是「每个 provider 都成了孤儿」的纯陷阱值。
|
|
614
|
+
* - `note` 缺失(trace 不是 dev 环起的 / 已被淘汰)⇒ `null`,调用方负责说「这条 run 没有
|
|
615
|
+
* 开发期元信息,重跑不了」。不猜一个空的 prompt 出来。
|
|
616
|
+
*/
|
|
617
|
+
export function rerunFromNote(note, all) {
|
|
618
|
+
if (!note)
|
|
619
|
+
return null;
|
|
620
|
+
const picked = note.toolSources === null || note.toolSources === undefined
|
|
621
|
+
? [...all]
|
|
622
|
+
: filterSelected(note.toolSources, all);
|
|
623
|
+
return {
|
|
624
|
+
prompt: typeof note.prompt === 'string' ? note.prompt : '',
|
|
625
|
+
workdir: typeof note.workdir === 'string' ? note.workdir : '',
|
|
626
|
+
toolSources: picked.length > 0 ? picked : [...all],
|
|
627
|
+
multiTurn: note.multiTurn === true,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
415
630
|
// ---------- 路径显示 ----------
|
|
416
631
|
/**
|
|
417
632
|
* 把绝对路径压短给面板看(`~/proj/a`)。
|
|
@@ -597,3 +812,55 @@ export function collapseDecision(text, opts = {}) {
|
|
|
597
812
|
: '';
|
|
598
813
|
return { collapsed, lines, chars, hint };
|
|
599
814
|
}
|
|
815
|
+
// ---------- ④ 右栏三块的标题栏读数:**收起不等于失明** ----------
|
|
816
|
+
/**
|
|
817
|
+
* 右栏三块(调用树 / 能力排行 / 回复)都能折叠(2026-11-12 ref-panel-panes)。
|
|
818
|
+
*
|
|
819
|
+
* 折叠后标题栏是这一块**唯一**还看得见的东西 ⇒ 它必须自己回答「值不值得展开」。
|
|
820
|
+
* 这不是装饰:把整块收起来却一个数都不留,用户就只能在「占屏」与「失明」之间二选一 ——
|
|
821
|
+
* 那恰好是这次改动之前的状态(三块挤在一列,谁都不够高)。
|
|
822
|
+
*
|
|
823
|
+
* 三条都是**纯格式化**(不量 DOM、不读时钟、不看窗口),所以它们进得了单测;
|
|
824
|
+
* 面板那边只负责把返回值写进 `textContent`。
|
|
825
|
+
*/
|
|
826
|
+
function fmtCount(n) {
|
|
827
|
+
const v = Number.isFinite(n) && n > 0 ? n : 0;
|
|
828
|
+
if (v < 1000)
|
|
829
|
+
return String(v);
|
|
830
|
+
const k = v / 1000;
|
|
831
|
+
// 一万以上不保留小数:标题栏只有一行,`12.3k` 比 `12k` 多不出信息
|
|
832
|
+
return `${k < 10 ? k.toFixed(1) : String(Math.round(k))}`.replace(/\.0$/, '') + 'k';
|
|
833
|
+
}
|
|
834
|
+
/** 调用树:`58 span`。span 数是这一块「有没有东西看」的最小信号,也随在飞帧增长 */
|
|
835
|
+
export function traceMeta(spanCount) {
|
|
836
|
+
return `${fmtCount(spanCount)} span`;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* 能力排行:`7 项 · 24 次 · 2 失败`。
|
|
840
|
+
*
|
|
841
|
+
* 失败数**只在真的有人失败时**出现 —— 恒挂一个「0 失败」等于给每一行加噪声,
|
|
842
|
+
* 与 `collapseDecision` 的 `hint` 同一条纪律(空的话就别占位置)。
|
|
843
|
+
* `errors` 的语义是**工具/护栏报错次数**(`summarizeTrace` 的账),不是「有几个能力出错」。
|
|
844
|
+
*/
|
|
845
|
+
export function summaryMeta(rows) {
|
|
846
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
847
|
+
return '0 项';
|
|
848
|
+
let calls = 0;
|
|
849
|
+
let errors = 0;
|
|
850
|
+
for (const r of rows) {
|
|
851
|
+
calls += Number.isFinite(r?.calls) ? r.calls : 0;
|
|
852
|
+
errors += Number.isFinite(r?.errors) ? r.errors : 0;
|
|
853
|
+
}
|
|
854
|
+
const head = `${rows.length} 项 · ${fmtCount(calls)} 次`;
|
|
855
|
+
return errors > 0 ? `${head} · ${fmtCount(errors)} 失败` : head;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* 回复:`32 行 · 1.4k 字符`。
|
|
859
|
+
*
|
|
860
|
+
* 行/字符数的算法**复用 `collapseDecision`**(同一个 `split('\n')` 口径)——
|
|
861
|
+
* 标题栏说「32 行」而正文折叠控件说「共 31 行」是最难查的一类不一致。
|
|
862
|
+
*/
|
|
863
|
+
export function replyMeta(text) {
|
|
864
|
+
const d = collapseDecision(text);
|
|
865
|
+
return d.lines > 1 ? `${d.lines} 行 · ${fmtCount(d.chars)} 字符` : `${fmtCount(d.chars)} 字符`;
|
|
866
|
+
}
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@migor/agentia": "^0.10.
|
|
14
|
+
"@migor/agentia": "^0.10.10"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@migor/cli": "^0.10.
|
|
17
|
+
"@migor/cli": "^0.10.10",
|
|
18
18
|
"tsx": "^4.19.0",
|
|
19
19
|
"typescript": "^7.0.2",
|
|
20
20
|
"@types/node": "^22.0.0"
|
package/package.json
CHANGED