mocode-ai 0.3.0 → 0.4.1
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 +61 -11
- package/dist/config/index.js +10 -1
- 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 +77 -6
- package/dist/repl/index.js +64 -11
- 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 +42 -3
- package/dist/tools/builtins/grep.js +52 -15
- package/dist/tools/builtins/read-file.js +15 -5
- package/dist/tools/constants.js +17 -0
- package/dist/ui/prompt.js +34 -8
- package/package.json +1 -1
package/dist/agent/core.js
CHANGED
|
@@ -10,9 +10,12 @@ import { executeTool } from '../tools/registry.js';
|
|
|
10
10
|
import { PLAN_DISABLED_TOOLS } from '../tools/constants.js';
|
|
11
11
|
import { getAgentMode, setAgentMode } from './mode.js';
|
|
12
12
|
import { maybeCompact, contextState, dropContextFromHistory } from '../session/index.js';
|
|
13
|
+
import { createBudgetScheduler } from '../session/scheduler.js';
|
|
13
14
|
import { optimizeToolResult } from '../context/index.js';
|
|
15
|
+
import { createRelevancePruner } from '../context/relevance.js';
|
|
14
16
|
import { config } from '../config/index.js';
|
|
15
17
|
import { jailResolve } from '../sandbox/index.js';
|
|
18
|
+
import { createLifecycleEngine } from '../context/lifecycle.js';
|
|
16
19
|
/** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
|
|
17
20
|
function parseArgs(raw) {
|
|
18
21
|
try {
|
|
@@ -70,15 +73,31 @@ function readDiffContext(tc, parsed) {
|
|
|
70
73
|
}
|
|
71
74
|
/** 回灌 tool 结果到 history:经 Context Optimization Pipeline 编码(tree/search/log/...)后裁到单条上限。
|
|
72
75
|
* tool_call_id 与 assistant.tool_calls 按序配对。未注册 encoder 时回落 capToolResultForHistory(零行为变化)。
|
|
73
|
-
* TUI 渲染(hooks.onToolResult)用原始 output,与此解耦——屏上看全量,LLM 看编码后紧凑版。
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
* TUI 渲染(hooks.onToolResult)用原始 output,与此解耦——屏上看全量,LLM 看编码后紧凑版。
|
|
77
|
+
* 出口再经 Relevance Pruner 做跨条裁剪:同 path 旧 read_file 自动 stub 为存根。
|
|
78
|
+
* - pruner 在每个 runAgentCore 实例化一次(本闭包持有),会话级状态。
|
|
79
|
+
* - 开关关闭时 pruner 不创建(零开销、零行为变化)。
|
|
80
|
+
* 出口再经 Lifecycle Engine 做引用追踪:LIVE→REFERENCED→OBSOLETE→STUB 四态。
|
|
81
|
+
* - lifecycle 也在每个 runAgentCore 实例化一次,登记 grep/glob/codegraph 等 producer
|
|
82
|
+
* 与 read/edit/write 的 consumer 关系;孤立+老化自动 STUB(观察类工具永不到 STUB)。
|
|
83
|
+
* - 开关关闭时 lifecycle=null 完全跳过。 */
|
|
84
|
+
function pushToolResult(history, tc, output, pruner, lifecycle, scheduler) {
|
|
85
|
+
const msg = {
|
|
76
86
|
role: 'tool',
|
|
77
87
|
tool_call_id: tc.id,
|
|
78
88
|
// optimizeToolResult:classifier 选 encoder → encode(保不变量压缩)→ capToolResultForHistory 兜底。
|
|
79
89
|
// tc.arguments 透传给 encoder(上下文感知编码,如 read_file 的 offset/limit)。永不抛错。
|
|
80
90
|
content: optimizeToolResult(tc.name, output, tc.arguments),
|
|
81
|
-
}
|
|
91
|
+
};
|
|
92
|
+
history.push(msg);
|
|
93
|
+
// 相关性裁剪:只动 read_file / edit_file / write_file 三类(其它 tool 与本层无关)。
|
|
94
|
+
// pruner 内部 try/catch + 幂等,永不抛错;开关关闭时 pruner=null 完全跳过。
|
|
95
|
+
if (pruner)
|
|
96
|
+
pruner.observePush(history, msg);
|
|
97
|
+
// 观察者生命周期:新 push 一律先登记 LIVE;内部自动维护 producer/consumer 图 + 老化 STUB。
|
|
98
|
+
// lifecycle 内部 try/catch + 幂等;开关关闭时 lifecycle=null 完全跳过。
|
|
99
|
+
if (lifecycle)
|
|
100
|
+
lifecycle.pushTool(history, history.length - 1);
|
|
82
101
|
}
|
|
83
102
|
/**
|
|
84
103
|
* agent 核心循环(纯逻辑):
|
|
@@ -111,6 +130,18 @@ export async function runAgentCore(opts) {
|
|
|
111
130
|
// 保护由 dropContextFromHistory 内部保证:history[0](system)+ 当前轮(最后 user 及其后)永不剔除。
|
|
112
131
|
// 子 agent 也在自己的 history 上操作(子 agent 独立 history);skipRollback 不影响此行为。
|
|
113
132
|
const dropContext = (filter) => dropContextFromHistory(history, filter);
|
|
133
|
+
// 相关性裁剪 pruner:每个 runAgentCore 实例一个,纯静态、不调 LLM、自动判定 read_file 失效。
|
|
134
|
+
// 开关关闭时为 null,所有 pushToolResult 调用走无 pruner 路径(零行为变化)。
|
|
135
|
+
const relprune = config.contextRelprune ? createRelevancePruner() : null;
|
|
136
|
+
// 观察者生命周期引擎:每个 runAgentCore 实例一个,纯静态、自动维护 grep/glob/codegraph 等
|
|
137
|
+
// producer 与 read/edit/write 的 consumer 引用关系;孤立+老化的非观察类工具自动 STUB。
|
|
138
|
+
// 开关关闭时为 null,所有 pushToolResult / mutation 调用走无 lifecycle 路径(零行为变化)。
|
|
139
|
+
const lifecycle = config.contextLifecycle ? createLifecycleEngine() : null;
|
|
140
|
+
// 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions。
|
|
141
|
+
// 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
|
|
142
|
+
const scheduler = config.contextBudget !== false
|
|
143
|
+
? createBudgetScheduler() // 在 step 循环之外实例化一次,跨步持有 lastRunLog
|
|
144
|
+
: null;
|
|
114
145
|
// 本轮流式状态:首个正文 token 到达即停 spinner(思考期间 spinner 持续转「思考中…」,不写思考内容)。
|
|
115
146
|
let mode = 'idle';
|
|
116
147
|
let gotText = false;
|
|
@@ -146,8 +177,15 @@ export async function runAgentCore(opts) {
|
|
|
146
177
|
abortRestore();
|
|
147
178
|
return { completed: false, finalText: null };
|
|
148
179
|
}
|
|
149
|
-
//
|
|
150
|
-
|
|
180
|
+
// 步前:五区 Budget Scheduler 决策——按 ROI 调度(冷工具优先 / history 摘要最后)。
|
|
181
|
+
// 开关关闭(scheduler=null)时退化回原 maybeCompact 路径,零行为变化。
|
|
182
|
+
// 此时 spinner 已停,通知行干净。
|
|
183
|
+
if (scheduler) {
|
|
184
|
+
await scheduler.runStep(history, step);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
await maybeCompact(history);
|
|
188
|
+
}
|
|
151
189
|
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
152
190
|
mode = 'idle';
|
|
153
191
|
gotText = false;
|
|
@@ -214,7 +252,7 @@ export async function runAgentCore(opts) {
|
|
|
214
252
|
const output = await started[k];
|
|
215
253
|
hooks.onToolDone?.();
|
|
216
254
|
hooks.onToolResult?.(tc, output, null, null, 1); // 只读工具无 diff
|
|
217
|
-
pushToolResult(history, tc, output);
|
|
255
|
+
pushToolResult(history, tc, output, relprune, lifecycle, scheduler);
|
|
218
256
|
}
|
|
219
257
|
i = j;
|
|
220
258
|
}
|
|
@@ -234,7 +272,7 @@ export async function runAgentCore(opts) {
|
|
|
234
272
|
hooks.onToolHeader?.(tc);
|
|
235
273
|
const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
|
|
236
274
|
hooks.onToolResult?.(tc, err, null, null, 1);
|
|
237
|
-
pushToolResult(history, tc, err);
|
|
275
|
+
pushToolResult(history, tc, err, relprune, lifecycle, scheduler);
|
|
238
276
|
i++;
|
|
239
277
|
continue;
|
|
240
278
|
}
|
|
@@ -253,7 +291,7 @@ export async function runAgentCore(opts) {
|
|
|
253
291
|
const tc = batch[k];
|
|
254
292
|
const output = await started[k];
|
|
255
293
|
hooks.onToolResult?.(tc, output, null, null, 1); // task 结果是摘要,无 diff
|
|
256
|
-
pushToolResult(history, tc, output);
|
|
294
|
+
pushToolResult(history, tc, output, relprune, lifecycle, scheduler);
|
|
257
295
|
}
|
|
258
296
|
hooks.onToolDone?.();
|
|
259
297
|
i = j;
|
|
@@ -267,7 +305,7 @@ export async function runAgentCore(opts) {
|
|
|
267
305
|
hooks.onToolHeader?.(tc);
|
|
268
306
|
const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
|
|
269
307
|
hooks.onToolResult?.(tc, err, null, null, 1);
|
|
270
|
-
pushToolResult(history, tc, err);
|
|
308
|
+
pushToolResult(history, tc, err, relprune, lifecycle, scheduler);
|
|
271
309
|
i++;
|
|
272
310
|
continue;
|
|
273
311
|
}
|
|
@@ -280,7 +318,19 @@ export async function runAgentCore(opts) {
|
|
|
280
318
|
const output = await executeTool(tc.name, tc.arguments, signal, { skipRollback, dropContext });
|
|
281
319
|
hooks.onToolDone?.();
|
|
282
320
|
hooks.onToolResult?.(tc, output, parsed, preWriteOld, editStartLine);
|
|
283
|
-
pushToolResult(history, tc, output);
|
|
321
|
+
pushToolResult(history, tc, output, relprune, lifecycle, scheduler);
|
|
322
|
+
// 相关性裁剪 mutation 通知:edit_file/write_file 后,该 path 之前的所有 read_file
|
|
323
|
+
// 结果已失效(已不再是文件当前状态)→ stub 为存根。pruner 内部 try/catch + 幂等。
|
|
324
|
+
// 非 mutation 工具(run_command/use_skill/memory_* 等)此处 path="" 不触发。
|
|
325
|
+
// 观察者生命周期:mutation push 后通知 lifecycle 把同 path 的旧 read 标 REFERENCED。
|
|
326
|
+
if (relprune && isMutationTool(tc.name)) {
|
|
327
|
+
const mp = parsed?.path;
|
|
328
|
+
if (typeof mp === 'string' && mp) {
|
|
329
|
+
relprune.observeMutation(history, mp);
|
|
330
|
+
if (lifecycle)
|
|
331
|
+
lifecycle.pushMutation(history, history.length - 1, mp);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
284
334
|
i++;
|
|
285
335
|
}
|
|
286
336
|
}
|
package/dist/config/index.js
CHANGED
|
@@ -81,6 +81,11 @@ ${PLATFORM_NOTE}
|
|
|
81
81
|
- Small steps: break tasks into verifiable sub-steps. Before each step, think clearly about what to change and why.
|
|
82
82
|
- Verify after change: run typecheck / tests / build via run_command to confirm it works. Never claim done without verification.
|
|
83
83
|
|
|
84
|
+
## Step / Turn Economy (read this — saves LLM calls)
|
|
85
|
+
- **Minimize turns**: each user message costs at least one LLM call, and history grows every step until threshold-triggered compact fires (extra call). If a request contains ≥2 independent sub-goals (e.g. "改 X 然后再优化 Y"), ask the user to split them into separate turns rather than chaining both in one go. State this politely: "这条包含 N 个独立目标,建议拆成 N 次对话,以避免上下文膨胀。"
|
|
86
|
+
- **Batch read-only tools in parallel**: in a single assistant turn, emit multiple tool_calls together — consecutive read-only tools (read_file, glob, grep, codegraph, web_search, web_fetch) auto-execute in parallel. Do NOT call them serially across turns when you could emit them together in one turn. This is the single biggest step-saver.
|
|
87
|
+
- **Decide before reading**: do not read files "just to see"; plan the 2-3 file paths you actually need, then emit them as one batched tool_calls turn.
|
|
88
|
+
|
|
84
89
|
## Tool Guidelines
|
|
85
90
|
- See each tool's own description for parameters and usage; this section covers selection strategy and pitfalls only.
|
|
86
91
|
- **Prefer codegraph for code exploration**: when understanding/locating code, tracing call chains, or assessing impact of changes, if a .codegraph/ index exists, use the codegraph tool first (explore to query by question, node to look up a single symbol) — it returns relevant source + call paths in one shot, more accurate and economical than piecing together via read_file/grep. Fall back to read_file / grep / glob only when codegraph is unavailable (no index), misses, you need to see just-changed content, or you're editing a single known small file. Build the index first with \`codegraph init\` if none exists.
|
|
@@ -91,7 +96,8 @@ ${PLATFORM_NOTE}
|
|
|
91
96
|
- Use web_search for information beyond training data (new versions, news, real-time data, latest APIs); don't answer potentially outdated info from memory.
|
|
92
97
|
- Use web_fetch to read a specific URL (a link from search results, or a URL given by the user); it only fetches static HTML — if a JS-rendered page yields no body, switch to web_search (its results include cleaned body text).
|
|
93
98
|
- Call ask_human when you hit a decision point requiring user input (multiple implementation approaches, unclear intent, or needing extra info to proceed) — list options for the user to pick (they can also choose "custom input" to answer freely). Don't call it frequently when the task is clear and you can decide yourself; if the user cancels, switch approach or proceed with available info — don't re-ask the same question.
|
|
94
|
-
- **Drop irrelevant context
|
|
99
|
+
- **Drop irrelevant context proactively**: call drop_context to stub-replace tool results in history that are no longer needed. This is your primary lever for keeping context lean — every step grows history until the threshold-triggered compact fires (which costs an extra LLM call and is more aggressive). Call when any of: (a) you just finished a grep/read sweep and only 1-2 hits mattered; (b) history has >20 tool messages and you are early in the task; (c) you switched sub-goals and the old sub-goal's exploration is dead weight. The call itself adds ~300 tokens, so only call when freed tokens clearly exceed that (≥1 large grep hit or ≥2 medium results). Do NOT call when near completion, when history is short (<10 tool messages), or when the candidate results are still in active use. It preserves tool_call_id pairing (only content changes); the system prompt and current turn are never dropped. Use filters (toolNames / contains) to target precisely.
|
|
100
|
+
- **Observation lifecycle is automatic**: behind the scenes every tool result goes LIVE→REFERENCED→OBSOLETE→STUB. grep/glob/codegraph/web_search/web_fetch are always kept as REFERENCED (never auto-stubbed) because they may surface multiple candidates. read/edit/write results that nobody consumes after two more consumer pushes get auto-stubbed. This is zero-cost (static analysis, no LLM call). You don't need to manage lifecycle yourself — just trust that stale tool results get pruned.
|
|
95
101
|
- **Batch independent tool calls in one turn**: the executor runs ALL returned tool calls before the next LLM call, so emitting [read_file, glob, read_file] together is dramatically cheaper than three separate turns. Default to bundling exploration reads and parallel writes.
|
|
96
102
|
- **Chain shell workflows in a single \`run_command\`**: use \`&&\`, \`;\`, \`|\`, \`>\`, heredocs to fold multi-step scripts (\`mkdir -p x && cat > x/file.ts <<'EOF' ... EOF && npm test\`) into one call. Only emit a follow-up turn when the result forces a decision (error, ambiguous output, branching logic).
|
|
97
103
|
|
|
@@ -155,6 +161,9 @@ export const config = {
|
|
|
155
161
|
includeUsage: process.env.LLM_STREAM_USAGE !== 'false',
|
|
156
162
|
autoCompact: process.env.AUTO_COMPACT !== 'false',
|
|
157
163
|
contextOptimize: process.env.MOCODE_CONTEXT_OPTIMIZE !== 'false',
|
|
164
|
+
contextRelprune: process.env.MOCODE_CONTEXT_RELPRUNE !== 'false',
|
|
165
|
+
contextLifecycle: process.env.MOCODE_LIFECYCLE !== 'false',
|
|
166
|
+
contextBudget: process.env.MOCODE_BUDGET_SCHEDULER !== 'false',
|
|
158
167
|
autoReflect: process.env.AUTO_REFLECT !== 'false',
|
|
159
168
|
reflectEveryN: Number(process.env.REFLECT_EVERY_N) || 5,
|
|
160
169
|
maxSteps: Number(process.env.MAX_STEPS) || 200,
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// 五区 Context Budget Scheduler。
|
|
2
|
+
//
|
|
3
|
+
// 目的:把当前四道独立闸(cap / pipeline / relevance / maybeCompact)统一为
|
|
4
|
+
// 「先看预算报告,再按 ROI 排序调度」的单一入口。
|
|
5
|
+
//
|
|
6
|
+
// 设计原则(对应用户拍板的设计方案):
|
|
7
|
+
// 1. 五区分账:不是把 history 当一个黑盒,而是把上下文切成 5 区逐区配预算
|
|
8
|
+
// (System / History / Tool-Recent / Tool-Old / Summary) + 1 个 Reserve。
|
|
9
|
+
// Tool 内部再分 Hot/Cold:
|
|
10
|
+
// - Cold Tool(老化区,scheduler 优先压缩——ROI 最低,LLM 复述成本最低)
|
|
11
|
+
// - Hot Tool(当前±N 步内,scheduler **不主动 stub**——避免干扰 agent 当前步)
|
|
12
|
+
// 注:**Hot 区「scheduler 不主动」≠ 「绝对不动」**。lifecycle age stub、pruner
|
|
13
|
+
// same-path 替代、agent 的 drop_context 工具仍可动 Hot 区;它们语义更精细(知道
|
|
14
|
+
// 哪条已无关),不会盲目 stub。Scheduler 只在更粗的层面决策,粗判断不踩精细判断。
|
|
15
|
+
// Hot 区超预算时 scheduler 仅 cap(降单条上限,不丢内容)。
|
|
16
|
+
// 2. ROI 排序:History > Summary > Hot Tool > Cold Tool。压缩时先动 Cold Tool
|
|
17
|
+
// (LLM 复述成本最低),再动 History(摘要成本高);Hot Tool 与 System 雷打不动(指 scheduler 层面)。
|
|
18
|
+
// 3. 零行为变化兜底:调度器不是闸,而是「报告 + 决策」——执行仍复用现有
|
|
19
|
+
// cap / pipeline / relevance / compact / lifecycle / drop_context 实现,只是触发条件更精准。
|
|
20
|
+
//
|
|
21
|
+
// 依赖:本文件是叶子级,只依赖 ChatMessage / estimateTokens,绝不反向依赖
|
|
22
|
+
// agent / session / compact(避免循环与耦合)。具体执行动作由 agent/core.ts
|
|
23
|
+
// 拿 ScheduleAction[] 去调现有闸。
|
|
24
|
+
//
|
|
25
|
+
// 开关(MOCODE_BUDGET_SCHEDULER):默认 true。false 时 agent/core.ts 走老路径
|
|
26
|
+
// (直接 maybeCompact),完全跳过本模块,零行为变化。
|
|
27
|
+
import { estimateMessagesTokens, estimateTokens } from '../llm/index.js';
|
|
28
|
+
/** 五区分账(占比对齐 CONTEXT_WINDOW)。顺序固定,便于遍历。 */
|
|
29
|
+
export const BUDGET_LAYERS = [
|
|
30
|
+
'system',
|
|
31
|
+
'history',
|
|
32
|
+
'toolRecent', // Hot Tool
|
|
33
|
+
'toolOld', // Cold Tool
|
|
34
|
+
'summary',
|
|
35
|
+
'reserve', // Reserve(不占内容,只占预算分配;5%)
|
|
36
|
+
];
|
|
37
|
+
/** 占比(总和 = 0.95,留 5% 给 Reserve)。对齐用户修正版:
|
|
38
|
+
* Recent Tool 25%(原 40% 偏大,因 Hot 区不该被压)+ Old Tool 25% 同等 +
|
|
39
|
+
* History 20% + System 15% + Summary 10%(平时 0 占用,触发后才用) */
|
|
40
|
+
export const BUDGET_RATIO = {
|
|
41
|
+
system: 0.15,
|
|
42
|
+
history: 0.20,
|
|
43
|
+
toolRecent: 0.25,
|
|
44
|
+
toolOld: 0.25,
|
|
45
|
+
summary: 0.10,
|
|
46
|
+
reserve: 0.05,
|
|
47
|
+
};
|
|
48
|
+
/** Hot/Cold 划分:当前 step 起往前 HOT_TURN_WINDOW 个 user turn 之内的工具结果视为 Hot,
|
|
49
|
+
* 之外的视为 Cold。0 = 全 Cold(等同老路径);越短 Hot 越小,压缩越激进。 */
|
|
50
|
+
export const HOT_TURN_WINDOW = 4;
|
|
51
|
+
/** 工具消息推入历史后,经过的「消费者 push 次数」即 age。
|
|
52
|
+
* Cold 区内:age ≥ TOOL_OLD_AGE 的非观察类工具结果可被调度器就地 stub。
|
|
53
|
+
* 默认 2 = 跨过 2 个消费者 push 仍未被消费,等同 lifecycle 的 DEFAULT_AGE_THRESHOLD。 */
|
|
54
|
+
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
|
+
function msgTokens(m) {
|
|
69
|
+
const c = m.content;
|
|
70
|
+
const tcs = m.tool_calls;
|
|
71
|
+
let extra = toText(c);
|
|
72
|
+
if (tcs)
|
|
73
|
+
for (const tc of tcs)
|
|
74
|
+
extra += tc?.function?.arguments ?? '';
|
|
75
|
+
// 与 llm.estimateTokens 同公式(CJK 1/字,ASCII 1/4字);保证调度器评估与系统估算口径一致。
|
|
76
|
+
return 4 + estimateTokens(extra);
|
|
77
|
+
}
|
|
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
|
+
/** 从 idx 处向前数第 N 个 user turn 的边界 index(含该 user 之后的内容)。
|
|
87
|
+
* 用于把 history 切成 Hot 区(tail 一段,endExclusive=history.length)与 Cold 区(0..endExclusive)。
|
|
88
|
+
* 若 N 个 user 不足,Hot 区 = history.length(全保护);Cold 区空,无压缩目标。 */
|
|
89
|
+
export function userTurnBoundary(history, window) {
|
|
90
|
+
let seen = 0;
|
|
91
|
+
for (let i = history.length - 1; i >= 1; i--) {
|
|
92
|
+
if (history[i].role === 'user') {
|
|
93
|
+
seen++;
|
|
94
|
+
if (seen >= window)
|
|
95
|
+
return i;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return 1; // 没攒够 N 个 user 之前的全归 Cold(history[0] system 不动)
|
|
99
|
+
}
|
|
100
|
+
/** 评估当前 history 的五区预算(纯函数,改不动 history)。
|
|
101
|
+
* 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。 */
|
|
102
|
+
export function evaluateBudget(history, window, step = 0) {
|
|
103
|
+
const layers = {};
|
|
104
|
+
for (const k of BUDGET_LAYERS) {
|
|
105
|
+
const budget = Math.floor(BUDGET_RATIO[k] * window);
|
|
106
|
+
layers[k] = { actual: 0, budget, overBudget: false, overRatio: 0 };
|
|
107
|
+
}
|
|
108
|
+
const sysMsg = history[0];
|
|
109
|
+
if (sysMsg)
|
|
110
|
+
layers.system.actual = msgTokens(sysMsg);
|
|
111
|
+
// Summary 检测:role:'system' 且不是 history[0] 的,视为摘要(compact.ts 摘要插 index 1)。
|
|
112
|
+
// 简单启发:若 history[1]?.role === 'system' 且 content 含「# 会话摘要」特征串,计入 summary。
|
|
113
|
+
// 命中时循环跳过 i=1;不命中时当作普通 message(罕见,落到下方 user/assistant 分支)。
|
|
114
|
+
let summaryHit = false;
|
|
115
|
+
if (history.length > 1 && history[1].role === 'system') {
|
|
116
|
+
const c1 = toText(history[1].content);
|
|
117
|
+
if (c1.startsWith('# 会话摘要') || c1.includes('会话摘要')) {
|
|
118
|
+
layers.summary.actual = msgTokens(history[1]);
|
|
119
|
+
summaryHit = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 划 Hot/Cold 边界
|
|
123
|
+
const hotStart = userTurnBoundary(history, HOT_TURN_WINDOW);
|
|
124
|
+
// history 区 = 减去 summary + tool 单算;tool 按 Hot/Cold 分。
|
|
125
|
+
for (let i = 1; i < history.length; i++) {
|
|
126
|
+
const m = history[i];
|
|
127
|
+
if (i === 1 && summaryHit)
|
|
128
|
+
continue; // summary 已单独算过
|
|
129
|
+
if (m.role === 'tool') {
|
|
130
|
+
const t = msgTokens(m);
|
|
131
|
+
if (i >= hotStart)
|
|
132
|
+
layers.toolRecent.actual += t;
|
|
133
|
+
else
|
|
134
|
+
layers.toolOld.actual += t;
|
|
135
|
+
}
|
|
136
|
+
else if (m.role !== 'system') {
|
|
137
|
+
// user / assistant 全部计入 history(对话轨迹)
|
|
138
|
+
layers.history.actual += msgTokens(m);
|
|
139
|
+
}
|
|
140
|
+
// 其它 system(几乎不存在)跳过
|
|
141
|
+
}
|
|
142
|
+
// 计算 overBudget + overRatio
|
|
143
|
+
const triggers = [];
|
|
144
|
+
for (const k of BUDGET_LAYERS) {
|
|
145
|
+
const lb = layers[k];
|
|
146
|
+
if (lb.actual > lb.budget) {
|
|
147
|
+
lb.overBudget = true;
|
|
148
|
+
lb.overRatio = (lb.actual - lb.budget) / Math.max(lb.budget, 1);
|
|
149
|
+
triggers.push(k);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// 按 overRatio 降序
|
|
153
|
+
triggers.sort((a, b) => layers[b].overRatio - layers[a].overRatio);
|
|
154
|
+
const total = BUDGET_LAYERS.reduce((s, k) => s + (k === 'reserve' ? 0 : layers[k].actual), 0);
|
|
155
|
+
const totalOver = total >= 0.85 * window;
|
|
156
|
+
return {
|
|
157
|
+
step,
|
|
158
|
+
total,
|
|
159
|
+
window,
|
|
160
|
+
layers,
|
|
161
|
+
triggers,
|
|
162
|
+
totalOver,
|
|
163
|
+
hotBoundary: hotStart,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** 根据 BudgetReport 生成调度动作(从轻到重,直至总占用回落到 0.85 以下)。
|
|
167
|
+
* 规则:
|
|
168
|
+
* - system 超 → warn(不压,配置问题不是内容问题)
|
|
169
|
+
* - toolOld 超 → 先 L1(中截超大)→ L2(same-path 已有 relevance)→ L3(age stub,新增)
|
|
170
|
+
* - toolRecent 超 → cap(只降低单条上限,不 stub)
|
|
171
|
+
* - history 超 或 totalOver → compact_history(调 maybeCompact / compactHistory)
|
|
172
|
+
* - summary 超 → 不动(摘要本身就压缩产物,删它等于丢历史,只能放任或扩 Recent 预算) */
|
|
173
|
+
export function scheduleActions(report) {
|
|
174
|
+
const actions = [];
|
|
175
|
+
const { layers, totalOver, total } = report;
|
|
176
|
+
const headroom = 0.85 * report.window - total;
|
|
177
|
+
// system 超 → warn,不是 schedule 目标
|
|
178
|
+
if (layers.system.overBudget) {
|
|
179
|
+
actions.push({
|
|
180
|
+
kind: 'warn',
|
|
181
|
+
layer: 'system',
|
|
182
|
+
reason: `System prompt 超预算(${layers.system.actual} > ${layers.system.budget}),请检查配置/MOCODE.md`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
// 渐进:toolOld(轻→重)
|
|
186
|
+
if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.1) {
|
|
187
|
+
actions.push({ kind: 'shrink_cold_tools', level: 1 });
|
|
188
|
+
}
|
|
189
|
+
if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.3) {
|
|
190
|
+
actions.push({ kind: 'shrink_cold_tools', level: 2 });
|
|
191
|
+
}
|
|
192
|
+
if (layers.toolOld.overBudget && layers.toolOld.overRatio > 0.6) {
|
|
193
|
+
actions.push({ kind: 'shrink_cold_tools', level: 3 });
|
|
194
|
+
}
|
|
195
|
+
// Hot 区只 cap
|
|
196
|
+
if (layers.toolRecent.overBudget && layers.toolRecent.overRatio > 0.15) {
|
|
197
|
+
actions.push({ kind: 'cap_hot_tools', aggressive: layers.toolRecent.overRatio > 0.5 });
|
|
198
|
+
}
|
|
199
|
+
// History / total 超 → 摘要(最贵);headroom < -2K 真正触发,让 cold tools 先动
|
|
200
|
+
if ((layers.history.overBudget || totalOver) && headroom < -2000) {
|
|
201
|
+
actions.push({ kind: 'compact_history' });
|
|
202
|
+
}
|
|
203
|
+
// 排序(同 kind 已在上面排好):warn → cold L1→L2→L3 → cap_hot → compact_history
|
|
204
|
+
return actions;
|
|
205
|
+
}
|
|
206
|
+
/** 拍平成人类可读(供 /context 命令与 check-budget 脚本用)。 */
|
|
207
|
+
export function formatReport(report) {
|
|
208
|
+
const lines = [];
|
|
209
|
+
lines.push(`step ${report.step} total ${report.total}/${report.window} (${((report.total / report.window) * 100).toFixed(1)}%)`);
|
|
210
|
+
for (const k of BUDGET_LAYERS) {
|
|
211
|
+
const lb = report.layers[k];
|
|
212
|
+
const pct = lb.budget > 0 ? ((lb.actual / lb.budget) * 100).toFixed(0) : '-';
|
|
213
|
+
const flag = lb.overBudget ? '⚠' : ' ';
|
|
214
|
+
lines.push(` ${flag} ${k.padEnd(10)} ${String(lb.actual).padStart(6)} / ${String(lb.budget).padStart(6)} (${pct.padStart(3)}%)`);
|
|
215
|
+
}
|
|
216
|
+
if (report.triggers.length > 0) {
|
|
217
|
+
lines.push(` triggers: ${report.triggers.join(' → ')}`);
|
|
218
|
+
}
|
|
219
|
+
return lines.join('\n');
|
|
220
|
+
}
|
|
221
|
+
/** 便捷:把 history 一把估成总 token 数(给 cap.js 等复用,避免重复实现)。
|
|
222
|
+
* 注意:此处是粗估(只看 content 长度),不区分五区——只用于「系统层整体还剩多少」快查。 */
|
|
223
|
+
export function quickEstimate(history) {
|
|
224
|
+
return estimateMessagesTokens(history);
|
|
225
|
+
}
|
package/dist/context/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
// context/ barrel:Context Optimization Pipeline。
|
|
1
|
+
// context/ barrel:Context Optimization Pipeline + 五区 Budget Scheduler。
|
|
2
2
|
//
|
|
3
3
|
// 单一入口 optimizeToolResult(agent/core.ts pushToolResult 调)接管"工具结果进 LLM 前"的表示。
|
|
4
|
+
// 单一入口 runScheduler(agent/core.ts 步前调)接管"何时调用哪一闸"的调度。
|
|
4
5
|
// 不调 LLM、不碰 Tool Calling schema / executeTool / tool_call_id 配对 / TUI 渲染
|
|
5
6
|
// (叶子级:仅 stdlib + tools/constants + session/compact 的 capToolResultForHistory 兜底 + config 开关)。
|
|
6
7
|
//
|
|
7
|
-
// 见 CLAUDE.md「Context Optimization Pipeline」节。
|
|
8
|
+
// 见 CLAUDE.md「Context Optimization Pipeline」节 +「Context Budget Scheduler」节。
|
|
8
9
|
export { optimizeToolResult } from './pipeline.js';
|
|
9
10
|
export { classify, knownToolKinds } from './classifier.js';
|
|
10
11
|
export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
|
|
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';
|