mocode-ai 0.5.4 → 0.5.5

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,7 +5,7 @@
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, planChatTools, } from '../llm/index.js';
8
+ import { chat, estimateMessagesTokens, estimateToolSchemaTokens, planChatTools, } 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';
@@ -17,7 +17,6 @@ import { createRelevancePruner } from '../context/relevance.js';
17
17
  import { config } from '../config/index.js';
18
18
  import { jailResolve } from '../sandbox/index.js';
19
19
  import { createLifecycleEngine } from '../context/lifecycle.js';
20
- import { hasActivePlan, getActivePlan } from '../plan/active.js';
21
20
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
22
21
  function parseArgs(raw) {
23
22
  try {
@@ -167,45 +166,11 @@ export async function runAgentCore(opts) {
167
166
  // Thrashing 检测:本轮内同 (name, args) 累计次数。≥3 在工具结果尾部追加 hint(见 thrashHint)。
168
167
  // 只在 runAgentCore 内,turn 结束自然 GC;不跨 turn 持久(下一轮重新计数,避免误把历史判为 thrashing)。
169
168
  const recentToolCalls = new Map();
170
- // Plan progress nudge:LLM 连续 ≥ PLAN_NUDGE_THRESHOLD 次工具调用没调 todolist → 注入提醒到结果末尾。
171
- // 让进度 chip 反映真实进展,而不是做完一大段后才一次性批量更新。
172
- // 计数器在任何 todolist 调用后重置;nudge 发出后也重置(避免后续每次工具调用都重复提醒)。
173
- let stepsSincePlanUpdate = 0;
174
- const PLAN_NUDGE_THRESHOLD = 2;
175
- // Plan nudge 节流:同一步骤只发一次,避免模型未响应时重复刷屏。
176
- // nudge 发出后置 -1;调 todolist 后重置为 0(可重新触发);中间步骤保持负值(不重复)。
177
- const planNudge = (name) => {
178
- if (name === 'todolist') {
179
- stepsSincePlanUpdate = 0;
180
- return null;
181
- }
182
- stepsSincePlanUpdate++;
183
- if (stepsSincePlanUpdate < PLAN_NUDGE_THRESHOLD)
184
- return null;
185
- if (stepsSincePlanUpdate > PLAN_NUDGE_THRESHOLD)
186
- return null; // 已发过,不再重复
187
- if (!hasActivePlan())
188
- return null;
189
- const p = getActivePlan();
190
- if (!p || p.status !== 'in_progress')
191
- return null;
192
- const hasPending = p.steps.some((s) => s.status === 'pending' || s.status === 'in_progress');
193
- if (!hasPending)
194
- return null;
195
- return ('\n\n[plan-nudge] ⚠ You\'ve run ' + PLAN_NUDGE_THRESHOLD +
196
- '+ tool calls since the last plan update. ' +
197
- 'Call `todolist` (action=update) NOW to mark the current step done and set the next step in_progress. ' +
198
- 'Do this BEFORE making more tool calls.');
199
- };
200
169
  const recordAndHint = (name, args) => {
201
170
  const fp = `${name}\x00${args}`;
202
171
  const c = (recentToolCalls.get(fp) ?? 0) + 1;
203
172
  recentToolCalls.set(fp, c);
204
- const thrash = thrashHint(name, args, c);
205
- const nudge = planNudge(name);
206
- if (!thrash && !nudge)
207
- return null;
208
- return [thrash, nudge].filter(Boolean).join('');
173
+ return thrashHint(name, args, c);
209
174
  };
210
175
  history.push({ role: 'user', content: userInput });
211
176
  // drop_context 工具的上下文剔除回调:闭包捕获 history,原地剔除无关旧 tool 结果。
@@ -242,6 +207,10 @@ export async function runAgentCore(opts) {
242
207
  const onToolCall = (name) => {
243
208
  // 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
244
209
  // 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
210
+ if (lastChar && lastChar !== '\n') {
211
+ hooks.onTextEnd?.(); // 主 agent:layout.contentWrite('\n')
212
+ lastChar = '\n';
213
+ }
245
214
  hooks.onToolCall?.(name); // 主 agent:spinner.start(`生成 ${name}…`)
246
215
  };
247
216
  // 中断还原:停 spinner + 补换行 + (已中断)提示 + history 还原到本 turn 前 + 模式还原。
@@ -293,12 +262,24 @@ export async function runAgentCore(opts) {
293
262
  }
294
263
  contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
295
264
  addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
265
+ // 校正系数:API 实测 prompt_tokens / 估算 token。
266
+ // 每次 chat 响应后刷新,让下次 evaluateBudget 用更接近真实的 actual。
267
+ // 钳位 [0.5, 2.0]:防单次异常值(极短回复 / 空 history)导致系数跳变。
268
+ if (result.usage?.promptTokens && result.usage.promptTokens > 100) {
269
+ const estimated = estimateMessagesTokens(history) + estimateToolSchemaTokens();
270
+ if (estimated > 100) {
271
+ const raw = result.usage.promptTokens / estimated;
272
+ contextState.correction = Math.max(0.5, Math.min(2.0, raw));
273
+ }
274
+ }
296
275
  hooks.onChatDone?.(); // 主 agent:spinner.stop()
297
276
  // lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
298
277
  onContextUpdate?.();
299
278
  if (result.toolCalls.length > 0) {
300
279
  hadToolsThisTurn = true;
301
- // 正文与工具批次的分隔由 onToolBatchStart 连同摘要一次性提交,避免两次写屏错位。
280
+ // 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
281
+ if (mode !== 'idle' && lastChar !== '\n')
282
+ hooks.onTextEnd?.();
302
283
  // 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
303
284
  history.push({
304
285
  role: 'assistant',
@@ -309,36 +290,39 @@ export async function runAgentCore(opts) {
309
290
  function: { name: tc.name, arguments: tc.arguments },
310
291
  })),
311
292
  });
312
- // 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)成组并发——一次性启动全部
313
- // executeTool(调用即开始 I/O),再按原顺序逐个 await + 渲染(● 头与 ↳ 结果紧邻,修并行时"全 ● 后全 ↳"分离)。
293
+ // 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)成组并发——先一次性
294
+ // 渲染全部 header,让摘要在任何同步工具真正执行前立即可见;随后启动全部 executeTool,
295
+ // 再按原顺序逐个 await + 回灌结果。
314
296
  // mutation(write_file/edit_file)及 run_command/use_skill 各为单步串行屏障——mutation 串行保
315
297
  // recordMutation 调用序 = 回滚快照序(executeTool 内写前记 before 快照,同文件多次写需按序)。
316
298
  // 渲染与 history 回灌一律按原顺序;并发只影响执行时序,tool_call_id 仍按序配对。
317
299
  // executeTool 永不抛错(调度器 try/catch 返字符串),故 await 单个 promise 不会抛(永远 resolve 为字符串)。
318
300
  const calls = result.toolCalls;
319
- hooks.onToolBatchStart?.(calls);
320
301
  let i = 0;
321
302
  while (i < calls.length) {
322
303
  if (READ_TOOL_NAMES.has(calls[i].name)) {
323
- // 收集连续只读组(≥1),并发执行:先一次性启动所有(executeTool 调用即开始 I/O),
324
- // 再按原顺序逐个 await + 渲染——● 头与 ↳ 结果紧邻、顺序 = tool_calls 序(修"全 ● 后全 ↳"分离 bug)。
304
+ // 收集连续只读组(≥1),并发执行:先渲染所有 header,再一次性启动所有
305
+ // (executeTool 调用即开始 I/O),最后按原顺序逐个 await + 回灌。
306
+ // 必须先 header 后 execute:todolist/grep 等同步快速工具会在 executeTool 返回 Promise 前
307
+ // 已经完成;若先 started.map,用户只能在工具完成后才看到摘要与其前面的换行。
325
308
  // 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
326
309
  let j = i;
327
310
  while (j < calls.length && READ_TOOL_NAMES.has(calls[j].name))
328
311
  j++;
329
312
  const batch = calls.slice(i, j);
313
+ for (const tc of batch)
314
+ hooks.onToolHeader?.(tc);
315
+ hooks.onToolStart?.(batch[0].name);
330
316
  const started = batch.map((tc) => executeTool(tc.name, tc.arguments, signal, { skipRollback, dropContext }));
331
317
  for (let k = 0; k < batch.length; k++) {
332
318
  const tc = batch[k];
333
- hooks.onToolHeader?.(tc);
334
- hooks.onToolStart?.(tc.name);
335
319
  const output = await started[k];
336
- hooks.onToolDone?.();
337
320
  hooks.onToolResult?.(tc, output, null, null, 1); // 只读工具无 diff
338
321
  // Thrashing:history 里附 hint(UI 已用干净 output 渲染,避免屏幕噪声)
339
322
  const hint = recordAndHint(tc.name, tc.arguments);
340
323
  pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
341
324
  }
325
+ hooks.onToolDone?.();
342
326
  i = j;
343
327
  }
344
328
  else if (calls[i].name === 'task') {
@@ -12,6 +12,8 @@ import { beginTurn } from '../rollback/index.js';
12
12
  import { config } from '../config/index.js';
13
13
  import { runAgentCore, isMutationTool, } from './core.js';
14
14
  import { createPetHooks } from '../pet/state.js';
15
+ /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
16
+ let currentBatchId = null;
15
17
  /** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
16
18
  function firstLineOf(ui) {
17
19
  if (typeof ui === 'string')
@@ -19,24 +21,24 @@ function firstLineOf(ui) {
19
21
  const first = ui.find((p) => p.type === 'text');
20
22
  return first?.text.split('\n')[0] ?? '';
21
23
  }
22
- /** 完整工具批次一拿到就聚合成一行写屏,结果随后回填到同一可展开项。 */
23
- function writeToolBatch(calls, leadingBlank) {
24
- const id = batch.beginBatch();
25
- for (const tc of calls) {
26
- batch.recordCall(id, tc.name, summarizeToolCall(tc.name, tc.arguments));
27
- }
28
- batch.showPendingBatch(id, layout, leadingBlank);
29
- return id;
30
- }
31
- function appendToolBatch(id, calls) {
32
- batch.appendPendingCalls(id, calls.map((tc) => ({
33
- name: tc.name,
34
- callSummary: summarizeToolCall(tc.name, tc.arguments),
35
- })), layout);
24
+ /** 工具调用 ● 头:工具名 + 参数摘要(按 tool_calls 原顺序打印,让用户看到本轮跑哪些工具)。
25
+ * 重构后改为累积到 BatchRenderer,onToolBatchEnd 时统一打摘要行;
26
+ * 展开/折叠由 BatchRenderer + 鼠标 release 决定,本函数不再直接写屏。 */
27
+ function writeToolHeader(tc) {
28
+ // 改文件工具是 batch 屏障:先收尾之前的普通工具,确保 mutation 永远独占一批。
29
+ if (isMutationTool(tc.name))
30
+ flushToolBatch();
31
+ if (!currentBatchId)
32
+ currentBatchId = batch.beginBatch();
33
+ batch.recordCall(currentBatchId, tc.name, summarizeToolCall(tc.name, tc.arguments));
34
+ // 第一条工具开始时立即落摘要;后续调用加入同一 batch,并原地刷新计数。
35
+ batch.showLiveBatch(currentBatchId, layout);
36
36
  }
37
37
  /** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮,仿 Claude Code);其余走一行 preview。
38
38
  * 同 writeToolHeader,改为累积到 BatchRenderer(只缓存字符串,不写屏)。 */
39
- function writeToolResult(batchId, tc, output, parsed, preWriteOld, editStartLine) {
39
+ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
40
+ if (!currentBatchId)
41
+ return;
40
42
  let diff = null;
41
43
  if (isMutationTool(tc.name) && parsed && !output.startsWith('错误')) {
42
44
  diff = renderFileChange({
@@ -50,7 +52,22 @@ function writeToolResult(batchId, tc, output, parsed, preWriteOld, editStartLine
50
52
  });
51
53
  }
52
54
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
53
- batch.completePendingBatch(batchId, tc.name, preview, diff, output, layout);
55
+ batch.recordResult(currentBatchId, tc.name, preview, diff, output);
56
+ // mutation 结果(成功 diff 或错误输出)立即可见,并阻止后续普通工具并入这一批。
57
+ if (isMutationTool(tc.name))
58
+ flushToolBatch(true);
59
+ }
60
+ /** 将跨 LLM 工具轮次累计的调用写入内容区;正文开始或整个 turn 收尾时才切批。 */
61
+ function flushToolBatch(expandSingleEntry = false) {
62
+ if (!currentBatchId)
63
+ return;
64
+ const id = currentBatchId;
65
+ currentBatchId = null;
66
+ batch.endBatch(id, layout);
67
+ if (expandSingleEntry)
68
+ batch.expandSingleEntryFully(id, layout);
69
+ // 摘要本身已有行尾;再补一行,保持工具与后续正文/状态摘要之间的原有间距。
70
+ layout.contentWrite('\n');
54
71
  }
55
72
  /**
56
73
  * agent 核心循环(主 agent,TUI 渲染版):
@@ -72,6 +89,7 @@ onContextUpdate) {
72
89
  // 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
73
90
  beginTurn(truncateDisplay(firstLineOf(userInput), 40));
74
91
  layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
92
+ currentBatchId = null; // 新 turn 清旧 batch id(防上 turn 残留)
75
93
  // spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
76
94
  // 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
77
95
  // 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
@@ -81,29 +99,44 @@ onContextUpdate) {
81
99
  // lastChar 镜像:core 跟踪流式末字符决定补换行,但 TUI hooks 需读它决定 layout.contentWrite('\n')。
82
100
  // core 的 onTextEnd hook 只在 lastChar !== '\n' 时才调,调后置 '\n';镜像与此同步。
83
101
  let lastChar = '';
84
- let visibleBlock = 'none';
85
- let currentBatchId = null;
102
+ // 正文 -> 工具的边界由 core.onTextEnd 与本层 onToolCall 分两段完成。
103
+ // markdown 段末已经是完整物理行,因此再写 1 个 \n 就代表 1 条空白行;
104
+ // 不能按普通字符串的“两个换行才有一个空行”来计算。
105
+ let textBoundaryNewlines = 0;
106
+ let hasPendingTextBoundary = false;
86
107
  const hooks = {
87
108
  onText: (s) => {
109
+ // 纯空白 chunk 在视觉上不是正文:既不切 batch,也不写入 markdown 缓冲。
110
+ // 部分兼容后端会在连续工具轮次间流出 " " / "\n",若据此切批会漏掉首个工具。
111
+ if (currentBatchId && s.trim().length === 0)
112
+ return;
113
+ const followsToolBatch = currentBatchId !== null;
114
+ // batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
115
+ // 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
116
+ const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
117
+ if (s)
118
+ flushToolBatch();
88
119
  spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
89
- const hasVisibleText = s.trim().length > 0;
90
- let expandedBatchHasBlankTail = false;
91
- if (hasVisibleText && currentBatchId) {
92
- batch.finalizePendingBatch(currentBatchId, layout);
93
- // 展开详情通过中段插入,原摘要后的空行会保留在详情尾部;无需再补一次。
94
- expandedBatchHasBlankTail = batch.isExpanded(currentBatchId);
95
- currentBatchId = null;
120
+ layout.contentWriteMd(visible); // 正文走 markdown 渲染(代码块高亮 / 标题 / 列表 / 行内 …),见 ui/markdown.ts
121
+ if (visible) {
122
+ lastChar = visible[visible.length - 1];
123
+ if (visible.trim().length > 0) {
124
+ hasPendingTextBoundary = true;
125
+ textBoundaryNewlines = 0;
126
+ }
96
127
  }
97
- const leadingBlank = hasVisibleText && visibleBlock === 'tool' && !expandedBatchHasBlankTail;
98
- layout.contentWriteMd(s, leadingBlank); // 分隔与正文同次重绘,避免续写位二次定位多出空行
99
- if (hasVisibleText)
100
- visibleBlock = 'text';
101
- if (s)
102
- lastChar = s[s.length - 1];
103
128
  },
104
129
  onToolCall: (name) => {
105
130
  // 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
106
131
  // 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
132
+ if (hasPendingTextBoundary) {
133
+ if (textBoundaryNewlines < 1) {
134
+ layout.contentWrite('\n');
135
+ }
136
+ lastChar = '\n';
137
+ textBoundaryNewlines = 1;
138
+ hasPendingTextBoundary = false;
139
+ }
107
140
  if (name)
108
141
  spinner.start(`生成 ${name}`);
109
142
  },
@@ -113,42 +146,40 @@ onContextUpdate) {
113
146
  if (lastChar && lastChar !== '\n') {
114
147
  layout.contentWrite('\n');
115
148
  lastChar = '\n';
149
+ if (hasPendingTextBoundary)
150
+ textBoundaryNewlines = 1;
116
151
  }
117
152
  },
118
- onToolBatchStart: (calls) => {
119
- if (currentBatchId)
120
- appendToolBatch(currentBatchId, calls);
121
- else
122
- currentBatchId = writeToolBatch(calls, visibleBlock !== 'none');
123
- visibleBlock = 'tool';
124
- },
125
- onToolHeader: () => { },
153
+ onToolHeader: (tc) => writeToolHeader(tc),
126
154
  onToolStart: (name) => spinner.start(`执行 ${name}`),
127
155
  onToolDone: () => spinner.stop(),
128
- onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => {
129
- if (currentBatchId) {
130
- writeToolResult(currentBatchId, tc, output, parsed, preWriteOld, editStartLine);
131
- }
156
+ onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine),
157
+ onToolBatchEnd: () => {
158
+ // 一次工具轮次结束不再切 UI batch;下一轮若仍无正文,继续复用 currentBatchId。
159
+ },
160
+ onNoReply: () => {
161
+ flushToolBatch();
162
+ layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`);
163
+ },
164
+ onMaxSteps: () => {
165
+ flushToolBatch();
166
+ layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`);
132
167
  },
133
- // 连续无正文的工具步骤保持同一 batch;遇到正文或本轮结束时才收尾。
134
- onToolBatchEnd: () => { },
135
- onNoReply: () => layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`),
136
- onMaxSteps: () => layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`),
137
168
  onAbort: () => {
138
169
  spinner.stop();
139
- if (currentBatchId)
140
- batch.finalizePendingBatch(currentBatchId, layout);
141
- currentBatchId = null;
142
170
  if (lastChar && lastChar !== '\n')
143
171
  layout.contentWrite('\n');
172
+ flushToolBatch();
144
173
  layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
145
174
  },
146
175
  onDone: (elapsedMs, usage) => {
147
- if (currentBatchId)
148
- batch.finalizePendingBatch(currentBatchId, layout);
149
- currentBatchId = null;
176
+ flushToolBatch();
150
177
  const tok = formatTurnTokens(usage);
151
178
  layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${tok}${ui.reset}\n`);
179
+ // 内容区触底时,DECSTBM 增量滚屏可能只推进物理终端,未把 Worked 前已在
180
+ // buffer 中的空行完整画出来;用户滚动/点击触发 repaint 后才“突然”出现。
181
+ // 轮次收尾立即按 buffer 原子重画,使未满屏与触底滚屏的布局一致。
182
+ layout.repaintViewport();
152
183
  },
153
184
  };
154
185
  // 桌宠状态广播:与 TUI hooks 并列注入,互不干扰(petHooks 只调 bridge.sendState,不写屏;
@@ -223,7 +223,6 @@ ${buildSnapshotSection()}${config.projectSkillEnabled ? buildProjectSkillSection
223
223
 
224
224
  ## Working notepad (todolist)
225
225
  - For genuinely complex tasks only: explore codebase → clarify with user → create plan → execute step by step. See tool description for details.
226
- - **Update the plan after each step** — do NOT batch all status updates at the end. The progress chip must reflect real-time state.
227
226
 
228
227
  ## Termination & Reporting
229
228
  - Stop immediately when no more tools are needed; give conclusions directly.
@@ -25,6 +25,7 @@
25
25
  // 开关(MOCODE_BUDGET_SCHEDULER):默认 true。false 时 agent/core.ts 走老路径
26
26
  // (直接 maybeCompact),完全跳过本模块,零行为变化。
27
27
  import { estimateMessagesTokens, estimateTokens } from '../llm/index.js';
28
+ import { toText } from './utils.js';
28
29
  /** 五区分账(占比对齐 CONTEXT_WINDOW)。顺序固定,便于遍历。 */
29
30
  export const BUDGET_LAYERS = [
30
31
  'system',
@@ -52,19 +53,6 @@ export const HOT_TURN_WINDOW = 4;
52
53
  * Cold 区内:age ≥ TOOL_OLD_AGE 的非观察类工具结果可被调度器就地 stub。
53
54
  * 默认 2 = 跨过 2 个消费者 push 仍未被消费,等同 lifecycle 的 DEFAULT_AGE_THRESHOLD。 */
54
55
  export const TOOL_OLD_AGE = 2;
55
- /** 把每条 token 拍平成字符串(只估 token,不深解析工具调用)。 */
56
- function toText(content) {
57
- if (content == null)
58
- return '';
59
- if (typeof content === 'string')
60
- return content;
61
- try {
62
- return JSON.stringify(content);
63
- }
64
- catch {
65
- return String(content);
66
- }
67
- }
68
56
  function msgTokens(m) {
69
57
  const c = m.content;
70
58
  const tcs = m.tool_calls;
@@ -75,14 +63,6 @@ function msgTokens(m) {
75
63
  // 与 llm.estimateTokens 同公式(CJK 1/字,ASCII 1/4字);保证调度器评估与系统估算口径一致。
76
64
  return 4 + estimateTokens(extra);
77
65
  }
78
- /** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。 */
79
- export function lastUserIndex(history) {
80
- for (let i = history.length - 1; i >= 1; i--) {
81
- if (history[i].role === 'user')
82
- return i;
83
- }
84
- return -1;
85
- }
86
66
  /** 从 idx 处向前数第 N 个 user turn 的边界 index(含该 user 之后的内容)。
87
67
  * 用于把 history 切成 Hot 区(tail 一段,endExclusive=history.length)与 Cold 区(0..endExclusive)。
88
68
  * 若 N 个 user 不足,Hot 区 = history.length(全保护);Cold 区空,无压缩目标。 */
@@ -98,16 +78,19 @@ export function userTurnBoundary(history, window) {
98
78
  return 1; // 没攒够 N 个 user 之前的全归 Cold(history[0] system 不动)
99
79
  }
100
80
  /** 评估当前 history 的五区预算(纯函数,改不动 history)。
101
- * 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。 */
102
- export function evaluateBudget(history, window, step = 0) {
81
+ * 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。
82
+ * correction:API 实测 / 估算的校正系数(默认 1);>1 表示粗估偏低,乘以系数后 actual 更接近真实值。 */
83
+ export function evaluateBudget(history, window, step = 0, correction = 1) {
103
84
  const layers = {};
104
85
  for (const k of BUDGET_LAYERS) {
105
86
  const budget = Math.floor(BUDGET_RATIO[k] * window);
106
87
  layers[k] = { actual: 0, budget, overBudget: false, overRatio: 0 };
107
88
  }
89
+ // 校正后的 token 数:raw * correction,最小 1(raw > 0 时)。
90
+ const adj = (raw) => (raw > 0 ? Math.max(1, Math.round(raw * correction)) : 0);
108
91
  const sysMsg = history[0];
109
92
  if (sysMsg)
110
- layers.system.actual = msgTokens(sysMsg);
93
+ layers.system.actual = adj(msgTokens(sysMsg));
111
94
  // Summary 检测:role:'system' 且不是 history[0] 的,视为摘要(compact.ts 摘要插 index 1)。
112
95
  // 简单启发:若 history[1]?.role === 'system' 且 content 含「# 会话摘要」特征串,计入 summary。
113
96
  // 命中时循环跳过 i=1;不命中时当作普通 message(罕见,落到下方 user/assistant 分支)。
@@ -115,7 +98,7 @@ export function evaluateBudget(history, window, step = 0) {
115
98
  if (history.length > 1 && history[1].role === 'system') {
116
99
  const c1 = toText(history[1].content);
117
100
  if (c1.startsWith('# 会话摘要') || c1.includes('会话摘要')) {
118
- layers.summary.actual = msgTokens(history[1]);
101
+ layers.summary.actual = adj(msgTokens(history[1]));
119
102
  summaryHit = true;
120
103
  }
121
104
  }
@@ -127,7 +110,7 @@ export function evaluateBudget(history, window, step = 0) {
127
110
  if (i === 1 && summaryHit)
128
111
  continue; // summary 已单独算过
129
112
  if (m.role === 'tool') {
130
- const t = msgTokens(m);
113
+ const t = adj(msgTokens(m));
131
114
  if (i >= hotStart)
132
115
  layers.toolRecent.actual += t;
133
116
  else
@@ -135,7 +118,7 @@ export function evaluateBudget(history, window, step = 0) {
135
118
  }
136
119
  else if (m.role !== 'system') {
137
120
  // user / assistant 全部计入 history(对话轨迹)
138
- layers.history.actual += msgTokens(m);
121
+ layers.history.actual += adj(msgTokens(m));
139
122
  }
140
123
  // 其它 system(几乎不存在)跳过
141
124
  }
@@ -152,7 +135,8 @@ export function evaluateBudget(history, window, step = 0) {
152
135
  // 按 overRatio 降序
153
136
  triggers.sort((a, b) => layers[b].overRatio - layers[a].overRatio);
154
137
  const total = BUDGET_LAYERS.reduce((s, k) => s + (k === 'reserve' ? 0 : layers[k].actual), 0);
155
- const totalOver = total >= 0.85 * window;
138
+ // 安全裕量:用 0.82 而非 0.85,预留 3% 给 correction 波动与新消息增量。
139
+ const totalOver = total >= 0.82 * window;
156
140
  return {
157
141
  step,
158
142
  total,
@@ -161,19 +145,24 @@ export function evaluateBudget(history, window, step = 0) {
161
145
  triggers,
162
146
  totalOver,
163
147
  hotBoundary: hotStart,
148
+ correction,
164
149
  };
165
150
  }
166
- /** 根据 BudgetReport 生成调度动作(从轻到重,直至总占用回落到 0.85 以下)。
151
+ /** 根据 BudgetReport 生成调度动作(从轻到重,直至总占用回落到阈值以下)。
167
152
  * 规则:
168
153
  * - system 超 → warn(不压,配置问题不是内容问题)
169
154
  * - toolOld 超 → 先 L1(中截超大)→ L2(same-path 已有 relevance)→ L3(age stub,新增)
170
155
  * - toolRecent 超 → cap(只降低单条上限,不 stub)
171
156
  * - history 超 或 totalOver → compact_history(调 maybeCompact / compactHistory)
172
- * - summary 超 → 不动(摘要本身就压缩产物,删它等于丢历史,只能放任或扩 Recent 预算) */
157
+ * - summary 超 → 不动(摘要本身就压缩产物,删它等于丢历史,只能放任或扩 Recent 预算)
158
+ *
159
+ * 安全裕量:headroom 按 0.80 * window - total * 1.05 计算(预留 5% 应对 correction 误差
160
+ * 与新消息增量),避免估算偏差导致被 API 硬截断。 */
173
161
  export function scheduleActions(report) {
174
162
  const actions = [];
175
163
  const { layers, totalOver, total } = report;
176
- const headroom = 0.85 * report.window - total;
164
+ // 收紧:0.80 阈值(原 0.85)+ total * 1.05 放大(估算不确定性缓冲)
165
+ const headroom = 0.80 * report.window - total * 1.05;
177
166
  // system 超 → warn,不是 schedule 目标
178
167
  if (layers.system.overBudget) {
179
168
  actions.push({
@@ -196,8 +185,8 @@ export function scheduleActions(report) {
196
185
  if (layers.toolRecent.overBudget && layers.toolRecent.overRatio > 0.15) {
197
186
  actions.push({ kind: 'cap_hot_tools', aggressive: layers.toolRecent.overRatio > 0.5 });
198
187
  }
199
- // History / total 超 → 摘要(最贵);headroom < -2K 真正触发,让 cold tools 先动
200
- if ((layers.history.overBudget || totalOver) && headroom < -2000) {
188
+ // History / total 超 → 摘要(最贵);headroom < -1500 真正触发(原 -2000,裕量收紧后同步调低),让 cold tools 先动
189
+ if ((layers.history.overBudget || totalOver) && headroom < -1500) {
201
190
  actions.push({ kind: 'compact_history' });
202
191
  }
203
192
  // 排序(同 kind 已在上面排好):warn → cold L1→L2→L3 → cap_hot → compact_history
@@ -10,4 +10,4 @@ export { optimizeToolResult } from './pipeline.js';
10
10
  export { classify, knownToolKinds } from './classifier.js';
11
11
  export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
12
12
  // ── Context Budget Scheduler ───────────────────────────────────────────────
13
- export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, lastUserIndex, BUDGET_LAYERS, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
13
+ export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
@@ -26,6 +26,7 @@
26
26
  // - mutation 分支额外调 pushMutation 通知(也走 observeMutation 同语义)。
27
27
  //
28
28
  // 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
29
+ import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
29
30
  /** 观察类工具(永远只到 REFERENCED,不参与自动 STUB)。 */
30
31
  const OBSERVER_TOOLS = new Set([
31
32
  'grep',
@@ -45,61 +46,6 @@ const STUB_PREFIX_NO_CONSUMER = '⌦[无消费者:观察结果已无引用价值
45
46
  * ≥ 这个值且仍为 LIVE 且非观察类 → 视为 OBSOLETE → STUB。
46
47
  * 默认 2:等价于「跨过两个消费者 push 仍无人引用」= 跨过整轮最末尾的工具调用。 */
47
48
  const DEFAULT_AGE_THRESHOLD = 2;
48
- /** 复用 drop.ts 的取 path 思路,但本层要支持更多字段名(read_file/edit_file/write_file 都有 path;
49
- * edit_file 还可能有 file_path,但这里只看 path,保持单一)。 */
50
- function extractPath(argsRaw) {
51
- if (!argsRaw)
52
- return null;
53
- let parsed;
54
- try {
55
- parsed = JSON.parse(argsRaw);
56
- }
57
- catch {
58
- return null;
59
- }
60
- if (!parsed || typeof parsed !== 'object')
61
- return null;
62
- const p = parsed.path;
63
- return typeof p === 'string' && p ? p : null;
64
- }
65
- /** 从 tool 消息往前找匹配的 assistant.tool_calls 拿 tool 名。找不到返 null(保守跳过)。 */
66
- function toolNameOf(history, idx) {
67
- const tcId = history[idx].tool_call_id;
68
- if (!tcId)
69
- return null;
70
- for (let j = idx - 1; j >= 1; j--) {
71
- const m = history[j];
72
- if (m.role !== 'assistant')
73
- continue;
74
- const tcs = m.tool_calls;
75
- if (!tcs)
76
- continue;
77
- const hit = tcs.find((tc) => tc?.id === tcId);
78
- if (hit)
79
- return hit.function?.name ?? null;
80
- }
81
- return null;
82
- }
83
- /** 找最后一个 user 消息索引;无 user 返 -1。 */
84
- function lastUserIndex(history) {
85
- for (let i = history.length - 1; i >= 1; i--) {
86
- if (history[i].role === 'user')
87
- return i;
88
- }
89
- return -1;
90
- }
91
- function toText(content) {
92
- if (content == null)
93
- return '';
94
- if (typeof content === 'string')
95
- return content;
96
- try {
97
- return JSON.stringify(content);
98
- }
99
- catch {
100
- return String(content);
101
- }
102
- }
103
49
  /** 从 tool 结果的 content 中提取「生产者命中过的 path 列表」。
104
50
  * - read_file:没有 path 列表(本身就是单 path 消费者,无需再追生产者)。
105
51
  * - grep:content 是 `file:line: ...` 行,提取每行的 file 段(只保留绝对路径形态或与 pattern 匹配的)。
@@ -22,71 +22,10 @@
22
22
  // - TUI 渲染(hooks.onToolResult)用原始 output,与本层解耦——屏上看全量,LLM 看裁剪后版。
23
23
  //
24
24
  // 零行为变化兜底:开关 `config.contextRelprune` 关闭时,pipeline 路径完全不调本模块。
25
+ import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
25
26
  /** stub 标记前缀(供幂等判定)。drop_context 用的是「⌦[已剔除:与当前任务无关]」,
26
27
  * 本层用「⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]」,区分两类剔除来源。 */
27
28
  const STUB_PREFIX = '⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]';
28
- /** 解析工具 arguments(只关心 path);非法返 null。 */
29
- function extractPath(argsRaw) {
30
- if (!argsRaw)
31
- return null;
32
- let parsed;
33
- try {
34
- parsed = JSON.parse(argsRaw);
35
- }
36
- catch {
37
- return null;
38
- }
39
- if (!parsed || typeof parsed !== 'object')
40
- return null;
41
- const p = parsed.path;
42
- if (typeof p !== 'string' || !p)
43
- return null;
44
- return p;
45
- }
46
- /** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
47
- * 复用 drop.ts 的思路:user 及其之后的 tool 结果视为当前轮保护区。
48
- * 在本层里,read_file tool 消息若落在 user 之后,本轮还在用,不能 stub。
49
- * 注:history[0] 是 system,user 不会落在 0;若 user 就在末尾(即 0 user 之后),
50
- * protectedFrom=0 时整段历史都不可 stub(实际不会发生:pushToolResult 必在 user 之后)。 */
51
- function lastUserIndex(history) {
52
- for (let i = history.length - 1; i >= 1; i--) {
53
- if (history[i].role === 'user')
54
- return i;
55
- }
56
- return -1;
57
- }
58
- /** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。
59
- * 返回 null 表示找不到(孤儿 tool,极少见),本层保守不动。 */
60
- function toolNameOf(history, idx) {
61
- const tcId = history[idx].tool_call_id;
62
- if (!tcId)
63
- return null;
64
- for (let j = idx - 1; j >= 1; j--) {
65
- const m = history[j];
66
- if (m.role !== 'assistant')
67
- continue;
68
- const tcs = m.tool_calls;
69
- if (!tcs)
70
- continue;
71
- const hit = tcs.find((tc) => tc?.id === tcId);
72
- if (hit)
73
- return hit.function?.name ?? null;
74
- }
75
- return null;
76
- }
77
- /** 把 content 拍平成字符串(对齐 drop.ts)。 */
78
- function toText(content) {
79
- if (content == null)
80
- return '';
81
- if (typeof content === 'string')
82
- return content;
83
- try {
84
- return JSON.stringify(content);
85
- }
86
- catch {
87
- return String(content);
88
- }
89
- }
90
29
  /**
91
30
  * 维护「path → 该 path 所有 read_file tool 消息的 history index」映射。
92
31
  * - observePush:把刚 push 的 read_file tool 消息登记,并把同 path 的"更早" read 全部 stub。