mocode-ai 0.6.5 → 0.6.7
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 +54 -31
- package/dist/context/age-aware.js +136 -0
- package/dist/context/budget.js +56 -86
- package/dist/context/encoders/code.js +186 -60
- package/dist/context/encoders/command.js +201 -0
- package/dist/context/encoders/graph.js +69 -19
- package/dist/context/encoders/index.js +2 -2
- package/dist/context/encoders/log.js +2 -52
- package/dist/context/encoders/search.js +116 -50
- package/dist/context/index.js +1 -1
- package/dist/context/pipeline.js +5 -1
- package/dist/context/relevance.js +166 -121
- package/dist/context/token-calibration.js +104 -0
- package/dist/llm/index.js +42 -99
- package/dist/llm/think-filter.js +90 -0
- package/dist/repl/index.js +0 -2
- package/dist/session/compact.js +20 -23
- package/dist/session/index.js +3 -3
- package/dist/session/scheduler.js +18 -38
- package/dist/tools/builtins/run-command.js +29 -9
- package/dist/tools/constants.js +0 -17
- package/package.json +1 -1
package/dist/agent/core.js
CHANGED
|
@@ -5,19 +5,31 @@
|
|
|
5
5
|
// 与 index.ts 的关系:index.ts 的 runAgent = runAgentCore + TUI hooks 薄封装(行为不变)。
|
|
6
6
|
// spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
|
-
import { chat,
|
|
8
|
+
import { chat, estimatePromptTokens, planChatTools, chatTools, } from '../llm/index.js';
|
|
9
9
|
import { executeTool, tools } from '../tools/registry.js';
|
|
10
10
|
import { checkPermission } from '../permissions/index.js';
|
|
11
11
|
import { getPlanDisabledTools } from '../tools/constants.js';
|
|
12
12
|
import { getAgentMode, setAgentMode } from './mode.js';
|
|
13
13
|
import { maybeCompact, contextState, dropContextFromHistory } from '../session/index.js';
|
|
14
14
|
import { createBudgetScheduler } from '../session/scheduler.js';
|
|
15
|
-
import { optimizeToolResult } from '../context/index.js';
|
|
15
|
+
import { optimizeToolResult, HOT_TURN_WINDOW, userTurnBoundary } from '../context/index.js';
|
|
16
|
+
import { createAgeAwareEncodingState, } from '../context/age-aware.js';
|
|
16
17
|
import { createRelevancePruner } from '../context/relevance.js';
|
|
17
18
|
import { isToolResultSuccess } from '../context/utils.js';
|
|
18
19
|
import { config } from '../config/index.js';
|
|
19
20
|
import { jailResolve } from '../sandbox/index.js';
|
|
20
21
|
import { createLifecycleEngine } from '../context/lifecycle.js';
|
|
22
|
+
import { getTokenCalibration, updateTokenCalibration, } from '../context/token-calibration.js';
|
|
23
|
+
/** Stable per-history age state survives user turns; WeakMap avoids retaining closed sessions. */
|
|
24
|
+
const ageAwareStateByHistory = new WeakMap();
|
|
25
|
+
function ageAwareStateFor(history) {
|
|
26
|
+
const existing = ageAwareStateByHistory.get(history);
|
|
27
|
+
if (existing)
|
|
28
|
+
return existing;
|
|
29
|
+
const created = createAgeAwareEncodingState(history);
|
|
30
|
+
ageAwareStateByHistory.set(history, created);
|
|
31
|
+
return created;
|
|
32
|
+
}
|
|
21
33
|
/** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
|
|
22
34
|
function parseArgs(raw) {
|
|
23
35
|
try {
|
|
@@ -104,16 +116,17 @@ function readDiffContext(tc, parsed) {
|
|
|
104
116
|
* - lifecycle 也在每个 runAgentCore 实例化一次,登记 grep/glob/codegraph 等 producer
|
|
105
117
|
* 与 read/edit/write 的 consumer 关系;孤立+老化自动 STUB(观察类工具永不到 STUB)。
|
|
106
118
|
* - 开关关闭时 lifecycle=null 完全跳过。 */
|
|
107
|
-
function pushToolResult(history, tc, output, pruner, lifecycle,
|
|
119
|
+
function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runtimeContextState = contextState) {
|
|
120
|
+
const succeeded = isToolResultSuccess(output);
|
|
121
|
+
const ageAware = config.contextOptimize ? ageAwareStateFor(history) : null;
|
|
122
|
+
const encodingContext = ageAware?.preparePush(tc, succeeded);
|
|
108
123
|
const msg = {
|
|
109
124
|
role: 'tool',
|
|
110
125
|
tool_call_id: tc.id,
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
content: optimizeToolResult(tc.name, output, tc.arguments),
|
|
126
|
+
// 初次 push 始终保守(age=0);旧 Cold 结果在下一 step 的 sweep 中按类型降级。
|
|
127
|
+
content: optimizeToolResult(tc.name, output, tc.arguments, encodingContext),
|
|
114
128
|
};
|
|
115
129
|
history.push(msg);
|
|
116
|
-
const succeeded = isToolResultSuccess(output);
|
|
117
130
|
// 失败 read 不得淘汰旧 read;失败 consumer 也不能改变 lifecycle 上游状态。
|
|
118
131
|
if (pruner)
|
|
119
132
|
pruner.observePush(history, msg, succeeded);
|
|
@@ -192,11 +205,14 @@ export async function runAgentCore(opts) {
|
|
|
192
205
|
? createLifecycleEngine(history)
|
|
193
206
|
: null;
|
|
194
207
|
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
195
|
-
// 预算调度器:每个 runAgentCore
|
|
196
|
-
//
|
|
208
|
+
// 预算调度器:每个 runAgentCore 实例一个,在 age-aware sweep 后评估并执行 warn / compact。
|
|
209
|
+
// contextBudget 开关关闭时为 null。
|
|
197
210
|
const scheduler = config.contextBudget !== false
|
|
198
211
|
? createBudgetScheduler(runtimeContextState) // 在 step 循环之外实例化一次,跨步持有 lastRunLog
|
|
199
212
|
: null;
|
|
213
|
+
// age-aware encoder 与 history 数组同寿命;每轮重建一次以覆盖外部 /compact 等原地修改。
|
|
214
|
+
const ageAware = config.contextOptimize ? ageAwareStateFor(history) : null;
|
|
215
|
+
ageAware?.rehydrate(history);
|
|
200
216
|
// 本轮流式状态:首个正文 token 到达即停 spinner(思考期间 spinner 持续转「思考中…」,不写思考内容)。
|
|
201
217
|
let mode = 'idle';
|
|
202
218
|
let gotText = false;
|
|
@@ -227,6 +243,7 @@ export async function runAgentCore(opts) {
|
|
|
227
243
|
hooks.onAbort?.();
|
|
228
244
|
history.length = 0;
|
|
229
245
|
history.push(...savedHistory);
|
|
246
|
+
ageAware?.rehydrate(history);
|
|
230
247
|
setAgentMode(savedMode);
|
|
231
248
|
};
|
|
232
249
|
try {
|
|
@@ -236,22 +253,34 @@ export async function runAgentCore(opts) {
|
|
|
236
253
|
abortRestore();
|
|
237
254
|
return { completed: false, finalText: null };
|
|
238
255
|
}
|
|
239
|
-
//
|
|
240
|
-
|
|
256
|
+
// 本步只计算一次实际工具集合,调度、请求和 usage 校准必须使用完全相同的 schema。
|
|
257
|
+
const activeTools = opts.toolsOverride
|
|
258
|
+
?? (getAgentMode() === 'plan' ? planChatTools : chatTools);
|
|
259
|
+
const requestBaseURL = config.baseURL;
|
|
260
|
+
const requestModel = config.model;
|
|
261
|
+
const storedCalibration = getTokenCalibration(requestBaseURL, requestModel, activeTools);
|
|
262
|
+
runtimeContextState.correction = storedCalibration.correction;
|
|
263
|
+
runtimeContextState.calibrationSamples = storedCalibration.samples;
|
|
264
|
+
// 初次 tool push 只做保守编码;预算评估前先对 Cold 且 age 达阈值的旧结果降级,
|
|
265
|
+
// 避免 scheduler 根据马上会被 sweep 的陈旧占用误触发 history compact。
|
|
266
|
+
ageAware?.sweep(history, userTurnBoundary(history, HOT_TURN_WINDOW));
|
|
267
|
+
// 步前:五区 Budget Scheduler 在优化后的 history 上决策;开关关闭时退化回原 maybeCompact 路径。
|
|
241
268
|
// 此时 spinner 已停,通知行干净。
|
|
242
269
|
let historyRebuilt = false;
|
|
243
270
|
if (scheduler) {
|
|
244
|
-
historyRebuilt = await scheduler.runStep(history, step);
|
|
271
|
+
historyRebuilt = await scheduler.runStep(history, step, activeTools);
|
|
245
272
|
}
|
|
246
273
|
else {
|
|
247
|
-
const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState);
|
|
274
|
+
const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState, activeTools);
|
|
248
275
|
historyRebuilt = compactResult?.historyRebuilt === true;
|
|
249
276
|
}
|
|
250
|
-
// compact 用新消息数组原地重建 history
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
277
|
+
// compact 用新消息数组原地重建 history 后,所有按消息位置恢复的状态都需重建。
|
|
278
|
+
if (historyRebuilt) {
|
|
279
|
+
if (lifecycle) {
|
|
280
|
+
lifecycle = createLifecycleEngine(history);
|
|
281
|
+
runtimeContextState.lifecycleStats = lifecycle.stats();
|
|
282
|
+
}
|
|
283
|
+
ageAware?.rehydrate(history);
|
|
255
284
|
}
|
|
256
285
|
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
257
286
|
mode = 'idle';
|
|
@@ -259,11 +288,7 @@ export async function runAgentCore(opts) {
|
|
|
259
288
|
lastChar = '';
|
|
260
289
|
let result;
|
|
261
290
|
try {
|
|
262
|
-
|
|
263
|
-
// (auto=chatTools 全量;plan=planChatTools 只读子集)。模式由 src/agent/mode.ts 单一持有。
|
|
264
|
-
// 调用方可传 toolsOverride 覆盖(子 agent 受限工具子集)。
|
|
265
|
-
result = await chat(history, { onText, onToolCall }, signal, opts.toolsOverride ??
|
|
266
|
-
(getAgentMode() === 'plan' ? planChatTools : undefined));
|
|
291
|
+
result = await chat(history, { onText, onToolCall }, signal, activeTools);
|
|
267
292
|
}
|
|
268
293
|
catch (e) {
|
|
269
294
|
// 中断(用户运行中 Ctrl+C):chat() 抛 AbortError(signal.aborted)→ 还原 history + 模式 + return(不抛)。
|
|
@@ -278,15 +303,13 @@ export async function runAgentCore(opts) {
|
|
|
278
303
|
}
|
|
279
304
|
runtimeContextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
280
305
|
addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// 钳位 [0.5, 2.0]:防单次异常值(极短回复 / 空 history)导致系数跳变。
|
|
306
|
+
// 用本次实际发送的 tools 计算分母,再以 EWMA 更新 provider/model/tool-set 校准。
|
|
307
|
+
// 只持久化比例与样本数;无 usage 或短 prompt 时保持既有值。
|
|
284
308
|
if (result.usage?.promptTokens && result.usage.promptTokens > 100) {
|
|
285
|
-
const estimated =
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
309
|
+
const estimated = estimatePromptTokens(history, activeTools);
|
|
310
|
+
const updated = updateTokenCalibration(requestBaseURL, requestModel, activeTools, estimated, result.usage.promptTokens);
|
|
311
|
+
runtimeContextState.correction = updated.correction;
|
|
312
|
+
runtimeContextState.calibrationSamples = updated.samples;
|
|
290
313
|
}
|
|
291
314
|
hooks.onChatDone?.(); // 主 agent:spinner.stop()
|
|
292
315
|
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Age-aware tool-result encoding coordinator.
|
|
2
|
+
// Initial pushes stay conservative; old Cold results are re-encoded before chat.
|
|
3
|
+
import { TOOL_OLD_AGE } from './budget.js';
|
|
4
|
+
import { optimizeToolResult } from './pipeline.js';
|
|
5
|
+
import { canonicalizePath, extractPath, isToolResultSuccess, toText, } from './utils.js';
|
|
6
|
+
/**
|
|
7
|
+
* Tracks successful first reads and tool-result age without coupling encoders to
|
|
8
|
+
* lifecycle's mutable history indexes. All methods are fail-safe and idempotent.
|
|
9
|
+
*/
|
|
10
|
+
export class AgeAwareEncodingState {
|
|
11
|
+
pushOrdinal = 0;
|
|
12
|
+
records = new Map();
|
|
13
|
+
seenReadPaths = new Set();
|
|
14
|
+
constructor(history = []) {
|
|
15
|
+
this.rehydrate(history);
|
|
16
|
+
}
|
|
17
|
+
/** Build the conservative context for a newly completed tool result. */
|
|
18
|
+
preparePush(tc, succeeded) {
|
|
19
|
+
const path = tc.name === 'read_file'
|
|
20
|
+
? canonicalizePath(extractPath(tc.arguments))
|
|
21
|
+
: null;
|
|
22
|
+
const isFirstRead = path ? !this.seenReadPaths.has(path) : undefined;
|
|
23
|
+
this.records.set(tc.id, {
|
|
24
|
+
toolCallId: tc.id,
|
|
25
|
+
toolName: tc.name,
|
|
26
|
+
argsRaw: tc.arguments,
|
|
27
|
+
pushOrdinal: this.pushOrdinal,
|
|
28
|
+
succeeded,
|
|
29
|
+
isFirstRead,
|
|
30
|
+
agedEncoded: false,
|
|
31
|
+
});
|
|
32
|
+
this.pushOrdinal++;
|
|
33
|
+
// Failed reads must not consume the "first successful read" privilege.
|
|
34
|
+
if (succeeded && path)
|
|
35
|
+
this.seenReadPaths.add(path);
|
|
36
|
+
return {
|
|
37
|
+
age: 0,
|
|
38
|
+
isCold: false,
|
|
39
|
+
isFirstRead,
|
|
40
|
+
phase: 'push',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Re-encode eligible tool messages in the Cold prefix in place. */
|
|
44
|
+
sweep(history, hotBoundary) {
|
|
45
|
+
try {
|
|
46
|
+
const end = Math.min(Math.max(hotBoundary, 1), history.length);
|
|
47
|
+
for (let idx = 1; idx < end; idx++) {
|
|
48
|
+
const message = history[idx];
|
|
49
|
+
if (message.role !== 'tool')
|
|
50
|
+
continue;
|
|
51
|
+
const toolMessage = message;
|
|
52
|
+
const id = toolMessage.tool_call_id;
|
|
53
|
+
const record = id ? this.records.get(id) : undefined;
|
|
54
|
+
if (!record || !record.succeeded || record.agedEncoded)
|
|
55
|
+
continue;
|
|
56
|
+
const content = toText(toolMessage.content);
|
|
57
|
+
if (!content || content.startsWith('⌦[')) {
|
|
58
|
+
record.agedEncoded = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// Exclude the result's own push: immediately after insertion its age is 0.
|
|
62
|
+
const age = Math.max(0, this.pushOrdinal - record.pushOrdinal - 1);
|
|
63
|
+
if (age < TOOL_OLD_AGE)
|
|
64
|
+
continue;
|
|
65
|
+
const encoded = optimizeToolResult(record.toolName, content, record.argsRaw, {
|
|
66
|
+
age,
|
|
67
|
+
isCold: true,
|
|
68
|
+
isFirstRead: record.isFirstRead,
|
|
69
|
+
phase: 'sweep',
|
|
70
|
+
});
|
|
71
|
+
// Aged encoding is a degradation step: never replace content with a
|
|
72
|
+
// representation that is equal-sized or larger.
|
|
73
|
+
if (encoded.length < content.length)
|
|
74
|
+
toolMessage.content = encoded;
|
|
75
|
+
record.agedEncoded = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Context optimization must never block an agent request.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Rebuild stable state after resume or structural history compaction. */
|
|
83
|
+
rehydrate(history) {
|
|
84
|
+
this.pushOrdinal = 0;
|
|
85
|
+
this.records.clear();
|
|
86
|
+
this.seenReadPaths.clear();
|
|
87
|
+
try {
|
|
88
|
+
const calls = new Map();
|
|
89
|
+
for (const message of history) {
|
|
90
|
+
if (message.role === 'assistant') {
|
|
91
|
+
const toolCalls = message.tool_calls;
|
|
92
|
+
for (const tc of toolCalls ?? []) {
|
|
93
|
+
if (!tc.id || !tc.function?.name)
|
|
94
|
+
continue;
|
|
95
|
+
calls.set(tc.id, {
|
|
96
|
+
name: tc.function.name,
|
|
97
|
+
argsRaw: tc.function.arguments ?? '',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (message.role !== 'tool')
|
|
103
|
+
continue;
|
|
104
|
+
const toolMessage = message;
|
|
105
|
+
const id = toolMessage.tool_call_id;
|
|
106
|
+
const call = id ? calls.get(id) : undefined;
|
|
107
|
+
if (!id || !call)
|
|
108
|
+
continue;
|
|
109
|
+
const content = toText(toolMessage.content);
|
|
110
|
+
const succeeded = isToolResultSuccess(content);
|
|
111
|
+
const path = call.name === 'read_file'
|
|
112
|
+
? canonicalizePath(extractPath(call.argsRaw))
|
|
113
|
+
: null;
|
|
114
|
+
const isFirstRead = path ? !this.seenReadPaths.has(path) : undefined;
|
|
115
|
+
this.records.set(id, {
|
|
116
|
+
toolCallId: id,
|
|
117
|
+
toolName: call.name,
|
|
118
|
+
argsRaw: call.argsRaw,
|
|
119
|
+
pushOrdinal: this.pushOrdinal,
|
|
120
|
+
succeeded,
|
|
121
|
+
isFirstRead,
|
|
122
|
+
agedEncoded: false,
|
|
123
|
+
});
|
|
124
|
+
this.pushOrdinal++;
|
|
125
|
+
if (succeeded && path)
|
|
126
|
+
this.seenReadPaths.add(path);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// A partial rebuild is conservative: unknown records simply stay full.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export function createAgeAwareEncodingState(history = []) {
|
|
135
|
+
return new AgeAwareEncodingState(history);
|
|
136
|
+
}
|
package/dist/context/budget.js
CHANGED
|
@@ -1,30 +1,13 @@
|
|
|
1
1
|
// 五区 Context Budget Scheduler。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 目的:把当前请求拆成 System / History / Tool-Recent / Tool-Old / Summary + Reserve,
|
|
4
|
+
// 统一报告各区占用,并只调度执行层能够真正落地的 warn / compact_history。
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
// - Hot Tool(当前±N 步内,scheduler **不主动 stub**——避免干扰 agent 当前步)
|
|
12
|
-
// 注:**Hot 区「scheduler 不主动」≠ 「绝对不动」**。lifecycle age stub、pruner
|
|
13
|
-
// same-path 替代、agent 的 drop_context 工具仍可动 Hot 区;它们语义更精细(知道
|
|
14
|
-
// 哪条已无关),不会盲目 stub。Scheduler 只在更粗的层面决策,粗判断不踩精细判断。
|
|
15
|
-
// Hot 区超预算时 scheduler 仅 cap(降单条上限,不丢内容)。
|
|
16
|
-
// 2. ROI 排序:History > Summary > Hot Tool > Cold Tool。压缩时先动 Cold Tool
|
|
17
|
-
// (LLM 复述成本最低),再动 History(摘要成本高);Hot Tool 与 System 雷打不动(指 scheduler 层面)。
|
|
18
|
-
// 3. 零行为变化兜底:调度器不是闸,而是「报告 + 决策」——执行仍复用现有
|
|
19
|
-
// cap / pipeline / relevance / compact / lifecycle / drop_context 实现,只是触发条件更精准。
|
|
20
|
-
//
|
|
21
|
-
// 依赖:本文件是叶子级,只依赖 ChatMessage / estimateTokens,绝不反向依赖
|
|
22
|
-
// agent / session / compact(避免循环与耦合)。具体执行动作由 agent/core.ts
|
|
23
|
-
// 拿 ScheduleAction[] 去调现有闸。
|
|
24
|
-
//
|
|
25
|
-
// 开关(MOCODE_BUDGET_SCHEDULER):默认 true。false 时 agent/core.ts 走老路径
|
|
26
|
-
// (直接 maybeCompact),完全跳过本模块,零行为变化。
|
|
27
|
-
import { estimateMessagesTokens, estimateTokens } from '../llm/index.js';
|
|
6
|
+
// push-time cap、pipeline、relevance、lifecycle 与 age-aware sweep 负责工具结果优化;
|
|
7
|
+
// scheduler 在这些处理完成后评估,不重复生成 Cold/Hot tool 压缩动作。
|
|
8
|
+
// 本文件保持叶子级,只依赖 ChatMessage / token estimate,具体执行由 session/scheduler.ts 完成。
|
|
9
|
+
// contextBudget 开关关闭时,agent/core.ts 退化为直接调用 maybeCompact。
|
|
10
|
+
import { chatTools, estimateMessagesTokens, estimateToolSchemaTokens, messageTokens, } from '../llm/index.js';
|
|
28
11
|
import { toText } from './utils.js';
|
|
29
12
|
/** 五区分账(占比对齐 CONTEXT_WINDOW)。顺序固定,便于遍历。 */
|
|
30
13
|
export const BUDGET_LAYERS = [
|
|
@@ -35,33 +18,30 @@ export const BUDGET_LAYERS = [
|
|
|
35
18
|
'summary',
|
|
36
19
|
'reserve', // Reserve(不占内容,只占预算分配;5%)
|
|
37
20
|
];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
21
|
+
export const DEFAULT_BUDGET_POLICY = {
|
|
22
|
+
ratios: {
|
|
23
|
+
system: 0.15,
|
|
24
|
+
history: 0.20,
|
|
25
|
+
toolRecent: 0.25,
|
|
26
|
+
toolOld: 0.25,
|
|
27
|
+
summary: 0.10,
|
|
28
|
+
reserve: 0.05,
|
|
29
|
+
},
|
|
30
|
+
hotTurnWindow: 4,
|
|
31
|
+
toolOldAge: 2,
|
|
32
|
+
compactKeepRatio: 0.40,
|
|
33
|
+
totalTriggerRatio: 0.82,
|
|
34
|
+
schedulerTargetRatio: 0.80,
|
|
35
|
+
estimateSafetyFactor: 1.05,
|
|
36
|
+
compactHeadroomTokens: 1500,
|
|
48
37
|
};
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
export const HOT_TURN_WINDOW =
|
|
52
|
-
|
|
53
|
-
* Cold 区内:age ≥ TOOL_OLD_AGE 的非观察类工具结果可被调度器就地 stub。
|
|
54
|
-
* 默认 2 = 跨过 2 个消费者 push 仍未被消费,等同 lifecycle 的 DEFAULT_AGE_THRESHOLD。 */
|
|
55
|
-
export const TOOL_OLD_AGE = 2;
|
|
38
|
+
/** 兼容既有调用方的只读别名;配置只在 DEFAULT_BUDGET_POLICY 中维护。 */
|
|
39
|
+
export const BUDGET_RATIO = DEFAULT_BUDGET_POLICY.ratios;
|
|
40
|
+
export const HOT_TURN_WINDOW = DEFAULT_BUDGET_POLICY.hotTurnWindow;
|
|
41
|
+
export const TOOL_OLD_AGE = DEFAULT_BUDGET_POLICY.toolOldAge;
|
|
56
42
|
function msgTokens(m) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
let extra = toText(c);
|
|
60
|
-
if (tcs)
|
|
61
|
-
for (const tc of tcs)
|
|
62
|
-
extra += tc?.function?.arguments ?? '';
|
|
63
|
-
// 与 llm.estimateTokens 同公式(CJK 1/字,ASCII 1/4字);保证调度器评估与系统估算口径一致。
|
|
64
|
-
return 4 + estimateTokens(extra);
|
|
43
|
+
// 与请求预估复用同一实现,避免角色结构开销、多模态和 tool_calls 在两个预算路径中漂移。
|
|
44
|
+
return messageTokens(m);
|
|
65
45
|
}
|
|
66
46
|
/** 从 idx 处向前数第 N 个 user turn 的边界 index(含该 user 之后的内容)。
|
|
67
47
|
* 用于把 history 切成 Hot 区(tail 一段,endExclusive=history.length)与 Cold 区(0..endExclusive)。
|
|
@@ -79,8 +59,9 @@ export function userTurnBoundary(history, window) {
|
|
|
79
59
|
}
|
|
80
60
|
/** 评估当前 history 的五区预算(纯函数,改不动 history)。
|
|
81
61
|
* 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。
|
|
82
|
-
* correction:API 实测 / 估算的校正系数(默认 1);>1 表示粗估偏低,乘以系数后 actual 更接近真实值。
|
|
83
|
-
|
|
62
|
+
* correction:API 实测 / 估算的校正系数(默认 1);>1 表示粗估偏低,乘以系数后 actual 更接近真实值。
|
|
63
|
+
* activeTools 必须与下一次 chat() 实际发送的工具集合一致,避免 plan/子 agent 误算 schema。 */
|
|
64
|
+
export function evaluateBudget(history, window, step = 0, correction = 1, activeTools = chatTools) {
|
|
84
65
|
const layers = {};
|
|
85
66
|
for (const k of BUDGET_LAYERS) {
|
|
86
67
|
const budget = Math.floor(BUDGET_RATIO[k] * window);
|
|
@@ -89,8 +70,12 @@ export function evaluateBudget(history, window, step = 0, correction = 1) {
|
|
|
89
70
|
// 校正后的 token 数:raw * correction,最小 1(raw > 0 时)。
|
|
90
71
|
const adj = (raw) => (raw > 0 ? Math.max(1, Math.round(raw * correction)) : 0);
|
|
91
72
|
const sysMsg = history[0];
|
|
92
|
-
|
|
93
|
-
|
|
73
|
+
const systemCosts = {
|
|
74
|
+
prompt: sysMsg ? msgTokens(sysMsg) : 0,
|
|
75
|
+
toolSchemas: estimateToolSchemaTokens(activeTools),
|
|
76
|
+
};
|
|
77
|
+
// 工具 schema 与 system prompt 同属请求固定开销;必须计入总量才能可靠触发压缩。
|
|
78
|
+
layers.system.actual = adj(systemCosts.prompt + systemCosts.toolSchemas);
|
|
94
79
|
// Summary 检测:role:'system' 且不是 history[0] 的,视为摘要(compact.ts 摘要插 index 1)。
|
|
95
80
|
// 简单启发:若 history[1]?.role === 'system' 且 content 含「# 会话摘要」特征串,计入 summary。
|
|
96
81
|
// 命中时循环跳过 i=1;不命中时当作普通 message(罕见,落到下方 user/assistant 分支)。
|
|
@@ -135,61 +120,46 @@ export function evaluateBudget(history, window, step = 0, correction = 1) {
|
|
|
135
120
|
// 按 overRatio 降序
|
|
136
121
|
triggers.sort((a, b) => layers[b].overRatio - layers[a].overRatio);
|
|
137
122
|
const total = BUDGET_LAYERS.reduce((s, k) => s + (k === 'reserve' ? 0 : layers[k].actual), 0);
|
|
138
|
-
|
|
139
|
-
const totalOver = total >= 0.82 * window;
|
|
123
|
+
const totalOver = total >= DEFAULT_BUDGET_POLICY.totalTriggerRatio * window;
|
|
140
124
|
return {
|
|
141
125
|
step,
|
|
142
126
|
total,
|
|
143
127
|
window,
|
|
144
128
|
layers,
|
|
129
|
+
systemCosts,
|
|
145
130
|
triggers,
|
|
146
131
|
totalOver,
|
|
147
132
|
hotBoundary: hotStart,
|
|
148
133
|
correction,
|
|
149
134
|
};
|
|
150
135
|
}
|
|
151
|
-
/** 根据 BudgetReport
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* - toolRecent 超 → cap(只降低单条上限,不 stub)
|
|
156
|
-
* - history 超 或 totalOver → compact_history(调 maybeCompact / compactHistory)
|
|
157
|
-
* - summary 超 → 不动(摘要本身就压缩产物,删它等于丢历史,只能放任或扩 Recent 预算)
|
|
158
|
-
*
|
|
159
|
-
* 安全裕量:headroom 按 0.80 * window - total * 1.05 计算(预留 5% 应对 correction 误差
|
|
160
|
-
* 与新消息增量),避免估算偏差导致被 API 硬截断。 */
|
|
136
|
+
/** 根据 BudgetReport 生成可执行动作。
|
|
137
|
+
* push-time cap、relevance、lifecycle 与 age-aware sweep 已在评估前完成,
|
|
138
|
+
* 因此这里不再生成无法执行的 Cold/Hot tool action。
|
|
139
|
+
* History 或总量超预算时才考虑昂贵的 LLM 摘要。 */
|
|
161
140
|
export function scheduleActions(report) {
|
|
162
141
|
const actions = [];
|
|
163
142
|
const { layers, totalOver, total } = report;
|
|
164
|
-
|
|
165
|
-
const headroom =
|
|
166
|
-
|
|
143
|
+
const policy = DEFAULT_BUDGET_POLICY;
|
|
144
|
+
const headroom = policy.schedulerTargetRatio * report.window
|
|
145
|
+
- total * policy.estimateSafetyFactor;
|
|
167
146
|
if (layers.system.overBudget) {
|
|
147
|
+
const { prompt, toolSchemas } = report.systemCosts;
|
|
148
|
+
const { actual, budget } = layers.system;
|
|
149
|
+
const excess = actual - budget;
|
|
150
|
+
const percent = ((actual / Math.max(budget, 1)) * 100).toFixed(0);
|
|
168
151
|
actions.push({
|
|
169
152
|
kind: 'warn',
|
|
170
153
|
layer: 'system',
|
|
171
|
-
reason:
|
|
154
|
+
reason: `固定请求开销 ${actual}/${budget} tokens (+${excess}, ${percent}%);`
|
|
155
|
+
+ `系统提示 ${prompt} + 工具 schema ${toolSchemas},校正 ×${report.correction.toFixed(2)}。`
|
|
156
|
+
+ '系统提示偏高时检查 MOCODE.md;工具 schema 偏高时减少可用工具;CONTEXT_WINDOW_TOKENS 应匹配模型真实窗口。',
|
|
172
157
|
});
|
|
173
158
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
actions.push({ kind: 'shrink_cold_tools', level: 1 });
|
|
177
|
-
}
|
|
178
|
-
if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.3) {
|
|
179
|
-
actions.push({ kind: 'shrink_cold_tools', level: 2 });
|
|
180
|
-
}
|
|
181
|
-
if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.6) {
|
|
182
|
-
actions.push({ kind: 'shrink_cold_tools', level: 3 });
|
|
183
|
-
}
|
|
184
|
-
// Hot 区只 cap
|
|
185
|
-
if (layers.toolRecent.overBudget && layers.toolRecent.overRatio > 0.15) {
|
|
186
|
-
actions.push({ kind: 'cap_hot_tools', aggressive: layers.toolRecent.overRatio > 0.5 });
|
|
187
|
-
}
|
|
188
|
-
// History / total 超 → 摘要(最贵);headroom < -1500 真正触发(原 -2000,裕量收紧后同步调低),让 cold tools 先动
|
|
189
|
-
if ((layers.history.overBudget || totalOver) && headroom < -1500) {
|
|
159
|
+
if ((layers.history.overBudget || totalOver)
|
|
160
|
+
&& headroom < -policy.compactHeadroomTokens) {
|
|
190
161
|
actions.push({ kind: 'compact_history' });
|
|
191
162
|
}
|
|
192
|
-
// 排序(同 kind 已在上面排好):warn → cold L1→L2→L3 → cap_hot → compact_history
|
|
193
163
|
return actions;
|
|
194
164
|
}
|
|
195
165
|
/** 拍平成人类可读(供 /context 命令与 check-budget 脚本用)。 */
|