mocode-ai 0.5.4 → 0.5.6
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 +34 -49
- package/dist/agent/index.js +96 -52
- package/dist/agent/spawn.js +7 -2
- 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/project-skill/initializer.js +83 -83
- package/dist/project-snapshot/llm-snapshot.js +69 -69
- package/dist/repl/index.js +28 -22
- package/dist/rollback/index.js +1 -13
- package/dist/session/compact.js +20 -24
- package/dist/session/drop.js +1 -43
- package/dist/session/index.js +1 -1
- package/dist/session/scheduler.js +8 -8
- package/dist/tools/builtins/todolist.js +2 -4
- package/dist/ui/batch.js +142 -207
- package/dist/ui/content.js +22 -0
- package/dist/ui/layout.js +55 -37
- package/dist/ui/markdown.js +8 -2
- 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 {
|
|
@@ -139,6 +138,7 @@ function pushToolResult(history, tc, output, pruner, lifecycle, scheduler) {
|
|
|
139
138
|
*/
|
|
140
139
|
export async function runAgentCore(opts) {
|
|
141
140
|
const { history, userInput, signal, onContextUpdate, hooks, skipRollback } = opts;
|
|
141
|
+
const runtimeContextState = opts.contextState ?? contextState;
|
|
142
142
|
const maxSteps = opts.maxSteps ?? config.maxSteps;
|
|
143
143
|
// 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
|
|
144
144
|
// 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
|
|
@@ -167,45 +167,11 @@ export async function runAgentCore(opts) {
|
|
|
167
167
|
// Thrashing 检测:本轮内同 (name, args) 累计次数。≥3 在工具结果尾部追加 hint(见 thrashHint)。
|
|
168
168
|
// 只在 runAgentCore 内,turn 结束自然 GC;不跨 turn 持久(下一轮重新计数,避免误把历史判为 thrashing)。
|
|
169
169
|
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
170
|
const recordAndHint = (name, args) => {
|
|
201
171
|
const fp = `${name}\x00${args}`;
|
|
202
172
|
const c = (recentToolCalls.get(fp) ?? 0) + 1;
|
|
203
173
|
recentToolCalls.set(fp, c);
|
|
204
|
-
|
|
205
|
-
const nudge = planNudge(name);
|
|
206
|
-
if (!thrash && !nudge)
|
|
207
|
-
return null;
|
|
208
|
-
return [thrash, nudge].filter(Boolean).join('');
|
|
174
|
+
return thrashHint(name, args, c);
|
|
209
175
|
};
|
|
210
176
|
history.push({ role: 'user', content: userInput });
|
|
211
177
|
// drop_context 工具的上下文剔除回调:闭包捕获 history,原地剔除无关旧 tool 结果。
|
|
@@ -222,7 +188,7 @@ export async function runAgentCore(opts) {
|
|
|
222
188
|
// 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions。
|
|
223
189
|
// 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
|
|
224
190
|
const scheduler = config.contextBudget !== false
|
|
225
|
-
? createBudgetScheduler() // 在 step 循环之外实例化一次,跨步持有 lastRunLog
|
|
191
|
+
? createBudgetScheduler(runtimeContextState) // 在 step 循环之外实例化一次,跨步持有 lastRunLog
|
|
226
192
|
: null;
|
|
227
193
|
// 本轮流式状态:首个正文 token 到达即停 spinner(思考期间 spinner 持续转「思考中…」,不写思考内容)。
|
|
228
194
|
let mode = 'idle';
|
|
@@ -242,6 +208,10 @@ export async function runAgentCore(opts) {
|
|
|
242
208
|
const onToolCall = (name) => {
|
|
243
209
|
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
244
210
|
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
211
|
+
if (lastChar && lastChar !== '\n') {
|
|
212
|
+
hooks.onTextEnd?.(); // 主 agent:layout.contentWrite('\n')
|
|
213
|
+
lastChar = '\n';
|
|
214
|
+
}
|
|
245
215
|
hooks.onToolCall?.(name); // 主 agent:spinner.start(`生成 ${name}…`)
|
|
246
216
|
};
|
|
247
217
|
// 中断还原:停 spinner + 补换行 + (已中断)提示 + history 还原到本 turn 前 + 模式还原。
|
|
@@ -266,7 +236,7 @@ export async function runAgentCore(opts) {
|
|
|
266
236
|
await scheduler.runStep(history, step);
|
|
267
237
|
}
|
|
268
238
|
else {
|
|
269
|
-
await maybeCompact(history);
|
|
239
|
+
await maybeCompact(history, undefined, undefined, runtimeContextState);
|
|
270
240
|
}
|
|
271
241
|
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
272
242
|
mode = 'idle';
|
|
@@ -291,14 +261,26 @@ export async function runAgentCore(opts) {
|
|
|
291
261
|
}
|
|
292
262
|
throw e;
|
|
293
263
|
}
|
|
294
|
-
|
|
264
|
+
runtimeContextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
295
265
|
addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
|
|
266
|
+
// 校正系数:API 实测 prompt_tokens / 估算 token。
|
|
267
|
+
// 每次 chat 响应后刷新,让下次 evaluateBudget 用更接近真实的 actual。
|
|
268
|
+
// 钳位 [0.5, 2.0]:防单次异常值(极短回复 / 空 history)导致系数跳变。
|
|
269
|
+
if (result.usage?.promptTokens && result.usage.promptTokens > 100) {
|
|
270
|
+
const estimated = estimateMessagesTokens(history) + estimateToolSchemaTokens();
|
|
271
|
+
if (estimated > 100) {
|
|
272
|
+
const raw = result.usage.promptTokens / estimated;
|
|
273
|
+
runtimeContextState.correction = Math.max(0.5, Math.min(2.0, raw));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
296
276
|
hooks.onChatDone?.(); // 主 agent:spinner.stop()
|
|
297
277
|
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
|
298
278
|
onContextUpdate?.();
|
|
299
279
|
if (result.toolCalls.length > 0) {
|
|
300
280
|
hadToolsThisTurn = true;
|
|
301
|
-
//
|
|
281
|
+
// 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
|
|
282
|
+
if (mode !== 'idle' && lastChar !== '\n')
|
|
283
|
+
hooks.onTextEnd?.();
|
|
302
284
|
// 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
|
|
303
285
|
history.push({
|
|
304
286
|
role: 'assistant',
|
|
@@ -309,36 +291,39 @@ export async function runAgentCore(opts) {
|
|
|
309
291
|
function: { name: tc.name, arguments: tc.arguments },
|
|
310
292
|
})),
|
|
311
293
|
});
|
|
312
|
-
// 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)
|
|
313
|
-
//
|
|
294
|
+
// 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)成组并发——先一次性
|
|
295
|
+
// 渲染全部 header,让摘要在任何同步工具真正执行前立即可见;随后启动全部 executeTool,
|
|
296
|
+
// 再按原顺序逐个 await + 回灌结果。
|
|
314
297
|
// mutation(write_file/edit_file)及 run_command/use_skill 各为单步串行屏障——mutation 串行保
|
|
315
298
|
// recordMutation 调用序 = 回滚快照序(executeTool 内写前记 before 快照,同文件多次写需按序)。
|
|
316
299
|
// 渲染与 history 回灌一律按原顺序;并发只影响执行时序,tool_call_id 仍按序配对。
|
|
317
300
|
// executeTool 永不抛错(调度器 try/catch 返字符串),故 await 单个 promise 不会抛(永远 resolve 为字符串)。
|
|
318
301
|
const calls = result.toolCalls;
|
|
319
|
-
hooks.onToolBatchStart?.(calls);
|
|
320
302
|
let i = 0;
|
|
321
303
|
while (i < calls.length) {
|
|
322
304
|
if (READ_TOOL_NAMES.has(calls[i].name)) {
|
|
323
|
-
// 收集连续只读组(≥1)
|
|
324
|
-
//
|
|
305
|
+
// 收集连续只读组(≥1),并发执行:先渲染所有 header,再一次性启动所有
|
|
306
|
+
// (executeTool 调用即开始 I/O),最后按原顺序逐个 await + 回灌。
|
|
307
|
+
// 必须先 header 后 execute:todolist/grep 等同步快速工具会在 executeTool 返回 Promise 前
|
|
308
|
+
// 已经完成;若先 started.map,用户只能在工具完成后才看到摘要与其前面的换行。
|
|
325
309
|
// 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
|
|
326
310
|
let j = i;
|
|
327
311
|
while (j < calls.length && READ_TOOL_NAMES.has(calls[j].name))
|
|
328
312
|
j++;
|
|
329
313
|
const batch = calls.slice(i, j);
|
|
314
|
+
for (const tc of batch)
|
|
315
|
+
hooks.onToolHeader?.(tc);
|
|
316
|
+
hooks.onToolStart?.(batch[0].name);
|
|
330
317
|
const started = batch.map((tc) => executeTool(tc.name, tc.arguments, signal, { skipRollback, dropContext }));
|
|
331
318
|
for (let k = 0; k < batch.length; k++) {
|
|
332
319
|
const tc = batch[k];
|
|
333
|
-
hooks.onToolHeader?.(tc);
|
|
334
|
-
hooks.onToolStart?.(tc.name);
|
|
335
320
|
const output = await started[k];
|
|
336
|
-
hooks.onToolDone?.();
|
|
337
321
|
hooks.onToolResult?.(tc, output, null, null, 1); // 只读工具无 diff
|
|
338
322
|
// Thrashing:history 里附 hint(UI 已用干净 output 渲染,避免屏幕噪声)
|
|
339
323
|
const hint = recordAndHint(tc.name, tc.arguments);
|
|
340
324
|
pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
|
|
341
325
|
}
|
|
326
|
+
hooks.onToolDone?.();
|
|
342
327
|
i = j;
|
|
343
328
|
}
|
|
344
329
|
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,25 @@ 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
|
+
// 普通摘要只有一个“当前空行”,再 break 一次把它提交为分隔空行。
|
|
70
|
+
// mutation 自动展开时 content.insertAfter 已先把该当前空行提交到 rows;若这里仍补 \n,
|
|
71
|
+
// diff 后就会固定出现两条空白行。
|
|
72
|
+
if (!expandSingleEntry)
|
|
73
|
+
layout.contentWrite('\n');
|
|
54
74
|
}
|
|
55
75
|
/**
|
|
56
76
|
* agent 核心循环(主 agent,TUI 渲染版):
|
|
@@ -72,6 +92,7 @@ onContextUpdate) {
|
|
|
72
92
|
// 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
|
|
73
93
|
beginTurn(truncateDisplay(firstLineOf(userInput), 40));
|
|
74
94
|
layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
|
|
95
|
+
currentBatchId = null; // 新 turn 清旧 batch id(防上 turn 残留)
|
|
75
96
|
// spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
|
|
76
97
|
// 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
|
|
77
98
|
// 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
|
|
@@ -81,29 +102,46 @@ onContextUpdate) {
|
|
|
81
102
|
// lastChar 镜像:core 跟踪流式末字符决定补换行,但 TUI hooks 需读它决定 layout.contentWrite('\n')。
|
|
82
103
|
// core 的 onTextEnd hook 只在 lastChar !== '\n' 时才调,调后置 '\n';镜像与此同步。
|
|
83
104
|
let lastChar = '';
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
// 正文 -> 工具的边界由 core.onTextEnd 与本层 onToolCall 分两段完成。
|
|
106
|
+
// markdown 段末已经是完整物理行,因此再写 1 个 \n 就代表 1 条空白行;
|
|
107
|
+
// 不能按普通字符串的“两个换行才有一个空行”来计算。
|
|
108
|
+
let textBoundaryNewlines = 0;
|
|
109
|
+
let hasPendingTextBoundary = false;
|
|
110
|
+
let toolBatchFollowsText = false;
|
|
86
111
|
const hooks = {
|
|
87
112
|
onText: (s) => {
|
|
113
|
+
// 纯空白 chunk 在视觉上不是正文:既不切 batch,也不写入 markdown 缓冲。
|
|
114
|
+
// 部分兼容后端会在连续工具轮次间流出 " " / "\n",若据此切批会漏掉首个工具。
|
|
115
|
+
if (currentBatchId && s.trim().length === 0)
|
|
116
|
+
return;
|
|
117
|
+
const followsToolBatch = currentBatchId !== null;
|
|
118
|
+
// batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
|
|
119
|
+
// 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
|
|
120
|
+
const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
|
|
121
|
+
if (s)
|
|
122
|
+
flushToolBatch();
|
|
88
123
|
spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
124
|
+
layout.contentWriteMd(visible); // 正文走 markdown 渲染(代码块高亮 / 标题 / 列表 / 行内 …),见 ui/markdown.ts
|
|
125
|
+
if (visible) {
|
|
126
|
+
lastChar = visible[visible.length - 1];
|
|
127
|
+
if (visible.trim().length > 0) {
|
|
128
|
+
hasPendingTextBoundary = true;
|
|
129
|
+
textBoundaryNewlines = 0;
|
|
130
|
+
}
|
|
96
131
|
}
|
|
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
132
|
},
|
|
104
133
|
onToolCall: (name) => {
|
|
105
134
|
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
106
135
|
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
136
|
+
if (hasPendingTextBoundary) {
|
|
137
|
+
toolBatchFollowsText = true;
|
|
138
|
+
if (textBoundaryNewlines < 1) {
|
|
139
|
+
layout.contentWrite('\n');
|
|
140
|
+
}
|
|
141
|
+
lastChar = '\n';
|
|
142
|
+
textBoundaryNewlines = 1;
|
|
143
|
+
hasPendingTextBoundary = false;
|
|
144
|
+
}
|
|
107
145
|
if (name)
|
|
108
146
|
spinner.start(`生成 ${name}`);
|
|
109
147
|
},
|
|
@@ -113,42 +151,48 @@ onContextUpdate) {
|
|
|
113
151
|
if (lastChar && lastChar !== '\n') {
|
|
114
152
|
layout.contentWrite('\n');
|
|
115
153
|
lastChar = '\n';
|
|
154
|
+
if (hasPendingTextBoundary)
|
|
155
|
+
textBoundaryNewlines = 1;
|
|
116
156
|
}
|
|
117
157
|
},
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
158
|
+
onToolHeader: (tc) => {
|
|
159
|
+
// mutation 的自动展开会把 current/committed 空行状态互相转换;在首摘要真正
|
|
160
|
+
// 落屏前按视觉行归一,避免同样的文本→edit 边界偶发 1 行或 2 行。
|
|
161
|
+
if (toolBatchFollowsText && isMutationTool(tc.name)) {
|
|
162
|
+
layout.normalizeMutationBoundary();
|
|
163
|
+
}
|
|
164
|
+
toolBatchFollowsText = false;
|
|
165
|
+
writeToolHeader(tc);
|
|
124
166
|
},
|
|
125
|
-
onToolHeader: () => { },
|
|
126
167
|
onToolStart: (name) => spinner.start(`执行 ${name}`),
|
|
127
168
|
onToolDone: () => spinner.stop(),
|
|
128
|
-
onToolResult: (tc, output, parsed, preWriteOld, editStartLine) =>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
169
|
+
onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine),
|
|
170
|
+
onToolBatchEnd: () => {
|
|
171
|
+
// 一次工具轮次结束不再切 UI batch;下一轮若仍无正文,继续复用 currentBatchId。
|
|
172
|
+
},
|
|
173
|
+
onNoReply: () => {
|
|
174
|
+
flushToolBatch();
|
|
175
|
+
layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`);
|
|
176
|
+
},
|
|
177
|
+
onMaxSteps: () => {
|
|
178
|
+
flushToolBatch();
|
|
179
|
+
layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`);
|
|
132
180
|
},
|
|
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
181
|
onAbort: () => {
|
|
138
182
|
spinner.stop();
|
|
139
|
-
if (currentBatchId)
|
|
140
|
-
batch.finalizePendingBatch(currentBatchId, layout);
|
|
141
|
-
currentBatchId = null;
|
|
142
183
|
if (lastChar && lastChar !== '\n')
|
|
143
184
|
layout.contentWrite('\n');
|
|
185
|
+
flushToolBatch();
|
|
144
186
|
layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
|
|
145
187
|
},
|
|
146
188
|
onDone: (elapsedMs, usage) => {
|
|
147
|
-
|
|
148
|
-
batch.finalizePendingBatch(currentBatchId, layout);
|
|
149
|
-
currentBatchId = null;
|
|
189
|
+
flushToolBatch();
|
|
150
190
|
const tok = formatTurnTokens(usage);
|
|
151
191
|
layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${tok}${ui.reset}\n`);
|
|
192
|
+
// 内容区触底时,DECSTBM 增量滚屏可能只推进物理终端,未把 Worked 前已在
|
|
193
|
+
// buffer 中的空行完整画出来;用户滚动/点击触发 repaint 后才“突然”出现。
|
|
194
|
+
// 轮次收尾立即按 buffer 原子重画,使未满屏与触底滚屏的布局一致。
|
|
195
|
+
layout.repaintViewport();
|
|
152
196
|
},
|
|
153
197
|
};
|
|
154
198
|
// 桌宠状态广播:与 TUI hooks 并列注入,互不干扰(petHooks 只调 bridge.sendState,不写屏;
|
package/dist/agent/spawn.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// - 独立 history:不共享主对话,避免子任务的工具噪声污染主上下文。
|
|
8
8
|
// - 系统提示复用主 agent 组装链(config.systemPrompt + memory 段 + skills 段)+ 子 agent 角色后缀。
|
|
9
9
|
// - 工具子集:按白名单从 chatTools 过滤;无白名单 = 全量(但 task 工具调用方通常会限定只读)。
|
|
10
|
-
// - 不调 beginTurn(不进主回滚链)
|
|
11
|
-
//
|
|
10
|
+
// - 不调 beginTurn(不进主回滚链);skipRollback=true 使文件 mutation 跳过 recordMutation,
|
|
11
|
+
// 子 agent 的改动不进主回滚快照链(靠 git 兜底,见下方 skipRollback 注释)。
|
|
12
12
|
// - 步数上限默认更低(config.subAgentMaxSteps ?? 50),防子任务失控耗尽配额。
|
|
13
13
|
// - 中断透传:opts.signal(主 agent 的 abort signal)透传给 runAgentCore → chat/executeTool,
|
|
14
14
|
// 主 Ctrl+C 树杀子 agent(chat 流式 abort + run_command/web_fetch 即时取消)。
|
|
@@ -21,6 +21,7 @@ import { buildMemorySection, buildMemoryIndexSection } from '../memory/index.js'
|
|
|
21
21
|
import { ui } from '../ui/theme.js';
|
|
22
22
|
import { runAgentCore } from './core.js';
|
|
23
23
|
import { summarizeToolCall, summarizeToolResult, truncateDisplay } from '../ui/render.js';
|
|
24
|
+
import { createContextState } from '../session/compact.js';
|
|
24
25
|
/** 子 agent 系统提示后缀:角色与约束。 */
|
|
25
26
|
const SUBAGENT_SUFFIX = `
|
|
26
27
|
|
|
@@ -115,6 +116,9 @@ export async function spawnAgent(opts) {
|
|
|
115
116
|
// onStepStart / onChatDone / onToolStart / onToolDone / onAbort:子 agent 静默,无需 spinner / 中断渲染。
|
|
116
117
|
// abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
|
|
117
118
|
};
|
|
119
|
+
// 每个子 agent 独享统计/预算状态。不能保存再恢复模块级单例:多个 task 并发时
|
|
120
|
+
// save/restore 会竞态,且 lastEstimate / schedulerLog 仍会污染主 agent。
|
|
121
|
+
const localContextState = createContextState();
|
|
118
122
|
const result = await runAgentCore({
|
|
119
123
|
history,
|
|
120
124
|
userInput: opts.prompt,
|
|
@@ -123,6 +127,7 @@ export async function spawnAgent(opts) {
|
|
|
123
127
|
maxSteps,
|
|
124
128
|
toolsOverride,
|
|
125
129
|
skipRollback: true, // 逻辑隔离:子 agent 文件改动不进主回滚快照链,主 /rollback 不撤销(靠 git 兜底)
|
|
130
|
+
contextState: localContextState,
|
|
126
131
|
});
|
|
127
132
|
return {
|
|
128
133
|
summary: result.finalText,
|
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';
|