mocode-ai 0.6.2 → 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 +28 -18
- package/dist/context/lifecycle.js +289 -36
- package/dist/context/relevance.js +45 -69
- 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 核心循环(纯逻辑):
|
|
@@ -186,7 +186,12 @@ export async function runAgentCore(opts) {
|
|
|
186
186
|
// 观察者生命周期引擎:每个 runAgentCore 实例一个,纯静态、自动维护 grep/glob/codegraph 等
|
|
187
187
|
// producer 与 read/edit/write 的 consumer 引用关系;孤立+老化的非观察类工具自动 STUB。
|
|
188
188
|
// 开关关闭时为 null,所有 pushToolResult / mutation 调用走无 lifecycle 路径(零行为变化)。
|
|
189
|
-
|
|
189
|
+
// 引擎需要从已有会话 history 恢复观察结果的年龄和 path 索引;不能只追踪本次
|
|
190
|
+
// runAgentCore,否则跨用户轮次的 grep/glob 永远不会衰减。
|
|
191
|
+
let lifecycle = config.contextLifecycle
|
|
192
|
+
? createLifecycleEngine(history)
|
|
193
|
+
: null;
|
|
194
|
+
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
190
195
|
// 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions。
|
|
191
196
|
// 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
|
|
192
197
|
const scheduler = config.contextBudget !== false
|
|
@@ -234,11 +239,19 @@ export async function runAgentCore(opts) {
|
|
|
234
239
|
// 步前:五区 Budget Scheduler 决策——按 ROI 调度(冷工具优先 / history 摘要最后)。
|
|
235
240
|
// 开关关闭(scheduler=null)时退化回原 maybeCompact 路径,零行为变化。
|
|
236
241
|
// 此时 spinner 已停,通知行干净。
|
|
242
|
+
let historyRebuilt = false;
|
|
237
243
|
if (scheduler) {
|
|
238
|
-
await scheduler.runStep(history, step);
|
|
244
|
+
historyRebuilt = await scheduler.runStep(history, step);
|
|
239
245
|
}
|
|
240
246
|
else {
|
|
241
|
-
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();
|
|
242
255
|
}
|
|
243
256
|
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
244
257
|
mode = 'idle';
|
|
@@ -438,16 +451,13 @@ export async function runAgentCore(opts) {
|
|
|
438
451
|
// Thrashing:同上(history 附 hint,UI 干净)
|
|
439
452
|
const hint = recordAndHint(tc.name, tc.arguments);
|
|
440
453
|
pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
// 非 mutation 工具(run_command/use_skill/memory_* 等)此处 path="" 不触发。
|
|
444
|
-
// 观察者生命周期:mutation push 后通知 lifecycle 把同 path 的旧 read 标 REFERENCED。
|
|
445
|
-
if (relprune && isMutationTool(tc.name)) {
|
|
454
|
+
// 只有成功 mutation 才会使旧 read 失效;pruner 与 lifecycle 独立启停。
|
|
455
|
+
if (isMutationTool(tc.name) && isToolResultSuccess(output)) {
|
|
446
456
|
const mp = mutationParsed?.path;
|
|
447
457
|
if (typeof mp === 'string' && mp) {
|
|
448
|
-
relprune
|
|
449
|
-
|
|
450
|
-
|
|
458
|
+
relprune?.observeMutation(history, mp);
|
|
459
|
+
lifecycle?.pushMutation(history, history.length - 1, mp);
|
|
460
|
+
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
451
461
|
}
|
|
452
462
|
}
|
|
453
463
|
i++;
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
// - OBSOLETE:无任何消费者引用,且距离当前 push 已老化 N 步(默认 2)。
|
|
10
10
|
// - STUB:已被替换为存根(物理上 content 变成「⌦[无消费者:...]」)。
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
// - grep/glob/codegraph/web_search/web_fetch
|
|
14
|
-
//
|
|
12
|
+
// 观察类工具两阶段衰减(避免误伤):
|
|
13
|
+
// - grep/glob/codegraph/web_search/web_fetch 等「观察/检索类」工具两阶段衰减:
|
|
14
|
+
// Phase 1(10 步):LIVE → REFERENCED,保留完整内容(返回多个候选,剩余候选可能后续被消费)。
|
|
15
|
+
// Phase 2(+5 步):REFERENCED → DIGEST,替换为摘要存根(保留文件列表+命中数+参数,丢弃详情),
|
|
16
|
+
// 释放 ~90% token。states 仍为 REFERENCED,不引入新状态。
|
|
15
17
|
// - 当前轮保护区(最后一个 user 之后的工具结果)完全不动。
|
|
16
18
|
// - 已 STUB(含「⌦[已过时:...]」或「⌦[已剔除:...]」或本层的「⌦[无消费者:...]」)不重复处理。
|
|
17
19
|
// - 永不抛错(对齐上下文管道的「调度器永不抛错」契约)。
|
|
@@ -19,15 +21,16 @@
|
|
|
19
21
|
//
|
|
20
22
|
// 与 Relevance Pruner 的分工(不重复):
|
|
21
23
|
// - Relevance Pruner:管 read_file 同 path 旧 read + mutation 覆写 → 直接 STUB。
|
|
22
|
-
// -
|
|
24
|
+
// - 本层:管「观察类工具老化后 → DIGEST、普通孤立工具老化后 → STUB」+
|
|
25
|
+
// 「被消费的工具 → REFERENCED 标记(可视)」。
|
|
23
26
|
//
|
|
24
27
|
// 触发点(agent/core.ts):
|
|
25
28
|
// - pushToolResult 出口,新 message idx = history.length - 1。
|
|
26
29
|
// - mutation 分支额外调 pushMutation 通知(也走 observeMutation 同语义)。
|
|
27
30
|
//
|
|
28
31
|
// 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
|
|
29
|
-
import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
|
|
30
|
-
/** 观察类工具(
|
|
32
|
+
import { canonicalizePath, extractPath, isToolResultSuccess, lastUserIndex, toText, toolNameOf, } from './utils.js';
|
|
33
|
+
/** 观察类工具(LIVE → REFERENCED → DIGEST 两阶段衰减,永不自动 STUB)。 */
|
|
31
34
|
const OBSERVER_TOOLS = new Set([
|
|
32
35
|
'grep',
|
|
33
36
|
'glob',
|
|
@@ -42,10 +45,19 @@ const CONSUMER_TOOLS = new Set(['read_file', 'edit_file', 'write_file']);
|
|
|
42
45
|
const MUTATION_TOOLS = new Set(['edit_file', 'write_file']);
|
|
43
46
|
/** 存根前缀(区分 Relevance Pruner 与 drop_context)。 */
|
|
44
47
|
const STUB_PREFIX_NO_CONSUMER = '⌦[无消费者:观察结果已无引用价值]';
|
|
48
|
+
/** 观察类工具摘要前缀(DIGEST 状态标记)。 */
|
|
49
|
+
const DIGEST_PREFIX = '⌦[摘要:';
|
|
45
50
|
/** 老化阈值:某条工具消息自 push 以来经历的「消费者 push」次数。
|
|
46
51
|
* ≥ 这个值且仍为 LIVE 且非观察类 → 视为 OBSOLETE → STUB。
|
|
47
52
|
* 默认 2:等价于「跨过两个消费者 push 仍无人引用」= 跨过整轮最末尾的工具调用。 */
|
|
48
53
|
const DEFAULT_AGE_THRESHOLD = 2;
|
|
54
|
+
/** 观察类工具 Phase 1:LIVE → REFERENCED 的老化阈值(普通工具用 DEFAULT_AGE_THRESHOLD=2)。
|
|
55
|
+
* 10 步后才降为 REFERENCED,保留完整内容;理由:返回多个候选(grep 10 文件但只读 1 个),
|
|
56
|
+
* 剩余候选可能后续被消费,给足够时间窗口。 */
|
|
57
|
+
const OBSERVER_REFERENCED_AGE = 10;
|
|
58
|
+
/** 观察类工具 Phase 2:REFERENCED → DIGEST 的老化阈值(从 REFERENCED 起再累积)。
|
|
59
|
+
* +5 步后替换为摘要存根,保留文件列表+命中数+参数,丢弃详情;释放 ~90% token。 */
|
|
60
|
+
const OBSERVER_DIGEST_AGE = 5;
|
|
49
61
|
/** 从 tool 结果的 content 中提取「生产者命中过的 path 列表」。
|
|
50
62
|
* - read_file:没有 path 列表(本身就是单 path 消费者,无需再追生产者)。
|
|
51
63
|
* - grep:content 是 `file:line: ...` 行,提取每行的 file 段(只保留绝对路径形态或与 pattern 匹配的)。
|
|
@@ -58,14 +70,22 @@ function extractProducerPaths(toolName, content) {
|
|
|
58
70
|
if (!content)
|
|
59
71
|
return [];
|
|
60
72
|
const out = new Set();
|
|
73
|
+
const addPath = (raw) => {
|
|
74
|
+
const canonical = canonicalizePath(raw);
|
|
75
|
+
if (canonical)
|
|
76
|
+
out.add(canonical);
|
|
77
|
+
};
|
|
61
78
|
try {
|
|
62
79
|
if (toolName === 'grep') {
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
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;
|
|
66
84
|
let m;
|
|
67
|
-
while ((m =
|
|
68
|
-
|
|
85
|
+
while ((m = rawLine.exec(content)))
|
|
86
|
+
addPath(m[1]);
|
|
87
|
+
while ((m = summaryHeader.exec(content)))
|
|
88
|
+
addPath(m[1]);
|
|
69
89
|
}
|
|
70
90
|
else if (toolName === 'glob') {
|
|
71
91
|
// glob 输出一般是「paths:」+ 换行 + 多路径;每行一个绝对或相对路径。
|
|
@@ -76,7 +96,7 @@ function extractProducerPaths(toolName, content) {
|
|
|
76
96
|
continue;
|
|
77
97
|
// 含扩展名或含路径分隔符
|
|
78
98
|
if (/\.[A-Za-z0-9]+$/.test(t) || t.includes('/') || t.includes('\\'))
|
|
79
|
-
|
|
99
|
+
addPath(t);
|
|
80
100
|
}
|
|
81
101
|
}
|
|
82
102
|
else if (toolName === 'codegraph') {
|
|
@@ -84,7 +104,7 @@ function extractProducerPaths(toolName, content) {
|
|
|
84
104
|
for (const line of content.split(/\r?\n/)) {
|
|
85
105
|
const m = /^([^\s:][^:]*?\.[A-Za-z0-9]+):(\d+):/.exec(line);
|
|
86
106
|
if (m)
|
|
87
|
-
|
|
107
|
+
addPath(m[1]);
|
|
88
108
|
}
|
|
89
109
|
}
|
|
90
110
|
else if (toolName === 'web_search' || toolName === 'web_fetch') {
|
|
@@ -113,17 +133,114 @@ export class LifecycleEngine {
|
|
|
113
133
|
/** producer 路径 → 生产者工具消息 idx 列表(逆查用:某个 path 被消费时,反查上游 producer)。
|
|
114
134
|
* 注意:不存 read_file,因为 read 自身就是消费者不充当 producer。 */
|
|
115
135
|
producersByPath = new Map();
|
|
136
|
+
/** 已被 DIGEST 的 tool 消息 idx 集合(避免重复检查;states 保持 REFERENCED)。 */
|
|
137
|
+
digestedIdxs = new Set();
|
|
138
|
+
/** 摘要不比原文短而保留原文的 idx;仅抑制当前 run 内的重复计算。 */
|
|
139
|
+
digestRetainedIdxs = new Set();
|
|
116
140
|
/** 当前步序号(用于 age 老化:每次 pushTool 自增,对比 age 阈值)。 */
|
|
117
141
|
step = 0;
|
|
118
142
|
/** 最后 user 索引缓存(pushTool 时重算;pushMutation 时也重算,因为 mutation 可能跟 user 同行)。 */
|
|
119
143
|
lastUser = -1;
|
|
120
|
-
constructor(ageThreshold = DEFAULT_AGE_THRESHOLD) {
|
|
144
|
+
constructor(ageThreshold = DEFAULT_AGE_THRESHOLD, history) {
|
|
121
145
|
this.ageThreshold = ageThreshold;
|
|
146
|
+
if (history)
|
|
147
|
+
this.rehydrate(history);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* 从会话历史恢复生命周期状态。
|
|
151
|
+
*
|
|
152
|
+
* 旧轮只恢复 observer,使检索结果能跨轮继续老化;最后一个 user 之后则恢复全部成功、
|
|
153
|
+
* 未归档的工具结果,保证同一 runAgentCore 内 compact 重建 history 后不丢当前轮状态。
|
|
154
|
+
*/
|
|
155
|
+
rehydrate(history) {
|
|
156
|
+
try {
|
|
157
|
+
const currentTurnStart = lastUserIndex(history);
|
|
158
|
+
let replayLastUser = -1;
|
|
159
|
+
const pendingDigests = new Set();
|
|
160
|
+
for (let idx = 1; idx < history.length; idx++) {
|
|
161
|
+
const m = history[idx];
|
|
162
|
+
if (m.role === 'user') {
|
|
163
|
+
replayLastUser = idx;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (m.role !== 'tool')
|
|
167
|
+
continue;
|
|
168
|
+
const toolName = toolNameOf(history, idx);
|
|
169
|
+
if (!toolName)
|
|
170
|
+
continue;
|
|
171
|
+
const content = toText(m.content);
|
|
172
|
+
const isObserver = OBSERVER_TOOLS.has(toolName);
|
|
173
|
+
const isDigest = content.startsWith(DIGEST_PREFIX);
|
|
174
|
+
const isCurrentTurnTool = currentTurnStart >= 0 && idx > currentTurnStart;
|
|
175
|
+
const shouldRestoreLive = isToolResultSuccess(content) &&
|
|
176
|
+
!content.startsWith('⌦[') &&
|
|
177
|
+
(isObserver || isCurrentTurnTool);
|
|
178
|
+
if (shouldRestoreLive) {
|
|
179
|
+
this.states.set(idx, 'LIVE');
|
|
180
|
+
this.consumerCount.set(idx, 0);
|
|
181
|
+
this.age.set(idx, 0);
|
|
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
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (isObserver && isDigest) {
|
|
192
|
+
this.states.set(idx, 'REFERENCED');
|
|
193
|
+
this.consumerCount.set(idx, 0);
|
|
194
|
+
this.age.set(idx, 0);
|
|
195
|
+
this.digestedIdxs.add(idx);
|
|
196
|
+
}
|
|
197
|
+
if (isToolResultSuccess(content) && CONSUMER_TOOLS.has(toolName)) {
|
|
198
|
+
const path = canonicalizePath(extractPath(this.findToolArgs(history, idx)));
|
|
199
|
+
if (path) {
|
|
200
|
+
for (const producerIdx of this.producersByPath.get(path) ?? []) {
|
|
201
|
+
if (this.states.get(producerIdx) === 'LIVE') {
|
|
202
|
+
this.states.set(producerIdx, 'REFERENCED');
|
|
203
|
+
}
|
|
204
|
+
this.consumerCount.set(producerIdx, (this.consumerCount.get(producerIdx) ?? 0) + 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const key of this.states.keys())
|
|
209
|
+
this.age.set(key, (this.age.get(key) ?? 0) + 1);
|
|
210
|
+
this.step++;
|
|
211
|
+
// 和 pushTool 一致:本轮 user 及之后的结果不处理;旧轮结果可以推进。
|
|
212
|
+
for (const [observerIdx, state] of this.states) {
|
|
213
|
+
if (observerIdx >= replayLastUser || this.isDigestFinalized(observerIdx))
|
|
214
|
+
continue;
|
|
215
|
+
const observerName = toolNameOf(history, observerIdx);
|
|
216
|
+
if (!observerName || !OBSERVER_TOOLS.has(observerName))
|
|
217
|
+
continue;
|
|
218
|
+
const age = this.age.get(observerIdx) ?? 0;
|
|
219
|
+
if (state === 'LIVE' && age >= OBSERVER_REFERENCED_AGE) {
|
|
220
|
+
this.states.set(observerIdx, 'REFERENCED');
|
|
221
|
+
}
|
|
222
|
+
else if (state === 'REFERENCED'
|
|
223
|
+
&& age >= OBSERVER_REFERENCED_AGE + OBSERVER_DIGEST_AGE) {
|
|
224
|
+
pendingDigests.add(observerIdx);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.lastUser = lastUserIndex(history);
|
|
229
|
+
// 回放期间只计算状态;最后统一落盘,避免迭代时破坏 path 提取。
|
|
230
|
+
for (const idx of pendingDigests) {
|
|
231
|
+
const toolName = toolNameOf(history, idx);
|
|
232
|
+
if (toolName && idx < Math.max(0, this.lastUser))
|
|
233
|
+
this.digestOne(history, idx, toolName);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// 历史中出现非标准消息时降级为本轮行为,不能影响 agent 主循环。
|
|
238
|
+
}
|
|
122
239
|
}
|
|
123
240
|
/** 新工具结果 push 进 history 时调;idx = history.length - 1。
|
|
124
241
|
* mutation 工具(edit_file/write_file)的 push 跳过本轮的 autoStubOrphans(由调用方在
|
|
125
242
|
* pushMutation 标完 read REFERENCED 之后再触发),避免刚被 mutation 消费的 read 被提前 STUB。 */
|
|
126
|
-
pushTool(history, idx) {
|
|
243
|
+
pushTool(history, idx, succeeded = true) {
|
|
127
244
|
try {
|
|
128
245
|
const m = history[idx];
|
|
129
246
|
if (!m || m.role !== 'tool')
|
|
@@ -131,6 +248,9 @@ export class LifecycleEngine {
|
|
|
131
248
|
const toolName = toolNameOf(history, idx);
|
|
132
249
|
if (!toolName)
|
|
133
250
|
return;
|
|
251
|
+
// 失败工具结果只保留给模型诊断,不登记为 observation、不会推动老化或污染统计。
|
|
252
|
+
if (!succeeded)
|
|
253
|
+
return;
|
|
134
254
|
// 已 stub 的不重复登记(幂等)。
|
|
135
255
|
const c = toText(m.content);
|
|
136
256
|
if (c.startsWith('⌦['))
|
|
@@ -139,8 +259,8 @@ export class LifecycleEngine {
|
|
|
139
259
|
this.states.set(idx, 'LIVE');
|
|
140
260
|
this.consumerCount.set(idx, 0);
|
|
141
261
|
this.age.set(idx, 0);
|
|
142
|
-
//
|
|
143
|
-
if (OBSERVER_TOOLS.has(toolName)) {
|
|
262
|
+
// 成功的 producer 才建立路径索引;失败结果仍可进入生命周期,但不能成为证据来源。
|
|
263
|
+
if (succeeded && OBSERVER_TOOLS.has(toolName)) {
|
|
144
264
|
const paths = extractProducerPaths(toolName, c);
|
|
145
265
|
for (const p of paths) {
|
|
146
266
|
const arr = this.producersByPath.get(p) ?? [];
|
|
@@ -149,8 +269,8 @@ export class LifecycleEngine {
|
|
|
149
269
|
this.producersByPath.set(p, arr);
|
|
150
270
|
}
|
|
151
271
|
}
|
|
152
|
-
//
|
|
153
|
-
if (CONSUMER_TOOLS.has(toolName)) {
|
|
272
|
+
// 只有成功 consumer 才能消费上游;失败 read/edit/write 不改变旧数据状态。
|
|
273
|
+
if (succeeded && CONSUMER_TOOLS.has(toolName)) {
|
|
154
274
|
const argsRaw = (() => {
|
|
155
275
|
// tool 消息本身没有 args;args 在前导 assistant.tool_calls 里;直接走同 idx 前的 assistant。
|
|
156
276
|
const tcId = m.tool_call_id;
|
|
@@ -165,7 +285,7 @@ export class LifecycleEngine {
|
|
|
165
285
|
}
|
|
166
286
|
return '';
|
|
167
287
|
})();
|
|
168
|
-
const path = extractPath(argsRaw);
|
|
288
|
+
const path = canonicalizePath(extractPath(argsRaw));
|
|
169
289
|
if (path) {
|
|
170
290
|
// 1) 找该 path 的所有上游 producer(grep/glob/codegraph)→ 标 REFERENCED。
|
|
171
291
|
const producers = this.producersByPath.get(path);
|
|
@@ -187,8 +307,7 @@ export class LifecycleEngine {
|
|
|
187
307
|
}
|
|
188
308
|
this.step++;
|
|
189
309
|
this.lastUser = lastUserIndex(history);
|
|
190
|
-
// mutation
|
|
191
|
-
// 再调 flushAutoStub 触发老化检查,避免 read 在被标 REFERENCED 之前被提前 STUB。
|
|
310
|
+
// 成功 mutation 跳过本轮 autoStubOrphans;调用方会在 pushMutation 标完旧 read 后统一检查。
|
|
192
311
|
if (MUTATION_TOOLS.has(toolName))
|
|
193
312
|
return;
|
|
194
313
|
// 老化检查:本次 push 完,扫描 LIVE(且非观察类)的工具消息,age ≥ 阈值 → 标 OBSOLETE → STUB。
|
|
@@ -203,6 +322,11 @@ export class LifecycleEngine {
|
|
|
203
322
|
* 注意:puhToolResult 出口已经登记过 mutation 本身,这里不再调 pushTool(避免 age 翻倍)。 */
|
|
204
323
|
pushMutation(history, mutationIdx, path) {
|
|
205
324
|
try {
|
|
325
|
+
const mutationPath = canonicalizePath(path);
|
|
326
|
+
if (!mutationPath) {
|
|
327
|
+
this.autoStubOrphans(history);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
206
330
|
// mutation 自身已在 pushToolResult 出口登记(若 lifecycle 存在);此处仅做「mutation 是
|
|
207
331
|
// path 的消费者」语义:把该 path 在 mutation 之前的所有 read_file(未被 stub 的 LIVE/REFERENCED)
|
|
208
332
|
// 标 REFERENCED。
|
|
@@ -220,7 +344,7 @@ export class LifecycleEngine {
|
|
|
220
344
|
if (c.startsWith('⌦['))
|
|
221
345
|
continue;
|
|
222
346
|
const argsRaw = this.findToolArgs(history, i);
|
|
223
|
-
if (extractPath(argsRaw) ===
|
|
347
|
+
if (canonicalizePath(extractPath(argsRaw)) === mutationPath) {
|
|
224
348
|
if (this.states.get(i) === 'LIVE')
|
|
225
349
|
this.states.set(i, 'REFERENCED');
|
|
226
350
|
this.consumerCount.set(i, (this.consumerCount.get(i) ?? 0) + 1);
|
|
@@ -233,27 +357,44 @@ export class LifecycleEngine {
|
|
|
233
357
|
// 永不抛错。
|
|
234
358
|
}
|
|
235
359
|
}
|
|
236
|
-
/**
|
|
360
|
+
/** 老化自动处理:
|
|
361
|
+
* - 非观察类 LIVE 且 age ≥ 阈值 → OBSOLETE → STUB(完全丢弃)。
|
|
362
|
+
* - 观察类工具两阶段衰减:
|
|
363
|
+
* Phase 1: LIVE → REFERENCED(age ≥ OBSERVER_REFERENCED_AGE,保留完整内容)。
|
|
364
|
+
* Phase 2: REFERENCED → DIGEST(增量 age ≥ OBSERVER_DIGEST_AGE,替换为摘要存根)。
|
|
365
|
+
* - 当前轮保护区(最后一个 user 之后)完全不动。
|
|
366
|
+
*/
|
|
237
367
|
autoStubOrphans(history) {
|
|
238
368
|
try {
|
|
239
369
|
const protectedFrom = Math.max(0, this.lastUser);
|
|
240
370
|
for (const [idx, state] of this.states) {
|
|
241
|
-
if (state !== 'LIVE')
|
|
242
|
-
continue;
|
|
243
371
|
if (idx >= protectedFrom)
|
|
244
372
|
continue; // 当前轮保护区
|
|
245
373
|
const age = this.age.get(idx) ?? 0;
|
|
246
|
-
if (age < this.ageThreshold)
|
|
247
|
-
continue;
|
|
248
374
|
const tn = toolNameOf(history, idx);
|
|
249
375
|
if (!tn)
|
|
250
376
|
continue;
|
|
251
|
-
// 观察类工具永远只到 REFERENCED,不自动 STUB(用户拍板)。
|
|
252
377
|
if (OBSERVER_TOOLS.has(tn)) {
|
|
253
|
-
|
|
378
|
+
// Phase 1: LIVE → REFERENCED(保留完整内容)。
|
|
379
|
+
if (state === 'LIVE' && age >= OBSERVER_REFERENCED_AGE) {
|
|
380
|
+
this.states.set(idx, 'REFERENCED');
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// Phase 2: REFERENCED → DIGEST(替换为摘要存根,状态不变)。
|
|
384
|
+
if (state === 'REFERENCED' && !this.isDigestFinalized(idx)) {
|
|
385
|
+
// read_file 可能在很早时就把 producer 标成 REFERENCED;不能因此跳过
|
|
386
|
+
// Phase 1 的保留窗口。摘要始终以 observer 自身的总 age 为准。
|
|
387
|
+
if (age >= OBSERVER_REFERENCED_AGE + OBSERVER_DIGEST_AGE) {
|
|
388
|
+
this.digestOne(history, idx, tn);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
254
391
|
continue;
|
|
255
392
|
}
|
|
256
|
-
//
|
|
393
|
+
// 非观察类:只在 LIVE 状态下老化 STUB。
|
|
394
|
+
if (state !== 'LIVE')
|
|
395
|
+
continue;
|
|
396
|
+
if (age < this.ageThreshold)
|
|
397
|
+
continue;
|
|
257
398
|
this.stubOne(history, idx, tn);
|
|
258
399
|
}
|
|
259
400
|
}
|
|
@@ -279,6 +420,115 @@ export class LifecycleEngine {
|
|
|
279
420
|
// 永不抛错。
|
|
280
421
|
}
|
|
281
422
|
}
|
|
423
|
+
/** 观察类工具 Phase 2:替换为摘要存根,保留文件列表+命中数+参数,丢弃详情。
|
|
424
|
+
* 幂等:已是 ⌦[ 前缀的跳过。states 保持 REFERENCED,只替换 content。 */
|
|
425
|
+
digestOne(history, idx, toolName) {
|
|
426
|
+
try {
|
|
427
|
+
const m = history[idx];
|
|
428
|
+
if (!m)
|
|
429
|
+
return;
|
|
430
|
+
const c = toText(m.content);
|
|
431
|
+
if (c.startsWith('⌦['))
|
|
432
|
+
return; // 幂等(已 STUB/DIGEST)
|
|
433
|
+
const origLen = c.length;
|
|
434
|
+
const argsRaw = this.findToolArgs(history, idx);
|
|
435
|
+
const summary = this.buildDigestSummary(toolName, c, argsRaw, origLen, extractProducerPaths(toolName, c));
|
|
436
|
+
// 摘要的唯一目标是降低上下文成本。短结果(如“未命中”)不能被固定文案放大。
|
|
437
|
+
if (summary.length >= origLen) {
|
|
438
|
+
this.digestRetainedIdxs.add(idx);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
m.content = summary;
|
|
442
|
+
// states 保持 REFERENCED;digestedIdxs 由 stats() 扣除显示。
|
|
443
|
+
this.digestedIdxs.add(idx);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// 永不抛错。
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** 为各观察类工具生成摘要字符串。只保留统计 + 参数,不保留详情(文件列表/URL/正文),
|
|
450
|
+
* 明确标注"这是历史摘要,需要最新信息请重新调用"。 */
|
|
451
|
+
buildDigestSummary(toolName, content, argsRaw, origLen, paths) {
|
|
452
|
+
const files = this.formatDigestPaths(paths);
|
|
453
|
+
switch (toolName) {
|
|
454
|
+
case 'grep': {
|
|
455
|
+
// 命中行数:content 里形如 `file:line:` 的行数
|
|
456
|
+
let lineCount = 0;
|
|
457
|
+
const re = /^[^\s:][^:]*?\.[A-Za-z0-9]+:\d+:/gm;
|
|
458
|
+
while (re.exec(content))
|
|
459
|
+
lineCount++;
|
|
460
|
+
const args = (() => { try {
|
|
461
|
+
const a = JSON.parse(argsRaw);
|
|
462
|
+
let s = `"${a.pattern ?? ''}"`;
|
|
463
|
+
if (a.glob)
|
|
464
|
+
s += `, glob="${a.glob}"`;
|
|
465
|
+
return s;
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return '...';
|
|
469
|
+
} })();
|
|
470
|
+
return `${DIGEST_PREFIX}grep(${args}) — 历史结果 ${lineCount} 行命中,文件 ${files},${origLen}→摘要]\n如需完整详情或最新信息请重新调用 grep`;
|
|
471
|
+
}
|
|
472
|
+
case 'glob': {
|
|
473
|
+
const args = (() => { try {
|
|
474
|
+
return `"${JSON.parse(argsRaw).pattern ?? ''}"`;
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return '...';
|
|
478
|
+
} })();
|
|
479
|
+
return `${DIGEST_PREFIX}glob(${args}) — 历史结果文件 ${files},${origLen}→摘要]\n如需完整列表或最新信息请重新调用 glob`;
|
|
480
|
+
}
|
|
481
|
+
case 'codegraph': {
|
|
482
|
+
const args = (() => { try {
|
|
483
|
+
const a = JSON.parse(argsRaw);
|
|
484
|
+
return `"${a.query ?? ''}"`;
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
return '...';
|
|
488
|
+
} })();
|
|
489
|
+
return `${DIGEST_PREFIX}codegraph(${args}) — 历史结果文件 ${files},${origLen}→摘要]\n如需完整详情或最新信息请重新调用 codegraph`;
|
|
490
|
+
}
|
|
491
|
+
case 'web_search': {
|
|
492
|
+
// 统计结果数
|
|
493
|
+
let resultCount = 0;
|
|
494
|
+
const lines = content.split(/\r?\n/);
|
|
495
|
+
for (const line of lines) {
|
|
496
|
+
if (/^\[\d+\]\s*.+/.test(line))
|
|
497
|
+
resultCount++;
|
|
498
|
+
}
|
|
499
|
+
const query = (() => { try {
|
|
500
|
+
return JSON.parse(argsRaw).query ?? '';
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return '';
|
|
504
|
+
} })();
|
|
505
|
+
return `${DIGEST_PREFIX}web_search("${query}") — 历史结果 ${resultCount} 条, ${origLen}→摘要]\n如需最新信息请重新调用 web_search`;
|
|
506
|
+
}
|
|
507
|
+
case 'web_fetch': {
|
|
508
|
+
const url = (() => { try {
|
|
509
|
+
return JSON.parse(argsRaw).url ?? '';
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return '';
|
|
513
|
+
} })();
|
|
514
|
+
return `${DIGEST_PREFIX}web_fetch(${url}) — 历史结果已归档, ${origLen}→摘要]\n如需最新信息请重新调用 web_fetch`;
|
|
515
|
+
}
|
|
516
|
+
default:
|
|
517
|
+
return `${DIGEST_PREFIX}${toolName} — 历史结果已归档, ${origLen}→摘要]\n如需最新信息请重新调用 ${toolName}`;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
isDigestFinalized(idx) {
|
|
521
|
+
return this.digestedIdxs.has(idx) || this.digestRetainedIdxs.has(idx);
|
|
522
|
+
}
|
|
523
|
+
/** 文件候选是摘要的关键可用信息;限量且去重,避免摘要本身重新膨胀。 */
|
|
524
|
+
formatDigestPaths(paths) {
|
|
525
|
+
const unique = [...new Set(paths)];
|
|
526
|
+
if (unique.length === 0)
|
|
527
|
+
return '0 个(未能从输出解析路径)';
|
|
528
|
+
const limit = 12;
|
|
529
|
+
const shown = unique.slice(0, limit).join(', ');
|
|
530
|
+
return unique.length > limit ? `${unique.length} 个 [${shown}, …]` : `${unique.length} 个 [${shown}]`;
|
|
531
|
+
}
|
|
282
532
|
/** 找某条 tool 消息对应的 assistant.tool_calls.arguments。 */
|
|
283
533
|
findToolArgs(history, idx) {
|
|
284
534
|
try {
|
|
@@ -305,7 +555,8 @@ export class LifecycleEngine {
|
|
|
305
555
|
getState(idx) {
|
|
306
556
|
return this.states.get(idx) ?? null;
|
|
307
557
|
}
|
|
308
|
-
/** 拿当前各状态计数。供 /context 显示「live=N, referenced=M,
|
|
558
|
+
/** 拿当前各状态计数。供 /context 显示「live=N, referenced=M, digest=K, obsolete=J, stubbed=S」。
|
|
559
|
+
* digested:已被摘要的观察类工具(states 仍为 REFERENCED,但 content 已替换为摘要)。 */
|
|
309
560
|
stats() {
|
|
310
561
|
let live = 0;
|
|
311
562
|
let referenced = 0;
|
|
@@ -321,10 +572,12 @@ export class LifecycleEngine {
|
|
|
321
572
|
else if (s === 'STUB')
|
|
322
573
|
stubbed++;
|
|
323
574
|
}
|
|
324
|
-
|
|
575
|
+
// digested 的 states 仍为 REFERENCED,从总 referenced 中扣除得到纯 REFERENCED 数。
|
|
576
|
+
return { live, referenced: referenced - this.digestedIdxs.size, digested: this.digestedIdxs.size, obsolete, stubbed };
|
|
325
577
|
}
|
|
326
578
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
579
|
+
export function createLifecycleEngine(historyOrThreshold, ageThreshold) {
|
|
580
|
+
const history = Array.isArray(historyOrThreshold) ? historyOrThreshold : undefined;
|
|
581
|
+
const threshold = typeof historyOrThreshold === 'number' ? historyOrThreshold : ageThreshold;
|
|
582
|
+
return new LifecycleEngine(threshold, history);
|
|
330
583
|
}
|
|
@@ -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 共享。 */
|
|
@@ -200,7 +173,10 @@ export function computePruneStats(history) {
|
|
|
200
173
|
if (m.role !== 'tool')
|
|
201
174
|
continue;
|
|
202
175
|
const c = toText(m.content);
|
|
203
|
-
|
|
176
|
+
// Relevance Pruner 的 stub(⌦[已过时:...) 和 Lifecycle Engine 的 DIGEST(⌦[摘要:...) 都统计。
|
|
177
|
+
const isPruneStub = c.startsWith(STUB_PREFIX);
|
|
178
|
+
const isDigest = c.startsWith('⌦[摘要:');
|
|
179
|
+
if (!isPruneStub && !isDigest)
|
|
204
180
|
continue;
|
|
205
181
|
stubbed++;
|
|
206
182
|
stubChars += c.length;
|
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
|