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.
@@ -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, estimateMessagesTokens, estimateToolSchemaTokens, planChatTools, } from '../llm/index.js';
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, scheduler, runtimeContextState = contextState) {
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
- // optimizeToolResult:classifier encoder → encode(保不变量压缩) capToolResultForHistory 兜底。
112
- // tc.arguments 透传给 encoder(上下文感知编码,如 read_file 的 offset/limit)。永不抛错。
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 实例一个,步前 evaluateBudget + scheduleActions
196
- // 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
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
- // 步前:五区 Budget Scheduler 决策——按 ROI 调度(冷工具优先 / history 摘要最后)
240
- // 开关关闭(scheduler=null)时退化回原 maybeCompact 路径,零行为变化。
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 后,旧 lifecycle 的数字 index 已全部失效。
251
- // 立即从新 history 恢复状态和 producer 索引,再允许后续 pushTool/pushMutation 使用。
252
- if (historyRebuilt && lifecycle) {
253
- lifecycle = createLifecycleEngine(history);
254
- runtimeContextState.lifecycleStats = lifecycle.stats();
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
- // 每步读实时模式:LLM 可能在上一步调 switch_mode 切了模式,这里立即用对应工具集
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
- // 校正系数:API 实测 prompt_tokens / 估算 token。
282
- // 每次 chat 响应后刷新,让下次 evaluateBudget 用更接近真实的 actual。
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 = estimateMessagesTokens(history) + estimateToolSchemaTokens();
286
- if (estimated > 100) {
287
- const raw = result.usage.promptTokens / estimated;
288
- runtimeContextState.correction = Math.max(0.5, Math.min(2.0, raw));
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
+ }
@@ -1,30 +1,13 @@
1
1
  // 五区 Context Budget Scheduler。
2
2
  //
3
- // 目的:把当前四道独立闸(cap / pipeline / relevance / maybeCompact)统一为
4
- // 「先看预算报告,再按 ROI 排序调度」的单一入口。
3
+ // 目的:把当前请求拆成 System / History / Tool-Recent / Tool-Old / Summary + Reserve,
4
+ // 统一报告各区占用,并只调度执行层能够真正落地的 warn / compact_history。
5
5
  //
6
- // 设计原则(对应用户拍板的设计方案):
7
- // 1. 五区分账:不是把 history 当一个黑盒,而是把上下文切成 5 区逐区配预算
8
- // (System / History / Tool-Recent / Tool-Old / Summary) + 1 个 Reserve。
9
- // Tool 内部再分 Hot/Cold:
10
- // - Cold Tool(老化区,scheduler 优先压缩——ROI 最低,LLM 复述成本最低)
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
- /** 占比(总和 = 0.95,留 5% 给 Reserve)。对齐用户修正版:
39
- * Recent Tool 25%(原 40% 偏大,因 Hot 区不该被压)+ Old Tool 25% 同等 +
40
- * History 20% + System 15% + Summary 10%(平时 0 占用,触发后才用) */
41
- export const BUDGET_RATIO = {
42
- system: 0.15,
43
- history: 0.20,
44
- toolRecent: 0.25,
45
- toolOld: 0.25,
46
- summary: 0.10,
47
- reserve: 0.05,
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
- /** Hot/Cold 划分:当前 step 起往前 HOT_TURN_WINDOW 个 user turn 之内的工具结果视为 Hot,
50
- * 之外的视为 Cold。0 = 全 Cold(等同老路径);越短 Hot 越小,压缩越激进。 */
51
- export const HOT_TURN_WINDOW = 4;
52
- /** 工具消息推入历史后,经过的「消费者 push 次数」即 age。
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
- const c = m.content;
58
- const tcs = m.tool_calls;
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
- export function evaluateBudget(history, window, step = 0, correction = 1) {
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
- if (sysMsg)
93
- layers.system.actual = adj(msgTokens(sysMsg));
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
- // 安全裕量:用 0.82 而非 0.85,预留 3% 给 correction 波动与新消息增量。
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
- * - system warn(不压,配置问题不是内容问题)
154
- * - toolOld L1(中截超大)→ L2(same-path 已有 relevance)→ L3(age stub,新增)
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
- // 收紧:0.80 阈值(原 0.85)+ total * 1.05 放大(估算不确定性缓冲)
165
- const headroom = 0.80 * report.window - total * 1.05;
166
- // system → warn,不是 schedule 目标
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: `System prompt 超预算(${layers.system.actual} > ${layers.system.budget}),请检查配置/MOCODE.md`,
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
- // 渐进:toolOld(轻→重)
175
- if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.1) {
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 脚本用)。 */