mingdao-harness 0.6.34 → 0.6.35
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/docs/ARCHITECTURE.md +5 -4
- package/docs/CONFIG.md +22 -1
- package/docs/DEVELOPER.md +15 -0
- package/package.json +1 -1
- package/src/agent.js +110 -1
- package/src/config.js +18 -0
- package/src/session-events.js +533 -0
- package/src/session.js +38 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# MingDao-Harness 架构设计
|
|
2
2
|
|
|
3
|
-
> 版本口径:v0.6.30
|
|
3
|
+
> 版本口径:v0.6.30(v0.7.0 §1.1 增量见下)。本文档覆盖 `src/` 全部 **94** 个模块(`find src -name '*.js' | wc -l` = 94),
|
|
4
4
|
> 按层给出每个模块的职责与它守住的那条不变量;判定以文件头注释与实际实现为准,不做推断性描述。
|
|
5
5
|
>
|
|
6
6
|
> 阅读顺序建议:§2 模块索引(查「某件事归谁管」)→ §3 数据流(看一次回合怎么走)→ §4 已知边界(看它**保证不了**什么)。
|
|
@@ -93,9 +93,10 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
93
93
|
- `net-policy.js` —— 出网白名单策略:纯函数,不碰 IO、不改全局状态,便于单测与文档化 —— 命中哪条规则、拦还是放,判定只此一份。
|
|
94
94
|
- `net-guard.js` —— 出网闸门:在 `globalThis.fetch` 收口做出口判定 + 记账,`guardEgress()` 供 `node:http(s)` 路径(safe-fetch、sync 的 rawRequest)复用 —— **默认不安装**(未配置 `config.net` 时完全不介入);只覆盖内核自己发起的请求,不代表整机不出网。
|
|
95
95
|
|
|
96
|
-
### 2.4 会话 / 记忆 / 任务(
|
|
96
|
+
### 2.4 会话 / 记忆 / 任务(11)
|
|
97
97
|
|
|
98
98
|
- `session.js` —— 会话持久化:`<home>/sessions/<时间戳>-<随机>.jsonl` 每轮追加,`--continue` 载入最近一次,`--resume` 打开选择器 —— 会话文件是消息结构的唯一权威,检查点等旁挂状态另存侧车文件。
|
|
99
|
+
- `session-events.js` —— 会话事件日志(v0.7.0 §1.1 切片 A):`<session>.events.ndjson` 追加 12 类事件 `{seq,time,type,data}`(session/turn/step/tool/message/usage/compact/interrupt/checkpoint)—— **seq 是唯一关联键**:跨进程短临界区锁内「读尾行 last+1」、单次 append 后回读校验;读侧跳过 torn 行/未知 type 并返回 `skipped`;`replayToMessages()` 是纯函数(`message.append` 提供消息本体、`compact` 重置基线、`interrupt` 延迟补 tool 占位),由事件流重放的消息数组必须与原消息数组逐字节等价;事件文件是**旁路**,`loadSession`/`rewriteSession`/SSE/UI 都不读它,`cfg.eventLog=false` 或 `MINGDAO_EVENT_LOG=0` 可整体回滚。
|
|
99
100
|
- `session-index.js` —— 会话检索索引:词表倒排 + 按 mtime/size 的增量同步,分片存 `<home>/sessions-index/<2位sha1前缀>.json`(256 片)—— 未变化直接用缓存词表,>8MB 文件跳过并移除条目;中文按 bigram + 单字、查询串同口径分词后 AND 匹配。
|
|
100
101
|
- `memory.js` —— 长记忆与自主进化:用户记忆 `~/.mingdao/AGENTS.md`(手动 + 会话结束自动提取用户偏好并去重追加)、会话日志 `journal.jsonl`(默认不注入系统提示,`--journal` / WebUI 勾选才取最近 3 条)—— 自动提取用 executor 模型(约几十 token),`autoMemory: false` 可关。
|
|
101
102
|
- `tasks.js` —— 多会话后台任务面板:`mingdao run` 拉起独立 worker 子进程,状态落 `<home>/tasks/<id>.json`,命令族 `run` / `tasks` / `tasks watch` / `tasks kill` —— taskId 用不可枚举随机值;kill 前必须过 PID 归属校验(见 `proc.js`)。
|
|
@@ -176,8 +177,8 @@ MingDao 是一个「模型循环 + 工具 + 权限」内核,纯 Node.js ≥ 18
|
|
|
176
177
|
- `help.js` —— 帮助文本的唯一来源:CLI 与 REPL 各用 `variant` 声明自己多出来的那几行 —— 两个入口的差异必须是显式的,不允许各存一份正文。
|
|
177
178
|
- `index.js` —— 库公共 API:导出 `@stable`(minor 内向后兼容)与 `@experimental` 两组能力,契约与示例见 DEVELOPER.md。
|
|
178
179
|
|
|
179
|
-
模块计数核对:7 + 8 +
|
|
180
|
-
`web/util.js` / `web/app.js` 一条覆盖 2 个文件、`web/viewmodel.js` 单列,故条目覆盖 **
|
|
180
|
+
模块计数核对:7 + 8 + 13 + 11 + 4 + 2 + 16 + 13 + 19 = **93** 个条目(含合并条目;`find src -name '*.js' | wc -l` = **94**),其中 §2.7 的
|
|
181
|
+
`web/util.js` / `web/app.js` 一条覆盖 2 个文件、`web/viewmodel.js` 单列,故条目覆盖 **94** 个 `src/**/*.js`(每版本以 `find` 实测为准),
|
|
181
182
|
与 `find src -name '*.js' | wc -l` 的结果一致。
|
|
182
183
|
|
|
183
184
|
## 3. 数据流:一次 `runTurn` 的关键路径
|
package/docs/CONFIG.md
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
可选字段:`temperature`、`maxOutputTokens`、`includeUsage`(流式请求 usage 统计,个别网关不支持
|
|
29
29
|
`stream_options` 时设 `false`)、`autoTitle`(自动生成会话标题,默认开)、`notify`(任务桌面通知,默认开)、
|
|
30
|
-
`autoCompact`(上下文自动压缩,默认开,见下)、`audit
|
|
30
|
+
`autoCompact`(上下文自动压缩,默认开,见下)、`audit`(工具调用审计,默认开,见下)、
|
|
31
|
+
`eventLog`(会话事件日志双写,默认开,见下)。
|
|
31
32
|
|
|
32
33
|
### WebUI 设置项的「默认值」列(只读,不落盘)
|
|
33
34
|
|
|
@@ -85,6 +86,26 @@ MingDao 先用 executor 模型(路由关闭时为当前模型)把被裁段
|
|
|
85
86
|
中文按 bigram+单字、英文按词,多词 AND 匹配;只有内容变化(mtime/size)的会话才重新
|
|
86
87
|
分词,删除的会话自动清出索引。索引目录可随时删除(下次搜索自动重建)。
|
|
87
88
|
|
|
89
|
+
## 会话事件日志(v0.7.0 §1.1)
|
|
90
|
+
|
|
91
|
+
每个会话在消息文件旁多一条 append-only 的事件流 `<session>.events.ndjson`(0600,与
|
|
92
|
+
`<session>.jsonl` 同级):每条 `{seq,time,type,data}`,seq 单调、是 turn/step/tool/message
|
|
93
|
+
之间的唯一关联键。当前 12 类事件:`session.start` / `turn.start` / `turn.end` / `step.start` /
|
|
94
|
+
`step.end` / `tool.start` / `tool.end` / `message.append` / `usage` / `compact` / `interrupt` /
|
|
95
|
+
`checkpoint`。
|
|
96
|
+
|
|
97
|
+
- **双写、旁路**:`appendMessages()` 在会话消息落盘**之后**追加 `message.append`;agent 在
|
|
98
|
+
turn/step/工具/用量/压缩/中断边界追加结构事件。事件日志写失败只 warn,**不影响会话写入**,
|
|
99
|
+
也不改变 `loadSession()` / `rewriteSession()` 的返回值。
|
|
100
|
+
- **确定性重放**:`replayToMessages(events)` 由事件流派生消息数组(`compact` 重置基线、
|
|
101
|
+
`interrupt` 补 tool 占位),已对拍单轮 / 工具调用 / 压缩 / 中断四个场景与消息数组 deepEqual。
|
|
102
|
+
- **回滚开关**:`"eventLog": false`(默认开)或环境变量 `MINGDAO_EVENT_LOG=0`。关闭后**完全**
|
|
103
|
+
停止事件写入(含 `message.append` 双写),会话文件照常写入。
|
|
104
|
+
- **兼容**:旧会话没有 events 文件时读取返回空流;事件侧车不会出现在会话列表/最近会话中;
|
|
105
|
+
事件文件不参与云同步。SSE 帧与 UI 在本切片**未改**。
|
|
106
|
+
- **已知边界**:手动 `/compact` 走 `rewriteSession` 时没有对应 `compact` 事件;自动标题重命名
|
|
107
|
+
会话文件不会搬迁事件侧车;这两类留待后续切片(事件日志目前是旁路,不参与既有读取)。
|
|
108
|
+
|
|
88
109
|
## 会话级工作空间(P3-4)
|
|
89
110
|
|
|
90
111
|
WebUI 中每个会话记住自己的工作目录:新会话记录创建时的全局工作空间;继续该会话时任务
|
package/docs/DEVELOPER.md
CHANGED
|
@@ -411,3 +411,18 @@ CI 想"只卡新增盲区"就用当前存量数当基线(例如 `--fail-over 1
|
|
|
411
411
|
三个套件都是零外部依赖、只用本机临时目录。
|
|
412
412
|
- 盲区检查只回答"有没有变异",不回答"变异质量"——`--fail-over` 只能挡住新增盲区,
|
|
413
413
|
挡不住"给一个 600 行模块写 1 条无关变异"这种凑数。质量仍靠 review 与 `expect` 关键词约束。
|
|
414
|
+
|
|
415
|
+
### 7.4 覆盖率当前**只报告不判失败**(v0.6.35 起)
|
|
416
|
+
|
|
417
|
+
实测:同一棵树、同一套 34 个套件、连续三次跑 `npm run coverage`,code-lines 得到
|
|
418
|
+
**73.7% / 72.5% / 69.7%**(覆盖行数差 760 行)。原因不是代码变了,而是套件会 spawn 大量子进程,
|
|
419
|
+
**子进程的 V8 覆盖数据是否被聚合不稳定**。
|
|
420
|
+
|
|
421
|
+
因此 `npm run coverage` 现在只输出趋势与"棘轮回落"提示,**不返回非零**。要恢复闸门:
|
|
422
|
+
|
|
423
|
+
1. 让收集确定:固定套件顺序、等待所有子进程退出、校验每个套件都产出了覆盖目录;
|
|
424
|
+
2. 连续 3 次同树测量差 ≤0.2pp(可作为一条套件断言);
|
|
425
|
+
3. 设 `COVERAGE_GATE=1` 即可恢复"低于基线即失败"(代码分支仍保留)。
|
|
426
|
+
|
|
427
|
+
> 为什么宁可"只报告"也不留一个随机红的闸门:**被随机关掉的闸门等于没有闸门**,
|
|
428
|
+
> 而且会训练大家忽略红灯。宁可先诚实地承认"现在量不准"。
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// v0.7.0 §1.5:**文案 / 预测层已迁至 ./messages.js**(逐字搬运、逻辑零变化)——
|
|
6
6
|
// 本文件只保留循环与编排;导出面由下方 `export { … } from './messages.js'` 逐字保留。
|
|
7
7
|
|
|
8
|
+
import path from 'node:path';
|
|
8
9
|
import { trimMessages, messageTokens, approxTokens } from './context.js';
|
|
9
10
|
import { compactConversation } from './compact.js';
|
|
10
11
|
// 审计 M-1 §2.1 第 4 条(Pack 反向提权):`makeToolCtx` 把内核 ctx 剪成"工具面向的 ctx"并冻结。
|
|
@@ -38,6 +39,8 @@ import { compileConstraints, checkPreTool, checkPostTool, checkOutput, blockedOu
|
|
|
38
39
|
import { createLedger, newRunId } from './ledger.js';
|
|
39
40
|
import { registerEgressSink } from './net-guard.js';
|
|
40
41
|
import { getActivePackContext } from './packs.js';
|
|
42
|
+
import { mingdaoHome } from './config.js';
|
|
43
|
+
import { appendEvent, eventFileFor, eventLogEnabled, missingToolCallIds, setEventLogEnabled } from './session-events.js';
|
|
41
44
|
// v0.6.11(审计 P1-1 拆分第三刀):工具编排两端的判据单源在 tools-flow.js——
|
|
42
45
|
// ① 本轮可见哪些工具(只读档 × 预设白名单 × 已用工具快照 × MCP 只读标注);
|
|
43
46
|
// ② 工具结果怎么回填(序列化 / 复用前缀 / 约束拒绝文案 / 按窗口截断);
|
|
@@ -117,6 +120,24 @@ function resolveEndpointBaseUrl(/** @type {any} */ cfg, /** @type {any} */ model
|
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
/**
|
|
124
|
+
* v0.7.0 §1.1:解析本会话的事件侧车。优先级:`cfg.eventFile`(测试/嵌入方显式指定)>
|
|
125
|
+
* `sessionRef.file` > `<MINGDAO_HOME>/sessions/<sessionRef.name>`。
|
|
126
|
+
* 任何异常都返回 null——事件日志是旁路,路径解析失败不得影响回合。
|
|
127
|
+
* @param {any} sessionRef
|
|
128
|
+
* @param {any} cfg
|
|
129
|
+
* @returns {string|null}
|
|
130
|
+
*/
|
|
131
|
+
function resolveAgentEventFile(sessionRef, cfg) {
|
|
132
|
+
try {
|
|
133
|
+
if (cfg?.eventFile) return eventFileFor(String(cfg.eventFile));
|
|
134
|
+
const ref = sessionRef || {};
|
|
135
|
+
if (ref.file) return eventFileFor(String(ref.file));
|
|
136
|
+
if (ref.name) return eventFileFor(path.join(mingdaoHome(), 'sessions', String(ref.name)));
|
|
137
|
+
} catch {}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
120
141
|
/**
|
|
121
142
|
* 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
|
|
122
143
|
* @param {{ provider: any, permission: any, io: any, modelName: any, workingDir: any,
|
|
@@ -132,6 +153,53 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
132
153
|
// v0.6.26 §3.91:子代理实例(spawnTask 传入 parentProgress / 子任务标记)。子代理的成功**不**计入
|
|
133
154
|
// 端点"连续干净成功"——一个父回合里可能跑多个子代理,否则一次回合就能把 +25% 的上调门槛凑满。
|
|
134
155
|
const isSubagent = Boolean(cfg?.parentProgress || cfg?.subagentLabel);
|
|
156
|
+
// v0.7.0 §1.1:事件双写的开关与出口(旁路:任何失败只 warn,绝不影响回合结果)。
|
|
157
|
+
// `cfg.eventLog === false` 时**同时**停掉 session.appendMessages 的 message.append 双写,
|
|
158
|
+
// 否则只会关掉 agent 事件、留下半份事件流。子代理不重复设置进程开关(父代理已设)。
|
|
159
|
+
const eventsOn = eventLogEnabled(cfg);
|
|
160
|
+
if (!isSubagent) setEventLogEnabled(eventsOn);
|
|
161
|
+
let eventFileKey = '';
|
|
162
|
+
let eventFileCache = /** @type {string|null} */ (null);
|
|
163
|
+
let eventWarned = false;
|
|
164
|
+
const sessionEventFile = () => {
|
|
165
|
+
const ref = /** @type {any} */ (sessionRef || {});
|
|
166
|
+
const key = `${String(cfg?.eventFile || '')}|${String(ref.file || '')}|${String(ref.name || '')}`;
|
|
167
|
+
if (key !== eventFileKey) {
|
|
168
|
+
eventFileKey = key;
|
|
169
|
+
eventFileCache = resolveAgentEventFile(sessionRef, cfg);
|
|
170
|
+
}
|
|
171
|
+
return eventFileCache;
|
|
172
|
+
};
|
|
173
|
+
/** @param {string} type @param {any} data */
|
|
174
|
+
const emitEvent = (type, data) => {
|
|
175
|
+
try {
|
|
176
|
+
if (!eventLogEnabled(cfg)) return;
|
|
177
|
+
const file = sessionEventFile();
|
|
178
|
+
if (!file) return;
|
|
179
|
+
const r = appendEvent(file, { type, data });
|
|
180
|
+
if (!r.ok && !eventWarned) {
|
|
181
|
+
eventWarned = true;
|
|
182
|
+
console.warn(`[MingDao] ⚠ 会话事件日志写入失败(${file}):${r.error}——事件日志本次不完整,不影响会话与回合结果。`);
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
};
|
|
186
|
+
// step 边界:step.start 在本步开始时写;step.end 在**下一步开始前 / 回合收尾**补写,
|
|
187
|
+
// 因此即使某个出口提前 return,事件流里也不会留下永远不结束的 step。
|
|
188
|
+
/** @type {{step:number,round:number}|null} */
|
|
189
|
+
let openStep = null;
|
|
190
|
+
/** @param {string} status */
|
|
191
|
+
const closeStep = (status) => {
|
|
192
|
+
if (!openStep) return;
|
|
193
|
+
const s = openStep;
|
|
194
|
+
openStep = null;
|
|
195
|
+
emitEvent('step.end', { step: s.step, round: s.round, status });
|
|
196
|
+
};
|
|
197
|
+
/** @param {number} step @param {number} round */
|
|
198
|
+
const beginStep = (step, round) => {
|
|
199
|
+
closeStep('next');
|
|
200
|
+
openStep = { step, round };
|
|
201
|
+
emitEvent('step.start', { step, round });
|
|
202
|
+
};
|
|
135
203
|
// safeBudget 恒用:即使用户显式 contextBudget 也套「窗口−输出−余量」上限与 75% 舒适区,
|
|
136
204
|
// 防显式值撑爆窗口(本地模型窗口可能只有 32k/131k,用户却留了默认 128000)。
|
|
137
205
|
const safeB = safeBudget(cfg, caps);
|
|
@@ -595,6 +663,14 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
595
663
|
zeroToolCalls: ensureZeroToolCallNotice(),
|
|
596
664
|
});
|
|
597
665
|
let aborted = false;
|
|
666
|
+
// v0.7.0 §1.1:中断事件只写一次(SIGINT 可能在思考/工具/权限询问期间到达)。
|
|
667
|
+
// toolCallIds 交给 replay 延迟补占位:随后 message.append 若已含占位,不会重复。
|
|
668
|
+
let interruptEmitted = false;
|
|
669
|
+
const emitInterruptOnce = (/** @type {string} */ reason) => {
|
|
670
|
+
if (interruptEmitted) return;
|
|
671
|
+
interruptEmitted = true;
|
|
672
|
+
emitEvent('interrupt', { reason, toolCallIds: missingToolCallIds(messages) });
|
|
673
|
+
};
|
|
598
674
|
// 连续空/截断输出计数(防止无限续写)。v0.6.28(M-10):**必须真的复位**——
|
|
599
675
|
// 有进展的工具轮(成功且不重复,与看门狗同判据)与有内容的模型轮都会清零,
|
|
600
676
|
// 否则「3 次非连续空轮」会被误判成「连续 3 轮」而中止(文案与实现的判据必须一致)。
|
|
@@ -634,7 +710,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
634
710
|
// A1:本回合的剥描述冻结快照(会话级 usedToolNames 的副本)——回合内 schema 字节不变
|
|
635
711
|
const turnStrippedSet = new Set(usedToolNames);
|
|
636
712
|
// 整个回合注册一次 SIGINT:思考、工具执行、权限询问期间都能中断
|
|
637
|
-
const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; currentAc?.abort(); }) : () => {};
|
|
713
|
+
const offSigint = io.onSigint ? io.onSigint(() => { aborted = true; emitInterruptOnce('sigint'); currentAc?.abort(); }) : () => {};
|
|
638
714
|
// ---- v0.6.13(A):回合级进度快照 + 无进展看门狗 ----
|
|
639
715
|
// 快照挂在 io 上:WebUI 服务端(src/web/server.js)每 5s 读它下发状态条、每 30s 落一行日志。
|
|
640
716
|
// 这里不改 web-io.js 的接口(它不在本次改动面内),字段是"只读约定"。
|
|
@@ -937,6 +1013,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
937
1013
|
// recordUsage,长任务期间「今日费用」恒为 0 被误读为「无统计」;中断时已完成轮次费用也保留)。
|
|
938
1014
|
const roundUsageStart = { prompt_tokens: usage.prompt_tokens, completion_tokens: usage.completion_tokens, prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens || 0, prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens || 0 };
|
|
939
1015
|
while (steps < stepLimit) {
|
|
1016
|
+
beginStep(steps + 1, round + 1);
|
|
940
1017
|
steps += 1;
|
|
941
1018
|
// v0.6.28(L-13):`finish` 是**本次模型请求**的 finish_reason,不是回合级状态。
|
|
942
1019
|
// 每步开始重置,且只由本步的 res.finish 写入(见下方赋值)——否则上一轮 length/capped
|
|
@@ -1011,6 +1088,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1011
1088
|
try {
|
|
1012
1089
|
onCompact?.(messages);
|
|
1013
1090
|
} catch {}
|
|
1091
|
+
// v0.7.0 §1.1:compact 事件携带压缩后的**完整基线**——重放据此整体替换,
|
|
1092
|
+
// 与 onCompact 把同一份 messages 重写进 .jsonl 是同一事实。
|
|
1093
|
+
emitEvent('compact', { droppedCount: compacted.droppedCount, droppedTokens: compacted.droppedTokens, messages });
|
|
1014
1094
|
}
|
|
1015
1095
|
} catch {}
|
|
1016
1096
|
}
|
|
@@ -1505,6 +1585,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1505
1585
|
}
|
|
1506
1586
|
}
|
|
1507
1587
|
}
|
|
1588
|
+
// v0.7.0 §1.1:本步模型用量(逐轮,便于与 message/tool 事件对齐)
|
|
1589
|
+
if (res.usage) emitEvent('usage', { model: activeModel, round, step: steps, usage: res.usage, total: { ...usage } });
|
|
1508
1590
|
|
|
1509
1591
|
if (res.toolCalls?.length) {
|
|
1510
1592
|
// v0.6.13(A):本回合 tool_call 计数(界面/日志/收尾提示的单一来源)
|
|
@@ -1661,6 +1743,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1661
1743
|
// 后续相同调用直接复用结果(仍逐个回填 tool 消息以保持 tool_call_id 配对)
|
|
1662
1744
|
async function runTool(/** @type {any} */ prep) {
|
|
1663
1745
|
io.renderToolStart?.(prep.name, prep.args);
|
|
1746
|
+
// v0.7.0 §1.1:工具边界事件(只记 id/名字/是否命中缓存,不记参数正文)
|
|
1747
|
+
emitEvent('tool.start', { callId: prep.tc?.id ?? null, name: String(prep.name || ''), cached: Boolean(prep.cached) });
|
|
1664
1748
|
// v0.6.13(A):登记"正在等待哪个工具"——`task` 子代理/长命令动辄几十分钟,
|
|
1665
1749
|
// 界面与日志必须能显示"在等谁、等了多久",否则与卡死无法区分。
|
|
1666
1750
|
const prevPending = turnProgress.pendingTool;
|
|
@@ -1725,6 +1809,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1725
1809
|
else turnProgress.unproductive += 1;
|
|
1726
1810
|
// v0.6.28(M-10):有进展的工具轮复位空轮计数(失败/重复调用不算进展,与看门狗同判据)。
|
|
1727
1811
|
if (toolOk && repeatedBefore === 0) emptyRounds = 0;
|
|
1812
|
+
emitEvent('tool.end', { callId: prep.tc?.id ?? null, name: String(prep.name || ''), ok: toolOk, ms, cached: Boolean(prep.cached) });
|
|
1728
1813
|
// 省钱 B3:逐工具调用/耗时累加(费用二级分账的 byTool 维度)
|
|
1729
1814
|
const ts = toolStats.get(prep.name) || { calls: 0, ms: 0 };
|
|
1730
1815
|
ts.calls += 1;
|
|
@@ -1799,6 +1884,12 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1799
1884
|
|
|
1800
1885
|
// 拒绝项(参数解析失败/钩子阻止/预设白名单/权限拒绝/领域约束)同样按原序入队。
|
|
1801
1886
|
// 它们没有执行、没有耗时/账本结果,只回填一条 tool 消息——顺序必须与成功项一致。
|
|
1887
|
+
function emitDeniedToolEvents(/** @type {any} */ prep) {
|
|
1888
|
+
// 拒绝项没有执行/耗时,但同样是「一次工具调用」:补 start/end 让时间线成对。
|
|
1889
|
+
emitEvent('tool.start', { callId: prep.tc?.id ?? null, name: String(prep.name || ''), denied: true });
|
|
1890
|
+
emitEvent('tool.end', { callId: prep.tc?.id ?? null, name: String(prep.name || ''), ok: false, denied: true, ms: 0 });
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1802
1893
|
function finishDenied(/** @type {any} */ prep) {
|
|
1803
1894
|
pendingToolMessages.push({
|
|
1804
1895
|
idx: prep.idx,
|
|
@@ -1857,6 +1948,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1857
1948
|
for (let idx = 0; idx < prefix.length; idx += 1) await finishTool(prefix[idx].prep, results[idx].r, results[idx].t0);
|
|
1858
1949
|
for (const b of batch.slice(prefix.length)) {
|
|
1859
1950
|
if (b.prep.denied != null) {
|
|
1951
|
+
emitDeniedToolEvents(b.prep);
|
|
1860
1952
|
finishDenied(b.prep);
|
|
1861
1953
|
continue;
|
|
1862
1954
|
}
|
|
@@ -1866,6 +1958,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
1866
1958
|
} else {
|
|
1867
1959
|
for (const b of batch) {
|
|
1868
1960
|
if (b.prep.denied != null) { // 拒绝/失败成员:只入队,不执行
|
|
1961
|
+
emitDeniedToolEvents(b.prep);
|
|
1869
1962
|
finishDenied(b.prep);
|
|
1870
1963
|
continue;
|
|
1871
1964
|
}
|
|
@@ -2239,13 +2332,29 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
2239
2332
|
turnLearned = { ...freshLearned.record, appliedBudget: freshSafe };
|
|
2240
2333
|
}
|
|
2241
2334
|
let r = /** @type {any} */ (null);
|
|
2335
|
+
// v0.7.0 §1.1:turn 边界(双写旁路;cfg.eventLog=false 时 emitEvent 内部直接 no-op)
|
|
2336
|
+
emitEvent('turn.start', { model: modelName, session: sessionRef?.name ?? null, messages: Array.isArray(messages) ? messages.length : 0 });
|
|
2242
2337
|
try {
|
|
2243
2338
|
r = await runTurnInner(messages, startBudget);
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
closeStep('error');
|
|
2341
|
+
emitEvent('turn.end', { status: 'error', error: String(/** @type {any} */ (err)?.message ?? err) });
|
|
2342
|
+
throw err;
|
|
2244
2343
|
} finally {
|
|
2245
2344
|
turnLearned = null;
|
|
2246
2345
|
currentTurnBudget = null;
|
|
2247
2346
|
currentTurnMaxOutput = null;
|
|
2248
2347
|
}
|
|
2348
|
+
// 收尾:最后一个 step 也要有 step.end(事件流里不留下永远不结束的 step),再写 turn.end。
|
|
2349
|
+
closeStep(r?.aborted ? 'aborted' : r?.stalled ? 'stalled' : 'completed');
|
|
2350
|
+
emitEvent('turn.end', {
|
|
2351
|
+
status: r?.stalled ? 'stalled' : r?.aborted ? 'aborted' : r?.capHit ? 'capped' : 'done',
|
|
2352
|
+
steps: Number(r?.perf?.steps) || 0,
|
|
2353
|
+
usage: r?.usage ?? null,
|
|
2354
|
+
aborted: Boolean(r?.aborted),
|
|
2355
|
+
capHit: Boolean(r?.capHit),
|
|
2356
|
+
truncated: Boolean(r?.truncated),
|
|
2357
|
+
});
|
|
2249
2358
|
// v0.6.26 §3.91(端点级学习·上调):**干净成功**(无内存压缩、无中断/无进展/截断)才计数,
|
|
2250
2359
|
// 连续 3 次把学习值 +25%(上限 = 用户配置的预算)。上调后若再撞内存墙,
|
|
2251
2360
|
// 两级压缩仍失败会把它重新学下来——降级与恢复都基于真实结果。
|
package/src/config.js
CHANGED
|
@@ -7,6 +7,7 @@ import os from 'node:os';
|
|
|
7
7
|
import { PROVIDERS, modelPreset, providerPreset, pickFamilyModel, normalizeModelLabels, DEFAULT_MODEL, DEFAULT_PLANNER_MODEL, DEFAULT_EXECUTOR_MODEL } from './models.js';
|
|
8
8
|
import { setStoredKey, resolveApiKey, maskKey } from './credentials.js';
|
|
9
9
|
import { atomicWriteFileSync, withFileLock, withFileLockSync as withStateFileLockSync } from './atomic-write.js';
|
|
10
|
+
import { setEventLogEnabled } from './session-events.js';
|
|
10
11
|
|
|
11
12
|
export function mingdaoHome() {
|
|
12
13
|
return process.env.MINGDAO_HOME || path.join(os.homedir(), '.mingdao');
|
|
@@ -357,6 +358,19 @@ function prepareConfigForWrite(cfg) {
|
|
|
357
358
|
return base;
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
/**
|
|
362
|
+
* v0.7.0 §1.1:把 `config.eventLog` 同步到事件日志的进程级开关。
|
|
363
|
+
*
|
|
364
|
+
* 为什么必须在 config 层做一次:WebUI 的 /api/chat 会**先**把用户消息 appendMessages 落盘、
|
|
365
|
+
* **后**才 createAgent;只靠 createAgent 设置开关会让 config 里的 eventLog=false 漏掉这条
|
|
366
|
+
* 用户消息(半份事件流)。loadConfig/saveConfig 是配置进入进程的两个入口,在这里设一次,
|
|
367
|
+
* 任何「先写会话、后建 agent」的顺序都不会漏。缺省(字段不存在)按开启处理。
|
|
368
|
+
* @param {any} cfg
|
|
369
|
+
*/
|
|
370
|
+
function applyEventLogGate(cfg) {
|
|
371
|
+
setEventLogEnabled(!(cfg && cfg.eventLog === false));
|
|
372
|
+
}
|
|
373
|
+
|
|
360
374
|
/** 读取配置对象(不存在/损坏返回 null);返回值为用户可编辑的任意 JSON 配置,类型不定
|
|
361
375
|
*
|
|
362
376
|
* 注意(v0.6.3 / H-7):null **同时**表示「不存在」与「读不出来」。凡是要**写**配置的调用点,
|
|
@@ -367,6 +381,7 @@ export function loadConfig() {
|
|
|
367
381
|
if (!r.ok) return null;
|
|
368
382
|
const cfg = applyModelLabels(applyCachedModelDefaults(r.data));
|
|
369
383
|
rememberConfig(cfg); // v0.7.0 §1.3:记下基线,saveConfig 只合并增量
|
|
384
|
+
applyEventLogGate(cfg); // v0.7.0 §1.1:config.eventLog=false 在「先落盘、后建 agent」路径也生效
|
|
370
385
|
return cfg;
|
|
371
386
|
}
|
|
372
387
|
|
|
@@ -388,11 +403,13 @@ export function loadConfigForWrite(why = '某个写配置的命令') {
|
|
|
388
403
|
if (r.ok) {
|
|
389
404
|
const cfg = r.data || {};
|
|
390
405
|
rememberConfig(cfg);
|
|
406
|
+
applyEventLogGate(cfg);
|
|
391
407
|
return cfg;
|
|
392
408
|
}
|
|
393
409
|
quarantineCorruptConfig(`${why} 需要写回配置,但读不出来:${r.error}`);
|
|
394
410
|
const empty = {};
|
|
395
411
|
rememberConfig(empty);
|
|
412
|
+
applyEventLogGate(empty);
|
|
396
413
|
return empty;
|
|
397
414
|
}
|
|
398
415
|
|
|
@@ -444,6 +461,7 @@ export function saveConfig(cfg) {
|
|
|
444
461
|
writeConfigUnlocked(prepareConfigForWrite(cfg));
|
|
445
462
|
rememberConfig(cfg); // 成功后把这版对象当作下次增量比较的基线
|
|
446
463
|
});
|
|
464
|
+
applyEventLogGate(cfg);
|
|
447
465
|
return { ok: true, error: null };
|
|
448
466
|
} catch (err) {
|
|
449
467
|
const msg = String(/** @type {any} */ (err)?.message ?? err);
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
// 会话事件日志(v0.7.0 §1.1 切片 A):append-only 的事件流,与消息数组**双写**。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它:会话此前只存 `<home>/sessions/<name>.jsonl` 的消息数组——「这条消息属于哪个
|
|
4
|
+
// turn/step、工具有没有结束、压缩发生在第几条之前」这些结构信息只能靠字段猜。本模块把会话状态
|
|
5
|
+
// 增加一条**可重放**的事实源:每条 `{seq,time,type,data}` 追加一行,seq 单调且是唯一关联键。
|
|
6
|
+
//
|
|
7
|
+
// 本切片**不改既有读取路径**:
|
|
8
|
+
// · `loadSession` / `rewriteSession` 的返回值与 `.jsonl` 格式一字未动;
|
|
9
|
+
// · 事件文件是**并列的新侧车** `<session>.events.ndjson`,旧会话没有它照样可读;
|
|
10
|
+
// **为什么不是 .jsonl**:全仓(以及用户脚本/测试)有大量 `readdirSync(sessions).filter(f => f.endsWith('.jsonl'))`
|
|
11
|
+
// 的写法,侧车若也叫 `.jsonl` 就会被当成会话——本版实测已挂掉 2 个 e2e(分享时把侧车当会话推给服务端,
|
|
12
|
+
// 服务端回「你还没有这个会话」)。改成 `.ndjson`(同格式、不同扩展名)让这类碰撞在命名层就不存在。
|
|
13
|
+
// · SSE 帧、UI、账本、同步都不读事件文件。
|
|
14
|
+
// 因此事件日志坏掉/写不进去,最多是「这条时间线不完整」,绝不影响会话本身。
|
|
15
|
+
//
|
|
16
|
+
// 可靠性约定:
|
|
17
|
+
// · 所有对外 API 一律返回 `{ok,error}` 形态,**任何失败都不许抛到调用方**;
|
|
18
|
+
// · seq 分配在跨进程文件锁内「读尾行 → last+1 → 单次 append」,同进程再用 Map 缓存上一条,
|
|
19
|
+
// 避免 8 个子进程并发时重号;
|
|
20
|
+
// · 读取跳过 torn 行(半截 JSON)与未知 type,并返回 `skipped` 计数——**可见**,不静默丢;
|
|
21
|
+
// · `replayToMessages` 是纯函数:`message.append` 提供消息本体,`compact` 重置基线,
|
|
22
|
+
// `interrupt` 在最终消息数组里补齐尚未回填的 tool 占位(延迟到全部事件读完再补,避免与
|
|
23
|
+
// 随后 message.append 的占位重复/错位)。
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { appendFilePrivateSync } from './atomic-write.js';
|
|
28
|
+
|
|
29
|
+
/** 本切片先定这 12 类事件;新增类型必须同时更新这里与 `test/session-events.js` 的清单。 */
|
|
30
|
+
export const EVENT_TYPES = Object.freeze([
|
|
31
|
+
'session.start',
|
|
32
|
+
'turn.start',
|
|
33
|
+
'turn.end',
|
|
34
|
+
'step.start',
|
|
35
|
+
'step.end',
|
|
36
|
+
'tool.start',
|
|
37
|
+
'tool.end',
|
|
38
|
+
'message.append',
|
|
39
|
+
'usage',
|
|
40
|
+
'compact',
|
|
41
|
+
'interrupt',
|
|
42
|
+
'checkpoint',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const EVENT_TYPE_SET = new Set(EVENT_TYPES);
|
|
46
|
+
|
|
47
|
+
/** 回滚开关:`MINGDAO_EVENT_LOG=0`(或 false/off/no)时全局关闭双写。 */
|
|
48
|
+
export const EVENT_LOG_ENV = 'MINGDAO_EVENT_LOG';
|
|
49
|
+
|
|
50
|
+
/** 中断时补的 tool 占位文案——与 `agent.js#finishTurn` 的既有文案保持逐字一致。 */
|
|
51
|
+
export const INTERRUPTED_TOOL_CONTENT = '(已中断,未执行)';
|
|
52
|
+
|
|
53
|
+
/** 尾部读取窗口:足够覆盖若干条事件;只有读不到 seq 且文件更大时才回退整文件。 */
|
|
54
|
+
const TAIL_BYTES = 64 * 1024;
|
|
55
|
+
|
|
56
|
+
/** 同进程 seq 缓存:`事件文件绝对路径 → {seq, size}`。跨进程仍以文件尾为准。 */
|
|
57
|
+
const seqCache = new Map();
|
|
58
|
+
|
|
59
|
+
/** 追加锁的临界区只有「读尾行 + 一次 append」:超过这个年龄的锁视为崩溃残留。 */
|
|
60
|
+
const APPEND_LOCK_STALE_MS = 10000;
|
|
61
|
+
const APPEND_LOCK_TIMEOUT_MS = 10000;
|
|
62
|
+
|
|
63
|
+
/** 同步小睡:临界区极短,用 Atomics.wait 让出 CPU(不引入异步传染)。 */
|
|
64
|
+
function sleepSync(/** @type {number} */ ms) {
|
|
65
|
+
try {
|
|
66
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 释放追加锁。返回是否真的删掉了(删除失败只影响下一次抢锁,不影响本次写入结果)。 */
|
|
71
|
+
function releaseAppendLock(/** @type {string} */ lock) {
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(lock);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 回收崩溃残留锁:只回收「超过临界区阈值」的锁,避免误删刚建的新锁。 */
|
|
81
|
+
function reclaimStaleAppendLock(/** @type {string} */ lock) {
|
|
82
|
+
try {
|
|
83
|
+
const st = fs.statSync(lock);
|
|
84
|
+
if (Date.now() - st.mtimeMs <= APPEND_LOCK_STALE_MS) return false;
|
|
85
|
+
try {
|
|
86
|
+
fs.unlinkSync(lock);
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 每文件一个极短临界区的追加锁(`<eventFile>.lock`,O_EXCL 创建)。
|
|
98
|
+
*
|
|
99
|
+
* 为什么不用 `atomic-write.js#withFileLockSync`:那套锁允许调用方按业务持锁数秒,
|
|
100
|
+
* 本模块的临界区只有「读尾行 → append → 回读校验」三步、毫秒级;这里用更小的超时/陈旧阈值,
|
|
101
|
+
* 且失败一律折成 `{ok:false}`(见 appendEvent 的 catch),不会让事件日志拖住会话。
|
|
102
|
+
* 也可以理解为:seq 的唯一性靠这把锁,锁外的任何路径都不写事件文件。
|
|
103
|
+
* @template T
|
|
104
|
+
* @param {string} file
|
|
105
|
+
* @param {() => T} fn
|
|
106
|
+
* @returns {T}
|
|
107
|
+
*/
|
|
108
|
+
function withAppendLock(file, fn) {
|
|
109
|
+
const lock = `${file}.lock`;
|
|
110
|
+
const deadline = Date.now() + APPEND_LOCK_TIMEOUT_MS;
|
|
111
|
+
for (;;) {
|
|
112
|
+
let fd = -1;
|
|
113
|
+
try {
|
|
114
|
+
fd = fs.openSync(lock, 'wx', 0o600);
|
|
115
|
+
try {
|
|
116
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
117
|
+
} finally {
|
|
118
|
+
fs.closeSync(fd);
|
|
119
|
+
fd = -1;
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (fd >= 0) {
|
|
124
|
+
try {
|
|
125
|
+
fs.closeSync(fd);
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
const code = String(/** @type {any} */ (err)?.code || '');
|
|
129
|
+
if (code !== 'EEXIST') throw err;
|
|
130
|
+
// 崩溃残留:临界区毫秒级,超过 10s 的锁不可能是活写方;二次 stat 防误删刚建的新锁。
|
|
131
|
+
if (reclaimStaleAppendLock(lock)) continue;
|
|
132
|
+
if (Date.now() > deadline) throw new Error(`获取事件追加锁超时:${lock}`);
|
|
133
|
+
sleepSync(5);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
return fn();
|
|
138
|
+
} finally {
|
|
139
|
+
releaseAppendLock(lock);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 写失败告警去重(每个事件文件每次进程只提示一次,避免每条消息刷屏)。 */
|
|
144
|
+
const warnedFiles = new Set();
|
|
145
|
+
|
|
146
|
+
/** 进程级开关(`createAgent` 按 `cfg.eventLog` 设置;默认开)。 */
|
|
147
|
+
let processEnabled = true;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 事件日志是否启用。
|
|
151
|
+
* @param {any} [cfg] 传入 config 时额外尊重 `cfg.eventLog === false`
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
export function eventLogEnabled(cfg) {
|
|
155
|
+
const env = String(process.env[EVENT_LOG_ENV] ?? '').toLowerCase();
|
|
156
|
+
if (env === '0' || env === 'false' || env === 'off' || env === 'no') return false;
|
|
157
|
+
if (cfg && cfg.eventLog === false) return false;
|
|
158
|
+
return processEnabled;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 进程级开关。`cfg.eventLog === false` 时由 `createAgent` 调用它,使**会话消息的**
|
|
163
|
+
* `message.append` 双写也一起停下(否则只停 agent 事件,仍会写一半)。
|
|
164
|
+
* @param {boolean} enabled
|
|
165
|
+
*/
|
|
166
|
+
export function setEventLogEnabled(enabled) {
|
|
167
|
+
processEnabled = enabled !== false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 会话文件 → 事件文件:`<session>.jsonl` → `<session>.events.ndjson`。
|
|
172
|
+
* 已经是 `.events.ndjson` 的原样返回(方便调用方只写一个入口)。
|
|
173
|
+
* @param {string} sessionFile
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function eventFileFor(sessionFile) {
|
|
177
|
+
const s = String(sessionFile || '');
|
|
178
|
+
if (!s) return '';
|
|
179
|
+
if (s.endsWith('.events.ndjson')) return s;
|
|
180
|
+
if (s.endsWith('.jsonl')) return `${s.slice(0, -'.jsonl'.length)}.events.ndjson`;
|
|
181
|
+
return `${s}.events.ndjson`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 是否是事件侧车文件名。会话列表/同步必须排除它——否则事件文件会被当成一个「没有消息的
|
|
186
|
+
* 幽灵会话」,甚至因为 mtime 更新而抢走 `latestSession()`。
|
|
187
|
+
* @param {string} name
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
export function isEventLogName(name) {
|
|
191
|
+
return String(name || '').endsWith('.events.ndjson');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @typedef {{seq: number, time: number, type: string, data: any}} SessionEvent
|
|
196
|
+
* @typedef {{ok: boolean, error: string|null, events: SessionEvent[], skipped: number}} ReadEventsResult
|
|
197
|
+
* @typedef {{ok: boolean, error: string|null, seq?: number|null, disabled?: boolean}} AppendEventResult
|
|
198
|
+
*/
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 读文件尾(最多 TAIL_BYTES)。文件不存在按空文件处理;其余读失败上抛,由调用方转成 `{ok:false}`。
|
|
202
|
+
* @param {string} file
|
|
203
|
+
* @returns {{size: number, text: string, endsWithNewline: boolean}}
|
|
204
|
+
*/
|
|
205
|
+
function readTail(file) {
|
|
206
|
+
let fd = -1;
|
|
207
|
+
try {
|
|
208
|
+
fd = fs.openSync(file, 'r');
|
|
209
|
+
const size = fs.fstatSync(fd).size;
|
|
210
|
+
const len = Math.min(size, TAIL_BYTES);
|
|
211
|
+
const buf = Buffer.alloc(len);
|
|
212
|
+
if (len > 0) fs.readSync(fd, buf, 0, len, size - len);
|
|
213
|
+
const text = buf.toString('utf8');
|
|
214
|
+
return { size, text, endsWithNewline: size === 0 || text.endsWith('\n') };
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (String(/** @type {any} */ (err)?.code || '') === 'ENOENT') {
|
|
217
|
+
return { size: 0, text: '', endsWithNewline: true };
|
|
218
|
+
}
|
|
219
|
+
throw err;
|
|
220
|
+
} finally {
|
|
221
|
+
if (fd >= 0) {
|
|
222
|
+
try {
|
|
223
|
+
fs.closeSync(fd);
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 从文本尾部找最后一条**可解析事件的 seq**;torn/非 JSON 行跳过。
|
|
231
|
+
* @param {string} text
|
|
232
|
+
* @returns {number}
|
|
233
|
+
*/
|
|
234
|
+
function lastSeqInText(text) {
|
|
235
|
+
const lines = String(text || '').split('\n');
|
|
236
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
237
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
238
|
+
const line = lines[i].replace(/\r$/, '');
|
|
239
|
+
if (!line.trim()) continue;
|
|
240
|
+
try {
|
|
241
|
+
const ev = JSON.parse(line);
|
|
242
|
+
const seq = Number(ev?.seq);
|
|
243
|
+
if (Number.isInteger(seq) && seq > 0) return seq;
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 读文件尾的 last seq;尾窗读不到且文件更大时回退整文件(只发生在文件损坏/超长时)。
|
|
251
|
+
* @param {string} file
|
|
252
|
+
* @returns {{lastSeq: number, size: number, endsWithNewline: boolean}}
|
|
253
|
+
*/
|
|
254
|
+
function readLastEventSeq(file) {
|
|
255
|
+
const tail = readTail(file);
|
|
256
|
+
let lastSeq = lastSeqInText(tail.text);
|
|
257
|
+
if (!lastSeq && tail.size > Buffer.byteLength(tail.text, 'utf8')) {
|
|
258
|
+
lastSeq = lastSeqInText(fs.readFileSync(file, 'utf8'));
|
|
259
|
+
}
|
|
260
|
+
return { lastSeq, size: tail.size, endsWithNewline: tail.endsWithNewline };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 追加一条事件。返回 `{ok,error,seq}`;**绝不抛异常**。
|
|
265
|
+
*
|
|
266
|
+
* seq 分配:跨进程文件锁内读尾行 last seq + 1,单次 append 后立刻回读校验;同进程用 Map
|
|
267
|
+
* 缓存上一条作为「文件被外部截断/重建」时的保守下限(文件大小小于缓存值时以文件尾为准,
|
|
268
|
+
* 避免凭空跳号)。
|
|
269
|
+
* @param {string} eventFile
|
|
270
|
+
* @param {{type?: string, data?: any, at?: number|Date|null}} [event]
|
|
271
|
+
* @returns {AppendEventResult}
|
|
272
|
+
*/
|
|
273
|
+
export function appendEvent(eventFile, { type = '', data = {}, at = null } = {}) {
|
|
274
|
+
if (!eventLogEnabled()) return { ok: true, error: null, seq: null, disabled: true };
|
|
275
|
+
try {
|
|
276
|
+
const file = path.resolve(String(eventFile || ''));
|
|
277
|
+
if (!file) return { ok: false, error: '事件文件路径为空' };
|
|
278
|
+
if (!EVENT_TYPE_SET.has(type)) return { ok: false, error: `未知事件类型:${String(type)}` };
|
|
279
|
+
const payload = data && typeof data === 'object' ? data : {};
|
|
280
|
+
const time = Number(at instanceof Date ? at.getTime() : at);
|
|
281
|
+
const lineBody = () => {
|
|
282
|
+
const event = { seq: 0, time: Number.isFinite(time) && time > 0 ? time : Date.now(), type, data: payload };
|
|
283
|
+
return event;
|
|
284
|
+
};
|
|
285
|
+
const result = withAppendLock(
|
|
286
|
+
file,
|
|
287
|
+
() => {
|
|
288
|
+
const tail = readLastEventSeq(file);
|
|
289
|
+
const cached = seqCache.get(file);
|
|
290
|
+
let base = tail.lastSeq;
|
|
291
|
+
if (tail.size === 0) base = 0;
|
|
292
|
+
else if (cached && tail.size >= cached.size) base = Math.max(base, cached.seq);
|
|
293
|
+
const seq = base + 1;
|
|
294
|
+
const event = lineBody();
|
|
295
|
+
event.seq = seq;
|
|
296
|
+
const json = JSON.stringify(event);
|
|
297
|
+
// 文件尾部若没有换行(torn 行/手工写入),先补一个换行:新事件永远独占一行。
|
|
298
|
+
const prefix = tail.size > 0 && !tail.endsWithNewline ? '\n' : '';
|
|
299
|
+
appendFilePrivateSync(file, prefix + json + '\n');
|
|
300
|
+
const newSize = tail.size + Buffer.byteLength(prefix + json + '\n', 'utf8');
|
|
301
|
+
// 末尾校验:确认最后一行就是刚写下的事件(锁内不会有第二个写方)。
|
|
302
|
+
const verify = readTail(file);
|
|
303
|
+
const lines = verify.text.split('\n').filter((l) => l.trim());
|
|
304
|
+
const last = lines.length ? lines[lines.length - 1] : '';
|
|
305
|
+
let ok = false;
|
|
306
|
+
try {
|
|
307
|
+
const parsed = JSON.parse(last.replace(/\r$/, ''));
|
|
308
|
+
ok = parsed?.seq === seq && parsed?.type === type;
|
|
309
|
+
} catch {}
|
|
310
|
+
if (!ok) return { ok: false, error: `事件写入后校验失败(seq=${seq})` };
|
|
311
|
+
seqCache.set(file, { seq, size: newSize });
|
|
312
|
+
return { ok: true, error: null, seq };
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
return result;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
return { ok: false, error: String(/** @type {any} */ (err)?.message ?? err) };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 读取事件流。torn 行 / 未知 type / 缺 seq 的行跳过并计入 `skipped`;文件不存在返回空流
|
|
323
|
+
* (这是旧会话的正常形态,不是错误)。
|
|
324
|
+
* @param {string} eventFile
|
|
325
|
+
* @returns {ReadEventsResult}
|
|
326
|
+
*/
|
|
327
|
+
export function readEvents(eventFile) {
|
|
328
|
+
try {
|
|
329
|
+
const file = path.resolve(String(eventFile || ''));
|
|
330
|
+
let raw;
|
|
331
|
+
try {
|
|
332
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (String(/** @type {any} */ (err)?.code || '') === 'ENOENT') {
|
|
335
|
+
return { ok: true, error: null, events: [], skipped: 0 };
|
|
336
|
+
}
|
|
337
|
+
return { ok: false, error: String(/** @type {any} */ (err)?.message ?? err), events: [], skipped: 0 };
|
|
338
|
+
}
|
|
339
|
+
/** @type {SessionEvent[]} */
|
|
340
|
+
const events = [];
|
|
341
|
+
let skipped = 0;
|
|
342
|
+
for (const rawLine of raw.split('\n')) {
|
|
343
|
+
const line = rawLine.replace(/\r$/, '').trim();
|
|
344
|
+
if (!line) continue;
|
|
345
|
+
let parsed;
|
|
346
|
+
try {
|
|
347
|
+
parsed = JSON.parse(line);
|
|
348
|
+
} catch {
|
|
349
|
+
skipped += 1;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
353
|
+
skipped += 1;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!EVENT_TYPE_SET.has(parsed.type)) {
|
|
357
|
+
skipped += 1;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const seq = Number(parsed.seq);
|
|
361
|
+
if (!Number.isInteger(seq) || seq < 1) {
|
|
362
|
+
skipped += 1;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const time = Number(parsed.time);
|
|
366
|
+
events.push({
|
|
367
|
+
seq,
|
|
368
|
+
time: Number.isFinite(time) ? time : 0,
|
|
369
|
+
type: String(parsed.type),
|
|
370
|
+
data: parsed.data && typeof parsed.data === 'object' ? parsed.data : {},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return { ok: true, error: null, events, skipped };
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return { ok: false, error: String(/** @type {any} */ (err)?.message ?? err), events: [], skipped: 0 };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** @param {any} value @returns {any} 深拷贝(事件里只会有 JSON 值)。 */
|
|
380
|
+
function cloneJson(value) {
|
|
381
|
+
try {
|
|
382
|
+
return JSON.parse(JSON.stringify(value));
|
|
383
|
+
} catch {
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* 事件按 seq 升序(seq 是唯一关联键);缺 seq 的保持原序在后。
|
|
390
|
+
* @param {any[]} events
|
|
391
|
+
* @returns {SessionEvent[]}
|
|
392
|
+
*/
|
|
393
|
+
function ordered(events) {
|
|
394
|
+
const list = Array.isArray(events) ? events.filter((e) => e && typeof e === 'object') : [];
|
|
395
|
+
return list
|
|
396
|
+
.map((e, i) => ({ e, i }))
|
|
397
|
+
.sort((a, b) => {
|
|
398
|
+
const sa = Number(a.e?.seq);
|
|
399
|
+
const sb = Number(b.e?.seq);
|
|
400
|
+
const fa = Number.isFinite(sa) ? sa : Number.MAX_SAFE_INTEGER;
|
|
401
|
+
const fb = Number.isFinite(sb) ? sb : Number.MAX_SAFE_INTEGER;
|
|
402
|
+
return fa === fb ? a.i - b.i : fa - fb;
|
|
403
|
+
})
|
|
404
|
+
.map((x) => /** @type {SessionEvent} */ (x.e));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 找出中断时**尚未回填**的 tool_call_id(按 assistant.tool_calls 原序)。
|
|
409
|
+
* 与 `agent.js#finishTurn` 的判据同源:最后一条带 tool_calls 的 assistant 之后,
|
|
410
|
+
* 哪些 id 还没有对应的 tool 消息。
|
|
411
|
+
* @param {any[]} messages
|
|
412
|
+
* @returns {string[]}
|
|
413
|
+
*/
|
|
414
|
+
export function missingToolCallIds(messages) {
|
|
415
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
416
|
+
/** @type {string[]} */
|
|
417
|
+
const ids = [];
|
|
418
|
+
try {
|
|
419
|
+
const assistant = [...list].reverse().find((m) => m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length);
|
|
420
|
+
if (!assistant) return ids;
|
|
421
|
+
const have = new Set(list.filter((m) => m?.role === 'tool' && m.tool_call_id).map((m) => String(m.tool_call_id)));
|
|
422
|
+
for (const tc of assistant.tool_calls) {
|
|
423
|
+
const id = tc?.id ? String(tc.id) : '';
|
|
424
|
+
if (id && !have.has(id)) ids.push(id);
|
|
425
|
+
}
|
|
426
|
+
} catch {}
|
|
427
|
+
return ids;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 由事件流确定性重放出消息数组(纯函数:不读写文件、不改入参)。
|
|
432
|
+
*
|
|
433
|
+
* 语义(与双写顺序一致):
|
|
434
|
+
* · `message.append`:`data.message` 追加到末尾;
|
|
435
|
+
* · `compact`:`data.messages` 是压缩后的**新基线**,整体替换(后续 append 接在它后面);
|
|
436
|
+
* · `interrupt`:`data.toolCallIds` 里的 id 若在最终消息里仍没有 tool 回填,则在对应
|
|
437
|
+
* assistant 之后补一条 `(已中断,未执行)` 占位——**延迟到所有事件处理完再补**,这样
|
|
438
|
+
* 随后到达的 message.append 若已含占位就不会重复,也不会插错位置。
|
|
439
|
+
* @param {any[]} events
|
|
440
|
+
* @returns {any[]}
|
|
441
|
+
*/
|
|
442
|
+
export function replayToMessages(events) {
|
|
443
|
+
/** @type {any[]} */
|
|
444
|
+
let messages = [];
|
|
445
|
+
/** @type {string[]} */
|
|
446
|
+
const pendingInterruptIds = [];
|
|
447
|
+
for (const ev of ordered(events)) {
|
|
448
|
+
if (ev.type === 'message.append') {
|
|
449
|
+
const m = ev.data?.message;
|
|
450
|
+
if (m && typeof m === 'object' && !Array.isArray(m)) messages.push(cloneJson(m));
|
|
451
|
+
} else if (ev.type === 'compact') {
|
|
452
|
+
const base = ev.data?.messages;
|
|
453
|
+
if (Array.isArray(base)) {
|
|
454
|
+
messages = base.filter((m) => m && typeof m === 'object' && !Array.isArray(m)).map(cloneJson);
|
|
455
|
+
}
|
|
456
|
+
} else if (ev.type === 'interrupt') {
|
|
457
|
+
const ids = Array.isArray(ev.data?.toolCallIds) ? ev.data.toolCallIds : Array.isArray(ev.data?.ids) ? ev.data.ids : [];
|
|
458
|
+
for (const raw of ids) {
|
|
459
|
+
const id = raw ? String(raw) : '';
|
|
460
|
+
if (id) pendingInterruptIds.push(id);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// 延迟补占位:只补「最终基线里确实缺 tool 回填」的 id,插在该 assistant 之后连续 tool 块末尾。
|
|
465
|
+
for (const id of [...new Set(pendingInterruptIds)]) {
|
|
466
|
+
if (messages.some((m) => m?.role === 'tool' && String(m.tool_call_id) === id)) continue;
|
|
467
|
+
let assistantAt = -1;
|
|
468
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
469
|
+
const m = messages[i];
|
|
470
|
+
if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.some((/** @type {any} */ tc) => String(tc?.id) === id)) {
|
|
471
|
+
assistantAt = i;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const placeholder = { role: 'tool', tool_call_id: id, content: INTERRUPTED_TOOL_CONTENT };
|
|
476
|
+
if (assistantAt < 0) {
|
|
477
|
+
// 找不到对应的 assistant(可能已被 compact 压掉):不凭空造消息,避免重放与真实消息数组分叉。
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
let insertAt = assistantAt + 1;
|
|
481
|
+
while (insertAt < messages.length && messages[insertAt]?.role === 'tool') insertAt += 1;
|
|
482
|
+
messages.splice(insertAt, 0, placeholder);
|
|
483
|
+
}
|
|
484
|
+
return messages;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 供后续 UI/诊断用的 turn 摘要(可选;纯函数)。只从事件自己推导,不读消息正文。
|
|
489
|
+
* @param {any[]} events
|
|
490
|
+
* @returns {{turn:number, startedAt:number, endedAt:number|null, status:string, steps:number, toolCalls:number, usage:any}[]}
|
|
491
|
+
*/
|
|
492
|
+
export function deriveTurnSummaries(events) {
|
|
493
|
+
/** @type {{turn:number, startedAt:number, endedAt:number|null, status:string, steps:number, toolCalls:number, usage:any}[]} */
|
|
494
|
+
const out = [];
|
|
495
|
+
/** @type {any} */
|
|
496
|
+
let cur = null;
|
|
497
|
+
for (const ev of ordered(events)) {
|
|
498
|
+
if (ev.type === 'turn.start') {
|
|
499
|
+
cur = { turn: out.length + 1, startedAt: ev.time, endedAt: null, status: 'running', steps: 0, toolCalls: 0, usage: null };
|
|
500
|
+
out.push(cur);
|
|
501
|
+
} else if (!cur) {
|
|
502
|
+
continue;
|
|
503
|
+
} else if (ev.type === 'step.start') {
|
|
504
|
+
cur.steps += 1;
|
|
505
|
+
} else if (ev.type === 'tool.start') {
|
|
506
|
+
cur.toolCalls += 1;
|
|
507
|
+
} else if (ev.type === 'usage') {
|
|
508
|
+
cur.usage = ev.data?.usage ?? null;
|
|
509
|
+
} else if (ev.type === 'turn.end') {
|
|
510
|
+
cur.endedAt = ev.time;
|
|
511
|
+
cur.status = String(ev.data?.status ?? 'done');
|
|
512
|
+
cur = null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 写失败时打一条**可见** warn(每个文件每进程一次)。事件日志是旁路,绝不能反过来影响调用方。
|
|
520
|
+
* @param {string} eventFile
|
|
521
|
+
* @param {string|null} error
|
|
522
|
+
*/
|
|
523
|
+
export function warnEventFailure(eventFile, error) {
|
|
524
|
+
const file = String(eventFile || '');
|
|
525
|
+
if (warnedFiles.has(file)) return;
|
|
526
|
+
warnedFiles.add(file);
|
|
527
|
+
try {
|
|
528
|
+
console.warn(
|
|
529
|
+
`[MingDao] ⚠ 会话事件日志写入失败(${file}):${error || '未知原因'}——会话消息已正常写入,` +
|
|
530
|
+
`事件日志本次不完整(可用 config.eventLog=false 或 MINGDAO_EVENT_LOG=0 关闭)。`
|
|
531
|
+
);
|
|
532
|
+
} catch {}
|
|
533
|
+
}
|
package/src/session.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { appendEvent, eventFileFor, eventLogEnabled, isEventLogName, warnEventFailure } from './session-events.js';
|
|
6
7
|
|
|
7
8
|
// —— 冲突备份名(单一事实来源,v0.4.6)——
|
|
8
9
|
// 同步冲突时写入的备份:<会话名>.server-<时间戳>[-<随机后缀>].jsonl(远端版本)与 .remote-(远端拉取版本)。
|
|
@@ -44,7 +45,9 @@ export function listSessionsDetailed(home) {
|
|
|
44
45
|
}
|
|
45
46
|
for (const f of entries) {
|
|
46
47
|
// 冲突备份不是会话:排除后不再出现在会话列表/最近会话/搜索/云同步推送集合中
|
|
47
|
-
|
|
48
|
+
// v0.7.0 §1.1:事件侧车 `<session>.events.ndjson` 不是会话——必须排除,否则它会变成
|
|
49
|
+
// 「没有消息的幽灵会话」,甚至因 mtime 更新而抢走 latestSession()。旧会话行为不变。
|
|
50
|
+
if (!f.endsWith('.jsonl') || isEventLogName(f) || isConflictBackupName(f)) continue;
|
|
48
51
|
const file = path.join(dir, f);
|
|
49
52
|
try {
|
|
50
53
|
list.push({ file, name: f, mtime: fs.statSync(file).mtimeMs });
|
|
@@ -116,12 +119,46 @@ export function stampMessageTs(messages, now = new Date()) {
|
|
|
116
119
|
return messages;
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
/**
|
|
123
|
+
* v0.7.0 §1.1:会话消息的**双写**(消息文件写完之后的旁路)。
|
|
124
|
+
*
|
|
125
|
+
* 纪律:会话文件已经落盘,这里任何失败都只 warn 并返回;**绝不改变 appendMessages 的结果**,
|
|
126
|
+
* 也绝不把异常抛给调用方。`cfg.eventLog=false` 由 agent 侧把进程开关关掉,这里自然一并停止。
|
|
127
|
+
* @param {any} file
|
|
128
|
+
* @param {any[]} messages
|
|
129
|
+
*/
|
|
130
|
+
function doubleWriteSessionEvents(file, messages) {
|
|
131
|
+
if (!eventLogEnabled()) return;
|
|
132
|
+
const eventFile = eventFileFor(file);
|
|
133
|
+
if (!eventFile) return;
|
|
134
|
+
try {
|
|
135
|
+
// 事件流的第一条:只在文件尚不存在时写一次(并发下最多重复一条 session.start,无害)。
|
|
136
|
+
if (!fs.existsSync(eventFile)) {
|
|
137
|
+
const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)) } });
|
|
138
|
+
if (!start.ok) {
|
|
139
|
+
warnEventFailure(eventFile, start.error ?? null);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const m of messages) {
|
|
144
|
+
const r = appendEvent(eventFile, { type: 'message.append', data: { message: m } });
|
|
145
|
+
if (!r.ok) {
|
|
146
|
+
warnEventFailure(eventFile, r.error ?? null);
|
|
147
|
+
return; // 写不进去就停:后面的每一条都只会重复同一条告警
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
warnEventFailure(eventFile, String(/** @type {any} */ (err)?.message ?? err));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
119
155
|
/** @param {any} file @param {any} messages */
|
|
120
156
|
export function appendMessages(file, messages) {
|
|
121
157
|
if (!messages?.length) return;
|
|
122
158
|
stampMessageTs(messages); // 单一来源:所有写入路径在这里补 ts(已有 ts 不覆盖)
|
|
123
159
|
const lines = messages.map((/** @type {any} */ m) => JSON.stringify(m)).join('\n') + '\n';
|
|
124
160
|
appendFilePrivateSync(file, lines);
|
|
161
|
+
doubleWriteSessionEvents(file, messages); // 旁路:失败只 warn,不影响上面的会话写入
|
|
125
162
|
}
|
|
126
163
|
|
|
127
164
|
// 整文件原子重写:自动压缩后把会话文件同步为压缩形态(否则每次加载历史都会重新触发压缩)
|