mocode-ai 0.4.0 → 0.4.2
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 +81 -16
- package/dist/agent/index.js +18 -2
- package/dist/agent/spawn.js +18 -9
- package/dist/config/index.js +106 -24
- package/dist/context/budget.js +225 -0
- package/dist/context/index.js +5 -2
- package/dist/context/lifecycle.js +384 -0
- package/dist/context/relevance.js +284 -0
- package/dist/llm/index.js +6 -3
- package/dist/memory/index.js +8 -1
- package/dist/memory/store.js +8 -1
- package/dist/repl/index.js +167 -22
- package/dist/session/compact.js +120 -12
- package/dist/session/index.js +5 -0
- package/dist/session/scheduler.js +172 -0
- package/dist/tools/builtins/ask-human.js +79 -3
- package/dist/tools/builtins/grep.js +52 -15
- package/dist/tools/builtins/index.js +20 -5
- package/dist/tools/builtins/read-file.js +15 -5
- package/dist/tools/constants.js +33 -0
- package/dist/ui/layout.js +30 -13
- package/package.json +1 -1
package/dist/session/compact.js
CHANGED
|
@@ -5,7 +5,8 @@ import { ui } from '../ui/theme.js';
|
|
|
5
5
|
import { Spinner } from '../ui/spinner.js';
|
|
6
6
|
import * as layout from '../ui/layout.js';
|
|
7
7
|
import { pruneAfterCompaction } from '../rollback/index.js';
|
|
8
|
-
/** 跨模块共享的上下文状态:agent 写 lastUsage,compact 写 lastEstimate,repl 的 /context 读。
|
|
8
|
+
/** 跨模块共享的上下文状态:agent 写 lastUsage,compact 写 lastEstimate,repl 的 /context 读。
|
|
9
|
+
* scheduler.ts 写最近一次调度日志(可选,repl 可读不到时 no-op)。 */
|
|
9
10
|
export const contextState = {
|
|
10
11
|
lastEstimate: 0,
|
|
11
12
|
};
|
|
@@ -268,15 +269,83 @@ export async function compactHistory(history, opts) {
|
|
|
268
269
|
summarized: false,
|
|
269
270
|
estimateBefore,
|
|
270
271
|
estimateAfter: estimateBefore,
|
|
271
|
-
reason: 'noop',
|
|
272
|
+
reason: 'noop-noold-noop',
|
|
273
|
+
protectedRatio: history.length > 0 ? kept.length / history.length : 0,
|
|
274
|
+
oldGroupCount: oldGroups.length,
|
|
272
275
|
};
|
|
273
276
|
if (oldGroups.length === 0) {
|
|
274
277
|
// 没有旧区可压缩
|
|
278
|
+
const protectedRatio = history.length > 0 ? kept.length / history.length : 0;
|
|
279
|
+
if (opts.force && history.length > 2) {
|
|
280
|
+
// 真·强制压:把降 keepBudget 当 old group 强行创建一组可压区
|
|
281
|
+
// 取最早的 user/assistant/tool 当 old,后一段当 kept
|
|
282
|
+
const mid = Math.max(1, Math.floor(history.length / 2));
|
|
283
|
+
const older = history.slice(1, mid);
|
|
284
|
+
const keptAfter = history.slice(mid);
|
|
285
|
+
// 走仅微压缩(LLM 可能不可用,不强行 summarize)
|
|
286
|
+
let microcompactDone2 = false;
|
|
287
|
+
for (const m of older) {
|
|
288
|
+
const c = m.content;
|
|
289
|
+
if (typeof c === 'string' && c.length > MAX_OLD_TOOL_STUB) {
|
|
290
|
+
m.content = truncateMid(c, MAX_OLD_TOOL_STUB);
|
|
291
|
+
microcompactDone2 = true;
|
|
292
|
+
}
|
|
293
|
+
const as = m;
|
|
294
|
+
if (as.role === 'assistant') {
|
|
295
|
+
if (typeof as.content === 'string' && as.content.length > MAX_OLD_TOOL_STUB) {
|
|
296
|
+
as.content = truncateMid(as.content, MAX_OLD_TOOL_STUB);
|
|
297
|
+
microcompactDone2 = true;
|
|
298
|
+
}
|
|
299
|
+
if (Array.isArray(as.tool_calls)) {
|
|
300
|
+
for (const tc of as.tool_calls) {
|
|
301
|
+
const args = tc?.function?.arguments;
|
|
302
|
+
if (typeof args !== 'string' || args.length <= MAX_OLD_TOOL_STUB)
|
|
303
|
+
continue;
|
|
304
|
+
const stubbed = stubToolCallArguments(args);
|
|
305
|
+
if (stubbed !== args) {
|
|
306
|
+
tc.function.arguments = stubbed;
|
|
307
|
+
microcompactDone2 = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const summaryMsg = {
|
|
314
|
+
role: 'system',
|
|
315
|
+
content: older.length > 0
|
|
316
|
+
? `# 会话摘要(force)\n被跳过的早期对话 ${older.length} 条已微压缩(token 数减少)。`
|
|
317
|
+
: `# 会话摘要(force)\n无内容。`,
|
|
318
|
+
};
|
|
319
|
+
const rebuilt = [history[0], summaryMsg, ...keptAfter];
|
|
320
|
+
history.length = 0;
|
|
321
|
+
history.push(...rebuilt);
|
|
322
|
+
pruneAfterCompaction(history);
|
|
323
|
+
const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
|
|
324
|
+
contextState.lastEstimate = estimateAfter;
|
|
325
|
+
contextState.lastUsage = undefined;
|
|
326
|
+
layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}强制压缩(focus on early history)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
327
|
+
return {
|
|
328
|
+
compacted: true,
|
|
329
|
+
summarized: false,
|
|
330
|
+
estimateBefore,
|
|
331
|
+
estimateAfter,
|
|
332
|
+
reason: microcompactDone2 ? 'microcompact' : 'summarize',
|
|
333
|
+
protectedRatio,
|
|
334
|
+
oldGroupCount: 0,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
// 细分 noop 类型,供 repl 文案
|
|
338
|
+
const isEmpty = history.length <= 1; // 只有 system 提示
|
|
339
|
+
if (isEmpty) {
|
|
340
|
+
// 整段 history 太短,没有可压内容
|
|
341
|
+
return { ...noop, reason: 'noop-empty', protectedRatio };
|
|
342
|
+
}
|
|
343
|
+
// history 有内容但全在保护区(系统 + 当前轮)
|
|
275
344
|
if (estimateBefore >= opts.threshold * opts.window) {
|
|
276
|
-
layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}
|
|
277
|
-
return { ...noop, reason: 'too-large' };
|
|
345
|
+
layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}上下文已超阈但无可压缩项(全在保护区),建议 /clear 或缩短输入。${ui.reset}\n`);
|
|
346
|
+
return { ...noop, reason: 'noop-shrunk-too-large', protectedRatio };
|
|
278
347
|
}
|
|
279
|
-
return noop;
|
|
348
|
+
return { ...noop, reason: 'noop-protected', protectedRatio };
|
|
280
349
|
}
|
|
281
350
|
// 第一层:微压缩——旧区原地截短(保 tool_call_id,无 LLM 调用)
|
|
282
351
|
// 覆盖三类大字段,均只裁模型/工具产物,不动 user 原话与 system(摘要):
|
|
@@ -365,24 +434,63 @@ export async function compactHistory(history, opts) {
|
|
|
365
434
|
estimateBefore,
|
|
366
435
|
estimateAfter,
|
|
367
436
|
reason: 'microcompact',
|
|
437
|
+
protectedRatio: history.length > 0 ? kept.length / history.length : 0,
|
|
438
|
+
oldGroupCount: oldGroups.length,
|
|
368
439
|
};
|
|
369
440
|
}
|
|
370
|
-
|
|
441
|
+
// 摘要失败 + 无超大单条可微压 = 真 noop
|
|
442
|
+
return {
|
|
443
|
+
...noop,
|
|
444
|
+
reason: 'noop-ml-only',
|
|
445
|
+
protectedRatio: history.length > 0 ? kept.length / history.length : 0,
|
|
446
|
+
oldGroupCount: oldGroups.length,
|
|
447
|
+
};
|
|
371
448
|
}
|
|
372
449
|
/**
|
|
373
450
|
* 自动压缩门槛:agent 每步调 chat() 前调用。
|
|
374
451
|
* 用全量启发式估算(始终可用、安全侧、无 stale-usage 问题);超阈则压缩。
|
|
452
|
+
*
|
|
453
|
+
* 升级到 Budget Scheduler:可传 `report: BudgetReport`。传了就**按 ROI 调度**:
|
|
454
|
+
* - 只有当 `report.layers.history.overBudget` 或 `report.totalOver` 时才压;
|
|
455
|
+
* - 冷工具超(Cold Tool ROI 最低)→ 不压 history,留给调度器的 cold tools 路径处理。
|
|
456
|
+
* 这样 cold tools 路径(L1 中截 / L2 relevance / L3 age stub)能先动,history
|
|
457
|
+
* 摘要(最贵)只在 cold tools 解不开时才触发。
|
|
458
|
+
*
|
|
459
|
+
* 不传 report 时退化为原行为:仅看总占用是否超 `compactThreshold * window`——
|
|
460
|
+
* 兼容老调用方(子 agent / 测试直接调时),零行为变化。
|
|
461
|
+
*
|
|
462
|
+
* manual 选项(repl /compact 用):true 时旁路 autoCompact 开关与 ROI 阈,
|
|
463
|
+
* 强制走 compactHistory(manual/force 参数透传)。返 CompactResult 给 caller 文案展示。
|
|
464
|
+
* 默认 manual=false 自动路径完全不变。
|
|
375
465
|
*/
|
|
376
|
-
export async function maybeCompact(history) {
|
|
466
|
+
export async function maybeCompact(history, report, manualOpts) {
|
|
377
467
|
const schemaTokens = estimateToolSchemaTokens();
|
|
378
468
|
const est = estimateMessagesTokens(history) + schemaTokens;
|
|
379
469
|
contextState.lastEstimate = est;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
470
|
+
const isManual = manualOpts?.manual === true;
|
|
471
|
+
// 手动路径:旁路 autoCompact / report / 总阈三重门
|
|
472
|
+
if (!isManual) {
|
|
473
|
+
if (!config.autoCompact)
|
|
474
|
+
return;
|
|
475
|
+
if (report) {
|
|
476
|
+
// 调度模式:只压 history 层超或兜底总超
|
|
477
|
+
const needsCompact = report.layers.history.overBudget || report.totalOver;
|
|
478
|
+
if (!needsCompact)
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
// 老路径:总占用超阈才压
|
|
483
|
+
if (est < config.compactThreshold * config.contextWindowTokens)
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const r = await compactHistory(history, {
|
|
385
488
|
window: config.contextWindowTokens,
|
|
386
489
|
threshold: config.compactThreshold,
|
|
490
|
+
focus: manualOpts?.focus,
|
|
491
|
+
manual: isManual,
|
|
492
|
+
force: manualOpts?.force,
|
|
387
493
|
});
|
|
494
|
+
if (isManual)
|
|
495
|
+
return r;
|
|
388
496
|
}
|
package/dist/session/index.js
CHANGED
|
@@ -5,5 +5,10 @@
|
|
|
5
5
|
* 依赖方向:session → {llm(摘要复用 chat), config, ui};llm 不反向依赖 session。
|
|
6
6
|
*/
|
|
7
7
|
export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, contextState, } from './compact.js';
|
|
8
|
+
// ── Context Budget Scheduler 接缝 ────────────────────────────────────────
|
|
9
|
+
// agent/core.ts 步前调 runScheduler(history, step):评估五区预算 → 按 ROI 调度
|
|
10
|
+
// shrink_cold_tools / cap_hot_tools / compact_history。开关关闭时退化为 maybeCompact。
|
|
11
|
+
// repl /compact 命令调 manualCompact(history, focus?):与自动路径完全一致,focus 透传摘要 prompt。
|
|
12
|
+
export { runScheduler, manualCompact, createBudgetScheduler, } from './scheduler.js';
|
|
8
13
|
export { dropContextFromHistory, formatDropResult, } from './drop.js';
|
|
9
14
|
export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Context Budget Scheduler(执行层):把 scheduleActions() 产生的动作落到既有闸上。
|
|
2
|
+
//
|
|
3
|
+
// 关系图:
|
|
4
|
+
//
|
|
5
|
+
// agent/core.ts (步前) repl /compact 命令(手动)
|
|
6
|
+
// ↓ runScheduler(history, step) ↓ manualCompact(history, focus?)
|
|
7
|
+
// session/scheduler.ts(本文件)
|
|
8
|
+
// ├─ evaluateBudget(history, window, step) → BudgetReport
|
|
9
|
+
// │ ↓
|
|
10
|
+
// ├─ scheduleActions(report) → ScheduleAction[]
|
|
11
|
+
// │ ↓
|
|
12
|
+
// └─ 执行 actions:
|
|
13
|
+
// - warn: 仅写日志
|
|
14
|
+
// - shrink_cold_tools L1: 由 push-time cap.ts(MAX_HISTORY_RESULT)自动处理;此处 no-op
|
|
15
|
+
// - shrink_cold_tools L2: 由 pruner.observePush(relevance.ts)自动处理;此处 no-op
|
|
16
|
+
// - shrink_cold_tools L3: 由 lifecycle.pushTool(lifecycle.ts)自动处理;此处 no-op
|
|
17
|
+
// - cap_hot_tools: Hot 区只 cap,实际仍由 push-time cap 走;此处 no-op + 记日志
|
|
18
|
+
// - compact_history: 调 maybeCompact(history, report)── report 路由到 ROI 调度
|
|
19
|
+
//
|
|
20
|
+
// 设计意图:
|
|
21
|
+
// - **L1/L2/L3 不重复实现**——push-time 已经自动跑过这三级。再在调度器做一遍是 Double-Action
|
|
22
|
+
// 且破坏「调度器永不抛错 + 幂等」契约。调度器只负责"决策时点",push-time 闸负责"执行"。
|
|
23
|
+
// - **hotBoundary** 仍由调度器算出来供 lifecycle 内部用(将来可演进成「仅 Cold 区跑 age stub」)——
|
|
24
|
+
// 现版本先全面暴露给 report,暂不传参给 lifecycle。
|
|
25
|
+
// - **actionLog**:每次执行的决策落进 contextState.schedulerLog,供 /context 命令与调试用。
|
|
26
|
+
// - **manualCompact**:手动入口(用户敲 /compact),与 runScheduler 共享决策路径;唯一差别是
|
|
27
|
+
// 即使 history 不超预算也强制产 compact_history(focus 透传)。对齐用户拍板的方案 A。
|
|
28
|
+
//
|
|
29
|
+
// 开关:
|
|
30
|
+
// - config.contextBudget !== false(默认 true):agent 调 runScheduler
|
|
31
|
+
// - 关时 agent 仍走原 maybeCompact(history)无 report 路径,零行为变化
|
|
32
|
+
// - 手动 /compact 走 manualCompact;关时退化直接调 compactHistory(history, { focus })
|
|
33
|
+
import { evaluateBudget, scheduleActions, formatReport, } from '../context/budget.js';
|
|
34
|
+
import { config } from '../config/index.js';
|
|
35
|
+
import { maybeCompact, contextState } from './compact.js';
|
|
36
|
+
import * as layout from '../ui/layout.js';
|
|
37
|
+
import { ui } from '../ui/theme.js';
|
|
38
|
+
/** 创建 runAgentCore 闭包持有的 scheduler(每次 agent 启动一个新实例)。
|
|
39
|
+
* observePush 当前只是占位:真正 L1/L2/L3 已由 cap / pruner / lifecycle 在 push 时跑;
|
|
40
|
+
* 保留接口为后续「调度器注入 hotBoundary 给 lifecycle」演进留接缝。 */
|
|
41
|
+
export function createBudgetScheduler() {
|
|
42
|
+
const obs = {
|
|
43
|
+
lastRunLog: null,
|
|
44
|
+
observePush(_history, _idx) {
|
|
45
|
+
// 占位:push-time 三闸(cap / pruner / lifecycle)已自动跑;此接缝供将来演进。
|
|
46
|
+
},
|
|
47
|
+
async runStep(history, step) {
|
|
48
|
+
const report = evaluateBudget(history, config.contextWindowTokens, step);
|
|
49
|
+
const actions = scheduleActions(report);
|
|
50
|
+
let compactHistoryCalled = false;
|
|
51
|
+
for (const a of actions) {
|
|
52
|
+
if (a.kind === 'warn') {
|
|
53
|
+
// system 超:写一行提示(配置漂移应由用户处理,不是调度器压)
|
|
54
|
+
layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}调度器警告:${a.layer} ${a.reason}${ui.reset}\n`);
|
|
55
|
+
}
|
|
56
|
+
else if (a.kind === 'compact_history') {
|
|
57
|
+
// 路由到 maybeCompact(history, report)——按 ROI 调度(只有 history 超 / totalOver 才真压)
|
|
58
|
+
await maybeCompact(history, report);
|
|
59
|
+
compactHistoryCalled = true;
|
|
60
|
+
}
|
|
61
|
+
// shrink_cold_tools L1/L2/L3 与 cap_hot_tools:已由 push-time 闸在每次 push 自动跑
|
|
62
|
+
// (cap = MAX_HISTORY_RESULT;pruner = same-path 新旧替换;lifecycle = age stub)。
|
|
63
|
+
// 调度器不重复,只把决策记录下来供调试。
|
|
64
|
+
}
|
|
65
|
+
const log = {
|
|
66
|
+
step,
|
|
67
|
+
report,
|
|
68
|
+
actions,
|
|
69
|
+
compactHistoryCalled,
|
|
70
|
+
ts: Date.now(),
|
|
71
|
+
};
|
|
72
|
+
obs.lastRunLog = log;
|
|
73
|
+
// 暴露给 /context 共享读(repl / context 命令)
|
|
74
|
+
contextState.schedulerLog = log;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
return obs;
|
|
78
|
+
}
|
|
79
|
+
/** 便捷:agent/core.ts 不需要每次 createBudgetScheduler,直接 runScheduler(history, step)。 */
|
|
80
|
+
export async function runScheduler(history, step) {
|
|
81
|
+
const s = createBudgetScheduler();
|
|
82
|
+
await s.runStep(history, step);
|
|
83
|
+
}
|
|
84
|
+
/** 手动 /compact 入口(repl):与自动路径完全一致——五区 ROI 调度,但 history 摘要强制执行。
|
|
85
|
+
* 即便 layers.history.overBudget=false 或 totalOver=false,manual 仍产 compact_history action
|
|
86
|
+
* 把 focus 透传给 LLM 摘要 prompt。其它 ROI 决策(cold tools / cap hot / warn)按 scheduleActions 走。
|
|
87
|
+
*
|
|
88
|
+
* 关系:runScheduler 是「自动触发」,manualCompact 是「用户显式触发」,二者共享 scheduleActions。
|
|
89
|
+
*
|
|
90
|
+
* force=true:即便 oldGroups 空(history 全在保护区)也强行把早期消息降级压一次。
|
|
91
|
+
* 适合"history 太长,自动阈值从未触发,但用户想强制压"的场景。
|
|
92
|
+
*
|
|
93
|
+
* 退化:config.contextBudget === false 时直接调 compactHistory(history, { focus }),与改造前等价。
|
|
94
|
+
* 返回 SchedulerRunLog + compactDetail 字段,供 repl 文案展示"为什么没压"。 */
|
|
95
|
+
export async function manualCompact(history, focus, opts) {
|
|
96
|
+
if (config.contextBudget === false) {
|
|
97
|
+
// 退化:不经调度器,直压 history(等价于改造前,但带 manual/force 透传)
|
|
98
|
+
const r = await import('./compact.js').then(({ compactHistory }) => compactHistory(history, {
|
|
99
|
+
window: config.contextWindowTokens,
|
|
100
|
+
threshold: config.compactThreshold,
|
|
101
|
+
focus,
|
|
102
|
+
manual: true,
|
|
103
|
+
force: opts?.force,
|
|
104
|
+
}));
|
|
105
|
+
return {
|
|
106
|
+
step: -1,
|
|
107
|
+
report: evaluateBudget(history, config.contextWindowTokens, -1),
|
|
108
|
+
actions: [{ kind: 'compact_history', focus }],
|
|
109
|
+
compactHistoryCalled: true,
|
|
110
|
+
ts: Date.now(),
|
|
111
|
+
compactDetail: {
|
|
112
|
+
reason: r.reason,
|
|
113
|
+
estimateBefore: r.estimateBefore,
|
|
114
|
+
estimateAfter: r.estimateAfter,
|
|
115
|
+
protectedRatio: r.protectedRatio,
|
|
116
|
+
oldGroupCount: r.oldGroupCount,
|
|
117
|
+
focus,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const report = evaluateBudget(history, config.contextWindowTokens, -1);
|
|
122
|
+
let actions = scheduleActions(report);
|
|
123
|
+
// 用户显式说「要压」:即使 report 不含 history 触发,仍追加 compact_history
|
|
124
|
+
const hasCompact = actions.some(a => a.kind === 'compact_history');
|
|
125
|
+
if (!hasCompact) {
|
|
126
|
+
actions = [...actions, { kind: 'compact_history', focus }];
|
|
127
|
+
}
|
|
128
|
+
else if (focus) {
|
|
129
|
+
// 已有 compact_history(action 由 scheduleActions 产,无 focus)— 注入 focus
|
|
130
|
+
actions = actions.map(a => a.kind === 'compact_history' ? { ...a, focus } : a);
|
|
131
|
+
}
|
|
132
|
+
const s = createBudgetScheduler();
|
|
133
|
+
// 直接驱动 runStep 的执行逻辑(action list 我们已自己生成)
|
|
134
|
+
let compactHistoryCalled = false;
|
|
135
|
+
let compactDetail;
|
|
136
|
+
for (const a of actions) {
|
|
137
|
+
if (a.kind === 'warn') {
|
|
138
|
+
// 复用现有 contentWrite 路径(经 scheduler.runStep);这里略,只记录到 log
|
|
139
|
+
}
|
|
140
|
+
else if (a.kind === 'compact_history') {
|
|
141
|
+
const r = await maybeCompact(history, report, {
|
|
142
|
+
manual: true,
|
|
143
|
+
force: opts?.force,
|
|
144
|
+
focus: a.focus,
|
|
145
|
+
});
|
|
146
|
+
compactHistoryCalled = true;
|
|
147
|
+
if (r) {
|
|
148
|
+
compactDetail = {
|
|
149
|
+
reason: r.reason,
|
|
150
|
+
estimateBefore: r.estimateBefore,
|
|
151
|
+
estimateAfter: r.estimateAfter,
|
|
152
|
+
protectedRatio: r.protectedRatio,
|
|
153
|
+
oldGroupCount: r.oldGroupCount,
|
|
154
|
+
focus: a.focus,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const log = {
|
|
160
|
+
step: -1,
|
|
161
|
+
report,
|
|
162
|
+
actions,
|
|
163
|
+
compactHistoryCalled,
|
|
164
|
+
ts: Date.now(),
|
|
165
|
+
compactDetail,
|
|
166
|
+
};
|
|
167
|
+
s.lastRunLog = log;
|
|
168
|
+
contextState.schedulerLog = log;
|
|
169
|
+
return log;
|
|
170
|
+
}
|
|
171
|
+
// ── 调试导出:用于 scripts/check-budget.ts 把报告打到 stdout 调试 ────────
|
|
172
|
+
export { evaluateBudget, scheduleActions, formatReport };
|
|
@@ -1,5 +1,80 @@
|
|
|
1
1
|
import { promptIntervention } from '../../ui/intervention.js';
|
|
2
2
|
import { sendState } from '../../pet/bridge.js';
|
|
3
|
+
/** 将单个选项元素安全地转为可读字符串。
|
|
4
|
+
* LLM 有时会传对象(如 {name/label/title:"xxx", desc/description:"yyy"})而不是纯字符串,
|
|
5
|
+
* 直接 String(obj) 会变成 "[object Object]"——这里智能提取可读字段。 */
|
|
6
|
+
function optionToString(o) {
|
|
7
|
+
if (o === null || o === undefined)
|
|
8
|
+
return '';
|
|
9
|
+
if (typeof o === 'string')
|
|
10
|
+
return o;
|
|
11
|
+
if (typeof o === 'number' || typeof o === 'boolean')
|
|
12
|
+
return String(o);
|
|
13
|
+
if (typeof o === 'object') {
|
|
14
|
+
const obj = o;
|
|
15
|
+
// 优先取常见的标签字段
|
|
16
|
+
const labelKeys = ['label', 'name', 'title', 'text', 'option', 'choice', 'value', 'key'];
|
|
17
|
+
for (const k of labelKeys) {
|
|
18
|
+
const v = obj[k];
|
|
19
|
+
if (typeof v === 'string' && v.trim())
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
// 其次尝试 "label + description" 组合
|
|
23
|
+
const label = obj.label ?? obj.name ?? obj.title;
|
|
24
|
+
const desc = obj.description ?? obj.desc ?? obj.detail;
|
|
25
|
+
if (typeof label === 'string' && typeof desc === 'string') {
|
|
26
|
+
return `${label}: ${desc}`;
|
|
27
|
+
}
|
|
28
|
+
// 兜底:JSON 序列化(去掉大括号让它看起来不像代码)
|
|
29
|
+
try {
|
|
30
|
+
const s = JSON.stringify(obj);
|
|
31
|
+
// 如果是简单对象尝试美化
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return String(o);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return String(o);
|
|
39
|
+
}
|
|
40
|
+
/** 公开以便 check-ask-human-options.ts 单元测试。 */
|
|
41
|
+
export function coerceOptions(raw) {
|
|
42
|
+
// 路径 1:本身就是数组,map 成字符串。
|
|
43
|
+
if (Array.isArray(raw)) {
|
|
44
|
+
// 子路径 1a:LLM 把真数组包成字符串塞在单元素里(本次 bug 现场)
|
|
45
|
+
if (raw.length === 1 && typeof raw[0] === 'string') {
|
|
46
|
+
const t = raw[0].trim();
|
|
47
|
+
if (t.startsWith('[') && t.endsWith(']')) {
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(t);
|
|
50
|
+
if (Array.isArray(parsed))
|
|
51
|
+
return parsed.map(optionToString);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// 不是合法 JSON 数组,降级原值
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return raw.map(optionToString);
|
|
59
|
+
}
|
|
60
|
+
// 路径 2:LLM 直接把整个数组 stringify 成单字符串塞 options 字段(JSON.parse 出来是字符串)
|
|
61
|
+
// 例如 GLM 系经常这么做,arg h['options']='["A","B"]' → args.options='["A","B"]'
|
|
62
|
+
// 这里解开成真数组再转。
|
|
63
|
+
if (typeof raw === 'string') {
|
|
64
|
+
const t = raw.trim();
|
|
65
|
+
if (t.startsWith('[') && t.endsWith(']')) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(t);
|
|
68
|
+
if (Array.isArray(parsed))
|
|
69
|
+
return parsed.map(optionToString);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// 不是合法 JSON,保留为单元素数组(对应 input 模式)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
3
78
|
// ---------- ask_human ----------
|
|
4
79
|
export const askHumanTool = {
|
|
5
80
|
name: 'ask_human',
|
|
@@ -30,9 +105,10 @@ export const askHumanTool = {
|
|
|
30
105
|
},
|
|
31
106
|
async execute(args) {
|
|
32
107
|
const question = String(args.question ?? '');
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
108
|
+
// 容错:部分 LLM(尤其 GLM 系)把数组/对象 stringify 后塞进来,这里识别「长得很像 JSON
|
|
109
|
+
// 数组的单字符串元素」并解开,避免菜单只剩一行 [object Object]、逼用户手动输入。
|
|
110
|
+
// 任何一步失败 / 解出非数组:降级 input,与原代码语义一致。
|
|
111
|
+
const options = coerceOptions(args.options);
|
|
36
112
|
const context = args.context ? String(args.context) : undefined;
|
|
37
113
|
// 桌宠:面板弹出期间广播 waiting_human(红灯闪烁,提示需要人工介入);拿到响应后 sendState 会被
|
|
38
114
|
// 下一个 hook 事件(如 onToolDone→tool_call)覆盖,这里不用手动切回——与其它工具状态转移逻辑一致。
|
|
@@ -5,19 +5,26 @@ import { getSandboxRoot, isInsideRoot, jailResolve } from '../../sandbox/index.j
|
|
|
5
5
|
// ---------- grep ----------
|
|
6
6
|
export const grepTool = {
|
|
7
7
|
name: 'grep',
|
|
8
|
-
description: 'Search file contents by regex,
|
|
9
|
-
'
|
|
8
|
+
description: 'Search file contents by regex (recursive, excludes node_modules/.git).\n' +
|
|
9
|
+
'Output: per-file header "<path>: N matches, lines [l1, l2, ...]" + first N matching lines.\n' +
|
|
10
|
+
'Use the line-number list to call read_file(offset=X, limit=Y) for each region — ' +
|
|
11
|
+
'do NOT read entire files after grepping. Prefer codegraph for call chains.',
|
|
10
12
|
parameters: {
|
|
11
13
|
type: 'object',
|
|
12
14
|
properties: {
|
|
13
15
|
pattern: { type: 'string', description: 'Regular expression' },
|
|
14
16
|
glob: { type: 'string', description: 'Optional, restrict to a file glob, e.g. *.ts' },
|
|
17
|
+
max_per_file: {
|
|
18
|
+
type: 'integer',
|
|
19
|
+
description: 'Max body lines per file (default 15, cap 50). Line-number list is always full.',
|
|
20
|
+
},
|
|
15
21
|
},
|
|
16
22
|
required: ['pattern'],
|
|
17
23
|
},
|
|
18
24
|
async execute(args) {
|
|
19
25
|
const pattern = String(args.pattern);
|
|
20
26
|
const g = String(args.glob ?? '**/*');
|
|
27
|
+
const maxPerFile = Math.min(Math.max(Number(args.max_per_file ?? 15), 1), 50);
|
|
21
28
|
let re;
|
|
22
29
|
try {
|
|
23
30
|
re = new RegExp(pattern);
|
|
@@ -34,11 +41,10 @@ export const grepTool = {
|
|
|
34
41
|
followSymbolicLinks: false, // 不跟随软链目录,防经软链扫到牢外文件
|
|
35
42
|
throwErrorOnBrokenSymbolicLink: false,
|
|
36
43
|
})).filter((f) => isInsideRoot(f)); // 后置兜底:仅留牢内
|
|
37
|
-
const
|
|
44
|
+
const hits = [];
|
|
38
45
|
let scanned = 0;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
break;
|
|
46
|
+
let truncated = false;
|
|
47
|
+
outer: for (const f of files) {
|
|
42
48
|
let content;
|
|
43
49
|
try {
|
|
44
50
|
// jailResolve:realpath 化,防「牢内文件软链→牢外」的内容泄露;越界/不可读均 catch 跳过
|
|
@@ -49,19 +55,50 @@ export const grepTool = {
|
|
|
49
55
|
}
|
|
50
56
|
scanned++;
|
|
51
57
|
const lines = content.split(/\r?\n/);
|
|
58
|
+
const lineNos = [];
|
|
52
59
|
for (let i = 0; i < lines.length; i++) {
|
|
53
|
-
if (re.test(lines[i]))
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
if (re.test(lines[i]))
|
|
61
|
+
lineNos.push(i + 1);
|
|
62
|
+
}
|
|
63
|
+
if (lineNos.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
// 全局配额:行号列表总是计入,body 行数也计入(行号 + body 两条共用 MAX_RESULTS)
|
|
66
|
+
const totalCost = lineNos.length + Math.min(lineNos.length, maxPerFile);
|
|
67
|
+
if (hits.length >= MAX_RESULTS || totalCost > MAX_RESULTS * 4) {
|
|
68
|
+
// 文件过多 / 配额爆:仅追加该文件行号列表,不再展开 body
|
|
69
|
+
if (hits.length < MAX_RESULTS) {
|
|
70
|
+
hits.push({ path: f, lineNos, bodies: [] });
|
|
57
71
|
}
|
|
72
|
+
truncated = true;
|
|
73
|
+
continue;
|
|
58
74
|
}
|
|
75
|
+
const bodies = lineNos.slice(0, maxPerFile).map((n) => {
|
|
76
|
+
const trimmed = lines[n - 1].trim();
|
|
77
|
+
return ` L${n}: ${trimmed}`;
|
|
78
|
+
});
|
|
79
|
+
hits.push({ path: f, lineNos, bodies });
|
|
59
80
|
}
|
|
60
|
-
if (
|
|
81
|
+
if (hits.length === 0)
|
|
61
82
|
return `无匹配(扫描了 ${scanned} 个文件)`;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
const out = [];
|
|
84
|
+
let totalShown = 0;
|
|
85
|
+
for (const h of hits) {
|
|
86
|
+
const header = `${h.path}: ${h.lineNos.length} 处匹配,行号 [${h.lineNos.join(', ')}]`;
|
|
87
|
+
out.push(header);
|
|
88
|
+
if (h.bodies.length > 0) {
|
|
89
|
+
out.push(...h.bodies);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
out.push(` (body 已折叠,见上方行号列表 → read_file 精读)`);
|
|
93
|
+
}
|
|
94
|
+
totalShown += h.lineNos.length + h.bodies.length;
|
|
95
|
+
if (totalShown >= MAX_RESULTS) {
|
|
96
|
+
out.push(`...(结果达到 ${MAX_RESULTS} 条上限)`);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (truncated)
|
|
101
|
+
out.push(`...(仍有更多匹配文件未展示,缩小 glob 或收窄正则)`);
|
|
102
|
+
return out.join('\n');
|
|
66
103
|
},
|
|
67
104
|
};
|
|
@@ -21,7 +21,26 @@ import { todolistTool } from './todolist.js';
|
|
|
21
21
|
/**
|
|
22
22
|
* 所有内置工具,按注册顺序排列。
|
|
23
23
|
* 加新工具:在本目录新建 `xxx.ts` 导出一个 Tool,再在下面数组里加一行。无需改 agent / llm。
|
|
24
|
+
*
|
|
25
|
+
* 记忆子系统总开关(MEMORY_ENABLED !== 'true'):5 个 memory_* 工具整体不进 builtinTools,
|
|
26
|
+
* 进而不进 LLM 的工具表(模型根本看不到、也不会想着去调)。运行时通过 /memory_switch 切;
|
|
27
|
+
* 切换对当前会话的 tool list 不重算(取的是模块初始化时的快照),所以需要重启 REPL 才生效
|
|
28
|
+
* —— 这是有意为之,避免切开关瞬间把已发出请求的工具列表打乱。
|
|
29
|
+
*
|
|
30
|
+
* 注:这里直接读 env(MEMORY_ENABLED)而不是调 config.isMemoryEnabled(),因为本模块可能在
|
|
31
|
+
* config 单例尚未初始化时被其它模块拉起(import 链路:tools/registry → builtinTools,
|
|
32
|
+
* config 单例字段 getter 在 getPlanDisabledTools 等调用链路上 lazy 求值)。
|
|
24
33
|
*/
|
|
34
|
+
const _memoryEnabledAtBoot = process.env.MEMORY_ENABLED === 'true';
|
|
35
|
+
const _memoryTools = _memoryEnabledAtBoot
|
|
36
|
+
? [
|
|
37
|
+
memorySaveTool,
|
|
38
|
+
memorySearchTool,
|
|
39
|
+
memoryListTool,
|
|
40
|
+
memoryUpdateTool,
|
|
41
|
+
memoryForgetTool,
|
|
42
|
+
]
|
|
43
|
+
: [];
|
|
25
44
|
export const builtinTools = [
|
|
26
45
|
readFileTool,
|
|
27
46
|
writeFileTool,
|
|
@@ -36,11 +55,7 @@ export const builtinTools = [
|
|
|
36
55
|
askHumanTool,
|
|
37
56
|
switchModeTool, // plan↔auto 自切(两模式都可见,不进 PLAN_DISABLED_TOOLS;副作用控制工具→串行分支)
|
|
38
57
|
dropContextTool, // 运行中剔除无关 tool 结果(上下文管理,无副作用;两模式都可见,串行分支)
|
|
39
|
-
|
|
40
|
-
memorySearchTool,
|
|
41
|
-
memoryListTool,
|
|
42
|
-
memoryUpdateTool,
|
|
43
|
-
memoryForgetTool,
|
|
58
|
+
..._memoryTools,
|
|
44
59
|
taskTool, // 派生子 agent(独立 history + 可受限工具集);plan 模式禁用(见 PLAN_DISABLED_TOOLS)
|
|
45
60
|
todolistTool, // 工作记事本(plan 文件:复杂任务 checklist,落盘抗压缩);plan 模式可用(便于「先 plan 再 auto」时落地执行清单)
|
|
46
61
|
];
|
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { MAX_FILE_LINES } from '../constants.js';
|
|
4
|
+
/** 默认单次 read_file 拉取的行数。刻意压低,逼 LLM 分块读大文件,
|
|
5
|
+
* 配合 description 中的 PAGINATION IS MANDATORY 引导。
|
|
6
|
+
* 300 行 ≈ 一个屏幕的源码量,够定位一段逻辑而不至于吃光上下文。 */
|
|
7
|
+
const DEFAULT_READ_LIMIT = 300;
|
|
4
8
|
// ---------- read_file ----------
|
|
5
9
|
export const readFileTool = {
|
|
6
10
|
name: 'read_file',
|
|
7
|
-
description: 'Read file content with line numbers. Read before editing
|
|
8
|
-
'
|
|
11
|
+
description: 'Read file content with line numbers. Read before editing.\n' +
|
|
12
|
+
'For files >500 lines: grep first to locate regions, then call read_file multiple times ' +
|
|
13
|
+
'with offset+limit (e.g. offset=350, limit=120). Do NOT read an entire large file in one call.\n' +
|
|
14
|
+
'Prefer codegraph over reading files one at a time for architecture/call-chain questions.',
|
|
9
15
|
parameters: {
|
|
10
16
|
type: 'object',
|
|
11
17
|
properties: {
|
|
12
18
|
path: { type: 'string', description: 'File path, relative to the working directory' },
|
|
13
|
-
offset: { type: 'integer', description: 'Start line
|
|
14
|
-
limit: {
|
|
19
|
+
offset: { type: 'integer', description: 'Start line, 1-based (default 1).' },
|
|
20
|
+
limit: {
|
|
21
|
+
type: 'integer',
|
|
22
|
+
description: 'Max lines to read (default 300, hard cap 2000). Keep ranges ~80-200.',
|
|
23
|
+
},
|
|
15
24
|
},
|
|
16
25
|
required: ['path'],
|
|
17
26
|
},
|
|
18
27
|
async execute(args) {
|
|
19
28
|
const path = String(args.path);
|
|
20
29
|
const offset = Number(args.offset ?? 1);
|
|
21
|
-
|
|
30
|
+
// 无论 LLM 传多大,单次硬钳到 MAX_FILE_LINES,杜绝「绕过分页引导一把全拿」。
|
|
31
|
+
const limit = Math.min(Number(args.limit ?? DEFAULT_READ_LIMIT), MAX_FILE_LINES);
|
|
22
32
|
const data = await readFile(resolve(path), 'utf8');
|
|
23
33
|
const lines = data.split(/\r?\n/);
|
|
24
34
|
const start = Math.max(0, offset - 1);
|