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.
- package/dist/agent/core.js +30 -46
- package/dist/agent/index.js +84 -53
- package/dist/config/index.js +0 -1
- package/dist/context/budget.js +22 -33
- package/dist/context/index.js +1 -1
- package/dist/context/lifecycle.js +1 -55
- package/dist/context/relevance.js +1 -62
- package/dist/context/utils.js +68 -0
- package/dist/repl/index.js +27 -21
- package/dist/rollback/index.js +1 -13
- package/dist/session/compact.js +9 -14
- package/dist/session/drop.js +1 -43
- package/dist/session/scheduler.js +3 -3
- package/dist/tools/builtins/todolist.js +2 -4
- package/dist/ui/batch.js +142 -207
- package/dist/ui/content.js +6 -0
- package/dist/ui/layout.js +29 -31
- package/dist/ui/markdown.js +5 -0
- package/package.json +1 -1
package/dist/agent/core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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)
|
|
324
|
-
//
|
|
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') {
|
package/dist/agent/index.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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,不写屏;
|
package/dist/config/index.js
CHANGED
|
@@ -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.
|
package/dist/context/budget.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 生成调度动作(
|
|
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
|
-
|
|
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 < -
|
|
200
|
-
if ((layers.history.overBudget || totalOver) && headroom < -
|
|
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
|
package/dist/context/index.js
CHANGED
|
@@ -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,
|
|
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。
|