mocode-ai 0.6.3 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/core.js +26 -18
- package/dist/context/lifecycle.js +55 -32
- package/dist/context/relevance.js +41 -68
- package/dist/context/utils.js +18 -0
- package/dist/repl/index.js +10 -1
- package/dist/session/compact.js +3 -2
- package/dist/session/scheduler.js +6 -3
- package/package.json +1 -1
package/dist/agent/core.js
CHANGED
|
@@ -14,6 +14,7 @@ import { maybeCompact, contextState, dropContextFromHistory } from '../session/i
|
|
|
14
14
|
import { createBudgetScheduler } from '../session/scheduler.js';
|
|
15
15
|
import { optimizeToolResult } from '../context/index.js';
|
|
16
16
|
import { createRelevancePruner } from '../context/relevance.js';
|
|
17
|
+
import { isToolResultSuccess } from '../context/utils.js';
|
|
17
18
|
import { config } from '../config/index.js';
|
|
18
19
|
import { jailResolve } from '../sandbox/index.js';
|
|
19
20
|
import { createLifecycleEngine } from '../context/lifecycle.js';
|
|
@@ -103,7 +104,7 @@ function readDiffContext(tc, parsed) {
|
|
|
103
104
|
* - lifecycle 也在每个 runAgentCore 实例化一次,登记 grep/glob/codegraph 等 producer
|
|
104
105
|
* 与 read/edit/write 的 consumer 关系;孤立+老化自动 STUB(观察类工具永不到 STUB)。
|
|
105
106
|
* - 开关关闭时 lifecycle=null 完全跳过。 */
|
|
106
|
-
function pushToolResult(history, tc, output, pruner, lifecycle, scheduler) {
|
|
107
|
+
function pushToolResult(history, tc, output, pruner, lifecycle, scheduler, runtimeContextState = contextState) {
|
|
107
108
|
const msg = {
|
|
108
109
|
role: 'tool',
|
|
109
110
|
tool_call_id: tc.id,
|
|
@@ -112,14 +113,13 @@ function pushToolResult(history, tc, output, pruner, lifecycle, scheduler) {
|
|
|
112
113
|
content: optimizeToolResult(tc.name, output, tc.arguments),
|
|
113
114
|
};
|
|
114
115
|
history.push(msg);
|
|
115
|
-
|
|
116
|
-
//
|
|
116
|
+
const succeeded = isToolResultSuccess(output);
|
|
117
|
+
// 失败 read 不得淘汰旧 read;失败 consumer 也不能改变 lifecycle 上游状态。
|
|
117
118
|
if (pruner)
|
|
118
|
-
pruner.observePush(history, msg);
|
|
119
|
-
// 观察者生命周期:新 push 一律先登记 LIVE;内部自动维护 producer/consumer 图 + 老化 STUB。
|
|
120
|
-
// lifecycle 内部 try/catch + 幂等;开关关闭时 lifecycle=null 完全跳过。
|
|
119
|
+
pruner.observePush(history, msg, succeeded);
|
|
121
120
|
if (lifecycle)
|
|
122
|
-
lifecycle.pushTool(history, history.length - 1);
|
|
121
|
+
lifecycle.pushTool(history, history.length - 1, succeeded);
|
|
122
|
+
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
125
|
* agent 核心循环(纯逻辑):
|
|
@@ -188,7 +188,10 @@ export async function runAgentCore(opts) {
|
|
|
188
188
|
// 开关关闭时为 null,所有 pushToolResult / mutation 调用走无 lifecycle 路径(零行为变化)。
|
|
189
189
|
// 引擎需要从已有会话 history 恢复观察结果的年龄和 path 索引;不能只追踪本次
|
|
190
190
|
// runAgentCore,否则跨用户轮次的 grep/glob 永远不会衰减。
|
|
191
|
-
|
|
191
|
+
let lifecycle = config.contextLifecycle
|
|
192
|
+
? createLifecycleEngine(history)
|
|
193
|
+
: null;
|
|
194
|
+
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
192
195
|
// 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions。
|
|
193
196
|
// 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
|
|
194
197
|
const scheduler = config.contextBudget !== false
|
|
@@ -236,11 +239,19 @@ export async function runAgentCore(opts) {
|
|
|
236
239
|
// 步前:五区 Budget Scheduler 决策——按 ROI 调度(冷工具优先 / history 摘要最后)。
|
|
237
240
|
// 开关关闭(scheduler=null)时退化回原 maybeCompact 路径,零行为变化。
|
|
238
241
|
// 此时 spinner 已停,通知行干净。
|
|
242
|
+
let historyRebuilt = false;
|
|
239
243
|
if (scheduler) {
|
|
240
|
-
await scheduler.runStep(history, step);
|
|
244
|
+
historyRebuilt = await scheduler.runStep(history, step);
|
|
241
245
|
}
|
|
242
246
|
else {
|
|
243
|
-
await maybeCompact(history, undefined, undefined, runtimeContextState);
|
|
247
|
+
const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState);
|
|
248
|
+
historyRebuilt = compactResult?.historyRebuilt === true;
|
|
249
|
+
}
|
|
250
|
+
// compact 用新消息数组原地重建 history 后,旧 lifecycle 的数字 index 已全部失效。
|
|
251
|
+
// 立即从新 history 恢复状态和 producer 索引,再允许后续 pushTool/pushMutation 使用。
|
|
252
|
+
if (historyRebuilt && lifecycle) {
|
|
253
|
+
lifecycle = createLifecycleEngine(history);
|
|
254
|
+
runtimeContextState.lifecycleStats = lifecycle.stats();
|
|
244
255
|
}
|
|
245
256
|
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
246
257
|
mode = 'idle';
|
|
@@ -440,16 +451,13 @@ export async function runAgentCore(opts) {
|
|
|
440
451
|
// Thrashing:同上(history 附 hint,UI 干净)
|
|
441
452
|
const hint = recordAndHint(tc.name, tc.arguments);
|
|
442
453
|
pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
// 非 mutation 工具(run_command/use_skill/memory_* 等)此处 path="" 不触发。
|
|
446
|
-
// 观察者生命周期:mutation push 后通知 lifecycle 把同 path 的旧 read 标 REFERENCED。
|
|
447
|
-
if (relprune && isMutationTool(tc.name)) {
|
|
454
|
+
// 只有成功 mutation 才会使旧 read 失效;pruner 与 lifecycle 独立启停。
|
|
455
|
+
if (isMutationTool(tc.name) && isToolResultSuccess(output)) {
|
|
448
456
|
const mp = mutationParsed?.path;
|
|
449
457
|
if (typeof mp === 'string' && mp) {
|
|
450
|
-
relprune
|
|
451
|
-
|
|
452
|
-
|
|
458
|
+
relprune?.observeMutation(history, mp);
|
|
459
|
+
lifecycle?.pushMutation(history, history.length - 1, mp);
|
|
460
|
+
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
453
461
|
}
|
|
454
462
|
}
|
|
455
463
|
i++;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// 观察类工具两阶段衰减(避免误伤):
|
|
13
13
|
// - grep/glob/codegraph/web_search/web_fetch 等「观察/检索类」工具两阶段衰减:
|
|
14
|
-
// Phase 1(
|
|
14
|
+
// Phase 1(10 步):LIVE → REFERENCED,保留完整内容(返回多个候选,剩余候选可能后续被消费)。
|
|
15
15
|
// Phase 2(+5 步):REFERENCED → DIGEST,替换为摘要存根(保留文件列表+命中数+参数,丢弃详情),
|
|
16
16
|
// 释放 ~90% token。states 仍为 REFERENCED,不引入新状态。
|
|
17
17
|
// - 当前轮保护区(最后一个 user 之后的工具结果)完全不动。
|
|
@@ -21,14 +21,15 @@
|
|
|
21
21
|
//
|
|
22
22
|
// 与 Relevance Pruner 的分工(不重复):
|
|
23
23
|
// - Relevance Pruner:管 read_file 同 path 旧 read + mutation 覆写 → 直接 STUB。
|
|
24
|
-
// -
|
|
24
|
+
// - 本层:管「观察类工具老化后 → DIGEST、普通孤立工具老化后 → STUB」+
|
|
25
|
+
// 「被消费的工具 → REFERENCED 标记(可视)」。
|
|
25
26
|
//
|
|
26
27
|
// 触发点(agent/core.ts):
|
|
27
28
|
// - pushToolResult 出口,新 message idx = history.length - 1。
|
|
28
29
|
// - mutation 分支额外调 pushMutation 通知(也走 observeMutation 同语义)。
|
|
29
30
|
//
|
|
30
31
|
// 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
|
|
31
|
-
import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
|
|
32
|
+
import { canonicalizePath, extractPath, isToolResultSuccess, lastUserIndex, toText, toolNameOf, } from './utils.js';
|
|
32
33
|
/** 观察类工具(LIVE → REFERENCED → DIGEST 两阶段衰减,永不自动 STUB)。 */
|
|
33
34
|
const OBSERVER_TOOLS = new Set([
|
|
34
35
|
'grep',
|
|
@@ -69,14 +70,22 @@ function extractProducerPaths(toolName, content) {
|
|
|
69
70
|
if (!content)
|
|
70
71
|
return [];
|
|
71
72
|
const out = new Set();
|
|
73
|
+
const addPath = (raw) => {
|
|
74
|
+
const canonical = canonicalizePath(raw);
|
|
75
|
+
if (canonical)
|
|
76
|
+
out.add(canonical);
|
|
77
|
+
};
|
|
72
78
|
try {
|
|
73
79
|
if (toolName === 'grep') {
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
const
|
|
80
|
+
// grep 内置工具的结构化输出是 `path: N 处匹配,行号 [...]`;旧格式也可能是
|
|
81
|
+
// `path:line: content`。两者都必须建立 producer 索引,供后续 read_file 关联。
|
|
82
|
+
const rawLine = /^(.+?\.[A-Za-z0-9]+):\d+:/gm;
|
|
83
|
+
const summaryHeader = /^(.+?\.[A-Za-z0-9]+):\s*\d+\s*(?:处匹配|matches?)[,,]/gmi;
|
|
77
84
|
let m;
|
|
78
|
-
while ((m =
|
|
79
|
-
|
|
85
|
+
while ((m = rawLine.exec(content)))
|
|
86
|
+
addPath(m[1]);
|
|
87
|
+
while ((m = summaryHeader.exec(content)))
|
|
88
|
+
addPath(m[1]);
|
|
80
89
|
}
|
|
81
90
|
else if (toolName === 'glob') {
|
|
82
91
|
// glob 输出一般是「paths:」+ 换行 + 多路径;每行一个绝对或相对路径。
|
|
@@ -87,7 +96,7 @@ function extractProducerPaths(toolName, content) {
|
|
|
87
96
|
continue;
|
|
88
97
|
// 含扩展名或含路径分隔符
|
|
89
98
|
if (/\.[A-Za-z0-9]+$/.test(t) || t.includes('/') || t.includes('\\'))
|
|
90
|
-
|
|
99
|
+
addPath(t);
|
|
91
100
|
}
|
|
92
101
|
}
|
|
93
102
|
else if (toolName === 'codegraph') {
|
|
@@ -95,7 +104,7 @@ function extractProducerPaths(toolName, content) {
|
|
|
95
104
|
for (const line of content.split(/\r?\n/)) {
|
|
96
105
|
const m = /^([^\s:][^:]*?\.[A-Za-z0-9]+):(\d+):/.exec(line);
|
|
97
106
|
if (m)
|
|
98
|
-
|
|
107
|
+
addPath(m[1]);
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
110
|
else if (toolName === 'web_search' || toolName === 'web_fetch') {
|
|
@@ -138,15 +147,14 @@ export class LifecycleEngine {
|
|
|
138
147
|
this.rehydrate(history);
|
|
139
148
|
}
|
|
140
149
|
/**
|
|
141
|
-
*
|
|
150
|
+
* 从会话历史恢复生命周期状态。
|
|
142
151
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* pushTool 的顺序重放登记、消费关系和 age,并使用当时的 user 保护边界推进阶段。
|
|
146
|
-
* 只纳入 observer:非 observer 的短期 orphan 处理刻意保持原有「本轮」语义。
|
|
152
|
+
* 旧轮只恢复 observer,使检索结果能跨轮继续老化;最后一个 user 之后则恢复全部成功、
|
|
153
|
+
* 未归档的工具结果,保证同一 runAgentCore 内 compact 重建 history 后不丢当前轮状态。
|
|
147
154
|
*/
|
|
148
155
|
rehydrate(history) {
|
|
149
156
|
try {
|
|
157
|
+
const currentTurnStart = lastUserIndex(history);
|
|
150
158
|
let replayLastUser = -1;
|
|
151
159
|
const pendingDigests = new Set();
|
|
152
160
|
for (let idx = 1; idx < history.length; idx++) {
|
|
@@ -161,25 +169,33 @@ export class LifecycleEngine {
|
|
|
161
169
|
if (!toolName)
|
|
162
170
|
continue;
|
|
163
171
|
const content = toText(m.content);
|
|
172
|
+
const isObserver = OBSERVER_TOOLS.has(toolName);
|
|
164
173
|
const isDigest = content.startsWith(DIGEST_PREFIX);
|
|
165
|
-
|
|
174
|
+
const isCurrentTurnTool = currentTurnStart >= 0 && idx > currentTurnStart;
|
|
175
|
+
const shouldRestoreLive = isToolResultSuccess(content) &&
|
|
176
|
+
!content.startsWith('⌦[') &&
|
|
177
|
+
(isObserver || isCurrentTurnTool);
|
|
178
|
+
if (shouldRestoreLive) {
|
|
166
179
|
this.states.set(idx, 'LIVE');
|
|
167
180
|
this.consumerCount.set(idx, 0);
|
|
168
181
|
this.age.set(idx, 0);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
182
|
+
// 只有 observer 是 producer;当前轮普通工具只恢复自身状态。
|
|
183
|
+
if (isObserver) {
|
|
184
|
+
for (const path of extractProducerPaths(toolName, content)) {
|
|
185
|
+
const producers = this.producersByPath.get(path) ?? [];
|
|
186
|
+
producers.push(idx);
|
|
187
|
+
this.producersByPath.set(path, producers);
|
|
188
|
+
}
|
|
173
189
|
}
|
|
174
190
|
}
|
|
175
|
-
else if (
|
|
191
|
+
else if (isObserver && isDigest) {
|
|
176
192
|
this.states.set(idx, 'REFERENCED');
|
|
177
193
|
this.consumerCount.set(idx, 0);
|
|
178
194
|
this.age.set(idx, 0);
|
|
179
195
|
this.digestedIdxs.add(idx);
|
|
180
196
|
}
|
|
181
|
-
if (CONSUMER_TOOLS.has(toolName)) {
|
|
182
|
-
const path = extractPath(this.findToolArgs(history, idx));
|
|
197
|
+
if (isToolResultSuccess(content) && CONSUMER_TOOLS.has(toolName)) {
|
|
198
|
+
const path = canonicalizePath(extractPath(this.findToolArgs(history, idx)));
|
|
183
199
|
if (path) {
|
|
184
200
|
for (const producerIdx of this.producersByPath.get(path) ?? []) {
|
|
185
201
|
if (this.states.get(producerIdx) === 'LIVE') {
|
|
@@ -224,7 +240,7 @@ export class LifecycleEngine {
|
|
|
224
240
|
/** 新工具结果 push 进 history 时调;idx = history.length - 1。
|
|
225
241
|
* mutation 工具(edit_file/write_file)的 push 跳过本轮的 autoStubOrphans(由调用方在
|
|
226
242
|
* pushMutation 标完 read REFERENCED 之后再触发),避免刚被 mutation 消费的 read 被提前 STUB。 */
|
|
227
|
-
pushTool(history, idx) {
|
|
243
|
+
pushTool(history, idx, succeeded = true) {
|
|
228
244
|
try {
|
|
229
245
|
const m = history[idx];
|
|
230
246
|
if (!m || m.role !== 'tool')
|
|
@@ -232,6 +248,9 @@ export class LifecycleEngine {
|
|
|
232
248
|
const toolName = toolNameOf(history, idx);
|
|
233
249
|
if (!toolName)
|
|
234
250
|
return;
|
|
251
|
+
// 失败工具结果只保留给模型诊断,不登记为 observation、不会推动老化或污染统计。
|
|
252
|
+
if (!succeeded)
|
|
253
|
+
return;
|
|
235
254
|
// 已 stub 的不重复登记(幂等)。
|
|
236
255
|
const c = toText(m.content);
|
|
237
256
|
if (c.startsWith('⌦['))
|
|
@@ -240,8 +259,8 @@ export class LifecycleEngine {
|
|
|
240
259
|
this.states.set(idx, 'LIVE');
|
|
241
260
|
this.consumerCount.set(idx, 0);
|
|
242
261
|
this.age.set(idx, 0);
|
|
243
|
-
//
|
|
244
|
-
if (OBSERVER_TOOLS.has(toolName)) {
|
|
262
|
+
// 成功的 producer 才建立路径索引;失败结果仍可进入生命周期,但不能成为证据来源。
|
|
263
|
+
if (succeeded && OBSERVER_TOOLS.has(toolName)) {
|
|
245
264
|
const paths = extractProducerPaths(toolName, c);
|
|
246
265
|
for (const p of paths) {
|
|
247
266
|
const arr = this.producersByPath.get(p) ?? [];
|
|
@@ -250,8 +269,8 @@ export class LifecycleEngine {
|
|
|
250
269
|
this.producersByPath.set(p, arr);
|
|
251
270
|
}
|
|
252
271
|
}
|
|
253
|
-
//
|
|
254
|
-
if (CONSUMER_TOOLS.has(toolName)) {
|
|
272
|
+
// 只有成功 consumer 才能消费上游;失败 read/edit/write 不改变旧数据状态。
|
|
273
|
+
if (succeeded && CONSUMER_TOOLS.has(toolName)) {
|
|
255
274
|
const argsRaw = (() => {
|
|
256
275
|
// tool 消息本身没有 args;args 在前导 assistant.tool_calls 里;直接走同 idx 前的 assistant。
|
|
257
276
|
const tcId = m.tool_call_id;
|
|
@@ -266,7 +285,7 @@ export class LifecycleEngine {
|
|
|
266
285
|
}
|
|
267
286
|
return '';
|
|
268
287
|
})();
|
|
269
|
-
const path = extractPath(argsRaw);
|
|
288
|
+
const path = canonicalizePath(extractPath(argsRaw));
|
|
270
289
|
if (path) {
|
|
271
290
|
// 1) 找该 path 的所有上游 producer(grep/glob/codegraph)→ 标 REFERENCED。
|
|
272
291
|
const producers = this.producersByPath.get(path);
|
|
@@ -288,8 +307,7 @@ export class LifecycleEngine {
|
|
|
288
307
|
}
|
|
289
308
|
this.step++;
|
|
290
309
|
this.lastUser = lastUserIndex(history);
|
|
291
|
-
// mutation
|
|
292
|
-
// 再调 flushAutoStub 触发老化检查,避免 read 在被标 REFERENCED 之前被提前 STUB。
|
|
310
|
+
// 成功 mutation 跳过本轮 autoStubOrphans;调用方会在 pushMutation 标完旧 read 后统一检查。
|
|
293
311
|
if (MUTATION_TOOLS.has(toolName))
|
|
294
312
|
return;
|
|
295
313
|
// 老化检查:本次 push 完,扫描 LIVE(且非观察类)的工具消息,age ≥ 阈值 → 标 OBSOLETE → STUB。
|
|
@@ -304,6 +322,11 @@ export class LifecycleEngine {
|
|
|
304
322
|
* 注意:puhToolResult 出口已经登记过 mutation 本身,这里不再调 pushTool(避免 age 翻倍)。 */
|
|
305
323
|
pushMutation(history, mutationIdx, path) {
|
|
306
324
|
try {
|
|
325
|
+
const mutationPath = canonicalizePath(path);
|
|
326
|
+
if (!mutationPath) {
|
|
327
|
+
this.autoStubOrphans(history);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
307
330
|
// mutation 自身已在 pushToolResult 出口登记(若 lifecycle 存在);此处仅做「mutation 是
|
|
308
331
|
// path 的消费者」语义:把该 path 在 mutation 之前的所有 read_file(未被 stub 的 LIVE/REFERENCED)
|
|
309
332
|
// 标 REFERENCED。
|
|
@@ -321,7 +344,7 @@ export class LifecycleEngine {
|
|
|
321
344
|
if (c.startsWith('⌦['))
|
|
322
345
|
continue;
|
|
323
346
|
const argsRaw = this.findToolArgs(history, i);
|
|
324
|
-
if (extractPath(argsRaw) ===
|
|
347
|
+
if (canonicalizePath(extractPath(argsRaw)) === mutationPath) {
|
|
325
348
|
if (this.states.get(i) === 'LIVE')
|
|
326
349
|
this.states.set(i, 'REFERENCED');
|
|
327
350
|
this.consumerCount.set(i, (this.consumerCount.get(i) ?? 0) + 1);
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// - TUI 渲染(hooks.onToolResult)用原始 output,与本层解耦——屏上看全量,LLM 看裁剪后版。
|
|
23
23
|
//
|
|
24
24
|
// 零行为变化兜底:开关 `config.contextRelprune` 关闭时,pipeline 路径完全不调本模块。
|
|
25
|
-
import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
|
|
25
|
+
import { canonicalizePath, extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
|
|
26
26
|
/** stub 标记前缀(供幂等判定)。drop_context 用的是「⌦[已剔除:与当前任务无关]」,
|
|
27
27
|
* 本层用「⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]」,区分两类剔除来源。 */
|
|
28
28
|
const STUB_PREFIX = '⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]';
|
|
@@ -40,50 +40,27 @@ export class RelevancePruner {
|
|
|
40
40
|
/** path → [history index, ...] 按插入序;最新在末尾。 */
|
|
41
41
|
readByPath = new Map();
|
|
42
42
|
/** 把刚 push 的消息通知 pruner。
|
|
43
|
-
* -
|
|
44
|
-
* -
|
|
45
|
-
* - 非 read_file 的 tool 消息:无操作(本层只管 read_file)。
|
|
46
|
-
* - 非 tool 消息(assistant / user / system):无操作。
|
|
43
|
+
* - 只处理成功的 read_file tool 消息;失败读取不能淘汰旧的有效结果。
|
|
44
|
+
* - 登记当前 canonical path,并反向 stub 同 path 旧 read。
|
|
47
45
|
*/
|
|
48
|
-
observePush(history, msg) {
|
|
46
|
+
observePush(history, msg, succeeded = true) {
|
|
49
47
|
try {
|
|
50
|
-
if (msg.role !== 'tool')
|
|
48
|
+
if (!succeeded || msg.role !== 'tool')
|
|
51
49
|
return;
|
|
52
50
|
const m = msg;
|
|
53
|
-
const tcId = m.tool_call_id;
|
|
54
|
-
if (!tcId)
|
|
55
|
-
return;
|
|
56
51
|
const idx = history.length - 1;
|
|
57
52
|
if (idx < 1 || history[idx] !== msg)
|
|
58
53
|
return; // 防御:必须刚 push 到末尾
|
|
59
|
-
|
|
60
|
-
if (name !== 'read_file')
|
|
54
|
+
if (toolNameOf(history, idx) !== 'read_file')
|
|
61
55
|
return;
|
|
62
56
|
const content = toText(msg.content);
|
|
63
57
|
if (content.startsWith(STUB_PREFIX))
|
|
64
58
|
return; // 已是存根(防御)
|
|
65
|
-
|
|
66
|
-
// 退化方案:从消息内容首行解析路径(read_file 输出形如 `\n 1\t...`,无 path;
|
|
67
|
-
// 故必须从 args 取)。找不到则保守不动。
|
|
68
|
-
let path = null;
|
|
69
|
-
for (let j = idx - 1; j >= 1; j--) {
|
|
70
|
-
const mm = history[j];
|
|
71
|
-
if (mm.role !== 'assistant')
|
|
72
|
-
continue;
|
|
73
|
-
const tcs = mm.tool_calls;
|
|
74
|
-
if (!tcs)
|
|
75
|
-
continue;
|
|
76
|
-
const hit = tcs.find((tc) => tc?.id === tcId);
|
|
77
|
-
if (hit) {
|
|
78
|
-
path = extractPath(hit.function?.arguments);
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
59
|
+
const path = this.pathAt(history, idx);
|
|
82
60
|
if (!path)
|
|
83
61
|
return;
|
|
84
|
-
// 先 stub 旧 read(同 path,idx 之前),再登记新 idx。
|
|
62
|
+
// 先 stub 旧 read(同 canonical path,idx 之前),再登记新 idx。
|
|
85
63
|
this.stubPriorReads(history, path, idx);
|
|
86
|
-
// 登记新 idx
|
|
87
64
|
const list = this.readByPath.get(path);
|
|
88
65
|
if (list)
|
|
89
66
|
list.push(idx);
|
|
@@ -94,47 +71,47 @@ export class RelevancePruner {
|
|
|
94
71
|
/* 永不抛错 */
|
|
95
72
|
}
|
|
96
73
|
}
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
74
|
+
/** 从 tool_call arguments 取得并规范化某条 read_file 的路径。 */
|
|
75
|
+
pathAt(history, idx) {
|
|
76
|
+
const tcId = history[idx]?.tool_call_id;
|
|
77
|
+
if (!tcId)
|
|
78
|
+
return null;
|
|
79
|
+
for (let j = idx - 1; j >= 1; j--) {
|
|
80
|
+
const mm = history[j];
|
|
81
|
+
if (mm.role !== 'assistant')
|
|
82
|
+
continue;
|
|
83
|
+
const tcs = mm.tool_calls;
|
|
84
|
+
const hit = tcs?.find((tc) => tc?.id === tcId);
|
|
85
|
+
if (hit)
|
|
86
|
+
return canonicalizePath(extractPath(hit.function?.arguments));
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/** 成功 mutation 后,把该 canonical path 在 mutation 之前的 read 全部 stub。 */
|
|
106
91
|
observeMutation(history, path) {
|
|
107
92
|
try {
|
|
108
|
-
|
|
93
|
+
const canonicalPath = canonicalizePath(path);
|
|
94
|
+
if (!canonicalPath)
|
|
109
95
|
return;
|
|
110
96
|
const idx = history.length - 1;
|
|
111
97
|
if (idx < 1)
|
|
112
98
|
return;
|
|
113
|
-
|
|
114
|
-
this.
|
|
115
|
-
// 该 path 的 read 索引全部作废(mutation 之后再 read 会重新登记)
|
|
116
|
-
this.readByPath.delete(path);
|
|
99
|
+
this.stubPriorReads(history, canonicalPath, idx);
|
|
100
|
+
this.readByPath.delete(canonicalPath);
|
|
117
101
|
}
|
|
118
102
|
catch {
|
|
119
103
|
/* 永不抛错 */
|
|
120
104
|
}
|
|
121
105
|
}
|
|
122
106
|
/**
|
|
123
|
-
* 把 history 里 "path 同 + index < beforeIdx + 不在当前轮保护区"
|
|
124
|
-
* tool
|
|
125
|
-
*
|
|
126
|
-
* 实现:
|
|
127
|
-
* - 用 readByPath[path] 直接拿到所有 index(已登记过),筛 < beforeIdx 的 stub。
|
|
128
|
-
* - 同时扫一遍 [1, beforeIdx) 区间找未登记的(防御:索引可能漏登;不依赖索引也能 stub,
|
|
129
|
-
* 保证正确性。索引只用于"避免重复扫全表"的优化)。
|
|
130
|
-
* - protectedFrom = lastUserIndex(history):user 之后一律不动。
|
|
131
|
-
* - 幂等:已是 STUB_PREFIX 的跳过。
|
|
107
|
+
* 把 history 里 "canonical path 同 + index < beforeIdx + 不在当前轮保护区" 的 read_file
|
|
108
|
+
* tool 消息替换为存根。索引是快路径,全表扫描用于恢复 resume 历史;两条路径都重新校验 path。
|
|
132
109
|
*/
|
|
133
110
|
stubPriorReads(history, path, beforeIdx) {
|
|
134
|
-
const
|
|
111
|
+
const targetPath = canonicalizePath(path);
|
|
112
|
+
if (!targetPath)
|
|
113
|
+
return;
|
|
135
114
|
const guard = lastUserIndex(history);
|
|
136
|
-
// protectedFrom = 最后一个 user index(若 >0);user 之后(>= guard)的 read 永不动。
|
|
137
|
-
// protectedFrom=0 表示无 user(history 只有 system),整段都可 stub。
|
|
138
115
|
const protectedFrom = guard > 0 ? guard : 0;
|
|
139
116
|
const stubOne = (i) => {
|
|
140
117
|
if (i >= beforeIdx)
|
|
@@ -145,30 +122,26 @@ export class RelevancePruner {
|
|
|
145
122
|
if (!m || m.role !== 'tool')
|
|
146
123
|
return;
|
|
147
124
|
const content = toText(m.content);
|
|
148
|
-
if (content.startsWith(
|
|
125
|
+
if (content.startsWith(STUB_PREFIX))
|
|
149
126
|
return; // 幂等
|
|
150
|
-
|
|
151
|
-
if (name !== 'read_file')
|
|
127
|
+
if (toolNameOf(history, i) !== 'read_file')
|
|
152
128
|
return;
|
|
153
|
-
|
|
129
|
+
if (this.pathAt(history, i) !== targetPath)
|
|
130
|
+
return; // 防止 fallback 扫描误裁其他文件
|
|
154
131
|
const tcId = m.tool_call_id;
|
|
155
132
|
if (!tcId)
|
|
156
133
|
return;
|
|
157
|
-
const stub = `${
|
|
134
|
+
const stub = `${STUB_PREFIX} read_file(${targetPath}) ${content.length} 字符 → 已被新 read / mutation 替代 · id …${tcId.slice(-6)}⌫`;
|
|
158
135
|
m.content = stub;
|
|
159
136
|
};
|
|
160
|
-
|
|
161
|
-
const indexed = this.readByPath.get(path);
|
|
137
|
+
const indexed = this.readByPath.get(targetPath);
|
|
162
138
|
if (indexed) {
|
|
163
139
|
for (const i of indexed)
|
|
164
140
|
stubOne(i);
|
|
165
141
|
}
|
|
166
|
-
// 2) 全表扫一遍(防御:索引可能漏登 / 历史来自 resume)
|
|
167
|
-
// 仅扫 [1, beforeIdx) 且不在保护区内的 range,成本可控。
|
|
168
142
|
const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
|
|
169
|
-
for (let i = 1; i < scanEnd; i++)
|
|
143
|
+
for (let i = 1; i < scanEnd; i++)
|
|
170
144
|
stubOne(i);
|
|
171
|
-
}
|
|
172
145
|
}
|
|
173
146
|
}
|
|
174
147
|
/** 默认单例:每个 agent 循环一个。runAgentCore 入口 new 一个,后续 observe 共享。 */
|
package/dist/context/utils.js
CHANGED
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
// 不变量:
|
|
7
7
|
// - 永不抛错(对齐「调度器永不抛错」契约)。
|
|
8
8
|
// - 仅依赖 `ChatMessage` 的最小形状,不反向 import agent / session / tools。
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
/** 将文件路径统一成可用于跨工具关联的绝对 key。
|
|
11
|
+
* Windows 路径不区分大小写,并统一使用 `/`,避免相对/绝对路径及分隔符差异导致漏配。 */
|
|
12
|
+
export function canonicalizePath(input) {
|
|
13
|
+
if (!input?.trim())
|
|
14
|
+
return null;
|
|
15
|
+
try {
|
|
16
|
+
const normalized = path.resolve(input.trim()).replace(/\\/g, '/');
|
|
17
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** 工具层以 `错误:` 作为统一失败前缀;失败结果不得消费或淘汰已有观察数据。 */
|
|
24
|
+
export function isToolResultSuccess(output) {
|
|
25
|
+
return !output.trimStart().startsWith('错误:');
|
|
26
|
+
}
|
|
9
27
|
/** 把消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。
|
|
10
28
|
* 用于估算 token、内容匹配、stub 拼接等场景。 */
|
|
11
29
|
export function toText(content) {
|
package/dist/repl/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { tools } from '../tools/registry.js';
|
|
|
19
19
|
import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
|
|
20
20
|
import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
|
|
21
21
|
import { modelSupportsVision } from '../llm/capabilities.js';
|
|
22
|
+
import { computePruneStats } from '../context/relevance.js';
|
|
22
23
|
import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
|
|
23
24
|
import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
|
|
24
25
|
import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
|
|
@@ -148,7 +149,13 @@ function renderContextBar(history) {
|
|
|
148
149
|
const src = contextState.lastUsage ? '实测' : '估算';
|
|
149
150
|
const k = (n) => `${Math.round(n / 1000)}k`;
|
|
150
151
|
const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
|
|
151
|
-
|
|
152
|
+
const lifecycle = contextState.lifecycleStats;
|
|
153
|
+
const archived = computePruneStats(history);
|
|
154
|
+
const lifecycleLine = lifecycle
|
|
155
|
+
? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
|
|
156
|
+
: '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
|
|
157
|
+
const archiveLine = `\n archived tool results · ${archived.stubbed}`;
|
|
158
|
+
return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${history.length} 条消息 (${src})${ui.reset}${lifecycleLine}${archiveLine}`;
|
|
152
159
|
}
|
|
153
160
|
/** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
|
|
154
161
|
* 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
|
|
@@ -882,6 +889,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
882
889
|
rebuildFromHistory(history);
|
|
883
890
|
contextState.lastUsage = undefined;
|
|
884
891
|
contextState.correction = 1;
|
|
892
|
+
contextState.lifecycleStats = undefined;
|
|
885
893
|
lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
|
|
886
894
|
layout.clearContent();
|
|
887
895
|
renderHistory(history);
|
|
@@ -1017,6 +1025,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1017
1025
|
turnCount = 0; // 反思 cadence 重新计数
|
|
1018
1026
|
contextState.lastUsage = undefined;
|
|
1019
1027
|
contextState.correction = 1;
|
|
1028
|
+
contextState.lifecycleStats = undefined;
|
|
1020
1029
|
lastTurnUsage = undefined; // 清空旧轮的 token 累计
|
|
1021
1030
|
pendingAttachments = []; // 一并清空待发图片
|
|
1022
1031
|
layout.clearContent();
|
package/dist/session/compact.js
CHANGED
|
@@ -320,6 +320,7 @@ export async function compactHistory(history, opts) {
|
|
|
320
320
|
return {
|
|
321
321
|
compacted: true,
|
|
322
322
|
summarized: false,
|
|
323
|
+
historyRebuilt: true,
|
|
323
324
|
estimateBefore,
|
|
324
325
|
estimateAfter,
|
|
325
326
|
reason: microcompactDone2 ? 'microcompact' : 'summarize',
|
|
@@ -411,6 +412,7 @@ export async function compactHistory(history, opts) {
|
|
|
411
412
|
return {
|
|
412
413
|
compacted: true,
|
|
413
414
|
summarized: true,
|
|
415
|
+
historyRebuilt: true,
|
|
414
416
|
estimateBefore,
|
|
415
417
|
estimateAfter,
|
|
416
418
|
reason: 'summarize',
|
|
@@ -487,6 +489,5 @@ export async function maybeCompact(history, report, manualOpts, state = contextS
|
|
|
487
489
|
force: manualOpts?.force,
|
|
488
490
|
contextState: state,
|
|
489
491
|
});
|
|
490
|
-
|
|
491
|
-
return r;
|
|
492
|
+
return r;
|
|
492
493
|
}
|
|
@@ -48,15 +48,17 @@ export function createBudgetScheduler(state = contextState) {
|
|
|
48
48
|
const report = evaluateBudget(history, config.contextWindowTokens, step, state.correction);
|
|
49
49
|
const actions = scheduleActions(report);
|
|
50
50
|
let compactHistoryCalled = false;
|
|
51
|
+
let historyRebuilt = false;
|
|
51
52
|
for (const a of actions) {
|
|
52
53
|
if (a.kind === 'warn') {
|
|
53
54
|
// system 超:写一行提示(配置漂移应由用户处理,不是调度器压)
|
|
54
55
|
layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}调度器警告:${a.layer} ${a.reason}${ui.reset}\n`);
|
|
55
56
|
}
|
|
56
57
|
else if (a.kind === 'compact_history') {
|
|
57
|
-
// 路由到 maybeCompact
|
|
58
|
-
await maybeCompact(history, report, undefined, state);
|
|
58
|
+
// 路由到 maybeCompact;把结构重建信号传回 core,使 lifecycle 按新 index 恢复。
|
|
59
|
+
const result = await maybeCompact(history, report, undefined, state);
|
|
59
60
|
compactHistoryCalled = true;
|
|
61
|
+
historyRebuilt ||= result?.historyRebuilt === true;
|
|
60
62
|
}
|
|
61
63
|
// shrink_cold_tools L1/L2/L3 与 cap_hot_tools:已由 push-time 闸在每次 push 自动跑
|
|
62
64
|
// (cap = MAX_HISTORY_RESULT;pruner = same-path 新旧替换;lifecycle = age stub)。
|
|
@@ -72,6 +74,7 @@ export function createBudgetScheduler(state = contextState) {
|
|
|
72
74
|
obs.lastRunLog = log;
|
|
73
75
|
// 暴露给 /context 共享读(repl / context 命令)
|
|
74
76
|
state.schedulerLog = log;
|
|
77
|
+
return historyRebuilt;
|
|
75
78
|
},
|
|
76
79
|
};
|
|
77
80
|
return obs;
|
|
@@ -79,7 +82,7 @@ export function createBudgetScheduler(state = contextState) {
|
|
|
79
82
|
/** 便捷:agent/core.ts 不需要每次 createBudgetScheduler,直接 runScheduler(history, step)。 */
|
|
80
83
|
export async function runScheduler(history, step, state = contextState) {
|
|
81
84
|
const s = createBudgetScheduler(state);
|
|
82
|
-
|
|
85
|
+
return s.runStep(history, step);
|
|
83
86
|
}
|
|
84
87
|
/** 手动 /compact 入口(repl):与自动路径完全一致——五区 ROI 调度,但 history 摘要强制执行。
|
|
85
88
|
* 即便 layers.history.overBudget=false 或 totalOver=false,manual 仍产 compact_history action
|