mocode-ai 0.6.4 → 0.6.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 +19 -16
- package/dist/context/budget.js +8 -13
- package/dist/context/encoders/command.js +201 -0
- package/dist/context/encoders/index.js +2 -2
- package/dist/context/encoders/log.js +2 -52
- package/dist/context/token-calibration.js +104 -0
- package/dist/llm/index.js +42 -99
- package/dist/llm/think-filter.js +90 -0
- package/dist/repl/index.js +0 -2
- package/dist/session/compact.js +18 -22
- package/dist/session/scheduler.js +6 -5
- package/dist/tools/builtins/run-command.js +29 -9
- 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,
|
|
8
|
+
import { chat, estimatePromptTokens, planChatTools, chatTools, } 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';
|
|
@@ -18,6 +18,7 @@ import { isToolResultSuccess } from '../context/utils.js';
|
|
|
18
18
|
import { config } from '../config/index.js';
|
|
19
19
|
import { jailResolve } from '../sandbox/index.js';
|
|
20
20
|
import { createLifecycleEngine } from '../context/lifecycle.js';
|
|
21
|
+
import { getTokenCalibration, updateTokenCalibration, } from '../context/token-calibration.js';
|
|
21
22
|
/** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
|
|
22
23
|
function parseArgs(raw) {
|
|
23
24
|
try {
|
|
@@ -236,15 +237,23 @@ export async function runAgentCore(opts) {
|
|
|
236
237
|
abortRestore();
|
|
237
238
|
return { completed: false, finalText: null };
|
|
238
239
|
}
|
|
240
|
+
// 本步只计算一次实际工具集合,调度、请求和 usage 校准必须使用完全相同的 schema。
|
|
241
|
+
const activeTools = opts.toolsOverride
|
|
242
|
+
?? (getAgentMode() === 'plan' ? planChatTools : chatTools);
|
|
243
|
+
const requestBaseURL = config.baseURL;
|
|
244
|
+
const requestModel = config.model;
|
|
245
|
+
const storedCalibration = getTokenCalibration(requestBaseURL, requestModel, activeTools);
|
|
246
|
+
runtimeContextState.correction = storedCalibration.correction;
|
|
247
|
+
runtimeContextState.calibrationSamples = storedCalibration.samples;
|
|
239
248
|
// 步前:五区 Budget Scheduler 决策——按 ROI 调度(冷工具优先 / history 摘要最后)。
|
|
240
249
|
// 开关关闭(scheduler=null)时退化回原 maybeCompact 路径,零行为变化。
|
|
241
250
|
// 此时 spinner 已停,通知行干净。
|
|
242
251
|
let historyRebuilt = false;
|
|
243
252
|
if (scheduler) {
|
|
244
|
-
historyRebuilt = await scheduler.runStep(history, step);
|
|
253
|
+
historyRebuilt = await scheduler.runStep(history, step, activeTools);
|
|
245
254
|
}
|
|
246
255
|
else {
|
|
247
|
-
const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState);
|
|
256
|
+
const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState, activeTools);
|
|
248
257
|
historyRebuilt = compactResult?.historyRebuilt === true;
|
|
249
258
|
}
|
|
250
259
|
// compact 用新消息数组原地重建 history 后,旧 lifecycle 的数字 index 已全部失效。
|
|
@@ -259,11 +268,7 @@ export async function runAgentCore(opts) {
|
|
|
259
268
|
lastChar = '';
|
|
260
269
|
let result;
|
|
261
270
|
try {
|
|
262
|
-
|
|
263
|
-
// (auto=chatTools 全量;plan=planChatTools 只读子集)。模式由 src/agent/mode.ts 单一持有。
|
|
264
|
-
// 调用方可传 toolsOverride 覆盖(子 agent 受限工具子集)。
|
|
265
|
-
result = await chat(history, { onText, onToolCall }, signal, opts.toolsOverride ??
|
|
266
|
-
(getAgentMode() === 'plan' ? planChatTools : undefined));
|
|
271
|
+
result = await chat(history, { onText, onToolCall }, signal, activeTools);
|
|
267
272
|
}
|
|
268
273
|
catch (e) {
|
|
269
274
|
// 中断(用户运行中 Ctrl+C):chat() 抛 AbortError(signal.aborted)→ 还原 history + 模式 + return(不抛)。
|
|
@@ -278,15 +283,13 @@ export async function runAgentCore(opts) {
|
|
|
278
283
|
}
|
|
279
284
|
runtimeContextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
280
285
|
addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
// 钳位 [0.5, 2.0]:防单次异常值(极短回复 / 空 history)导致系数跳变。
|
|
286
|
+
// 用本次实际发送的 tools 计算分母,再以 EWMA 更新 provider/model/tool-set 校准。
|
|
287
|
+
// 只持久化比例与样本数;无 usage 或短 prompt 时保持既有值。
|
|
284
288
|
if (result.usage?.promptTokens && result.usage.promptTokens > 100) {
|
|
285
|
-
const estimated =
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
289
|
+
const estimated = estimatePromptTokens(history, activeTools);
|
|
290
|
+
const updated = updateTokenCalibration(requestBaseURL, requestModel, activeTools, estimated, result.usage.promptTokens);
|
|
291
|
+
runtimeContextState.correction = updated.correction;
|
|
292
|
+
runtimeContextState.calibrationSamples = updated.samples;
|
|
290
293
|
}
|
|
291
294
|
hooks.onChatDone?.(); // 主 agent:spinner.stop()
|
|
292
295
|
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
package/dist/context/budget.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
//
|
|
25
25
|
// 开关(MOCODE_BUDGET_SCHEDULER):默认 true。false 时 agent/core.ts 走老路径
|
|
26
26
|
// (直接 maybeCompact),完全跳过本模块,零行为变化。
|
|
27
|
-
import { estimateMessagesTokens,
|
|
27
|
+
import { chatTools, estimateMessagesTokens, estimateToolSchemaTokens, messageTokens, } from '../llm/index.js';
|
|
28
28
|
import { toText } from './utils.js';
|
|
29
29
|
/** 五区分账(占比对齐 CONTEXT_WINDOW)。顺序固定,便于遍历。 */
|
|
30
30
|
export const BUDGET_LAYERS = [
|
|
@@ -54,14 +54,8 @@ export const HOT_TURN_WINDOW = 4;
|
|
|
54
54
|
* 默认 2 = 跨过 2 个消费者 push 仍未被消费,等同 lifecycle 的 DEFAULT_AGE_THRESHOLD。 */
|
|
55
55
|
export const TOOL_OLD_AGE = 2;
|
|
56
56
|
function msgTokens(m) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
let extra = toText(c);
|
|
60
|
-
if (tcs)
|
|
61
|
-
for (const tc of tcs)
|
|
62
|
-
extra += tc?.function?.arguments ?? '';
|
|
63
|
-
// 与 llm.estimateTokens 同公式(CJK 1/字,ASCII 1/4字);保证调度器评估与系统估算口径一致。
|
|
64
|
-
return 4 + estimateTokens(extra);
|
|
57
|
+
// 与请求预估复用同一实现,避免角色结构开销、多模态和 tool_calls 在两个预算路径中漂移。
|
|
58
|
+
return messageTokens(m);
|
|
65
59
|
}
|
|
66
60
|
/** 从 idx 处向前数第 N 个 user turn 的边界 index(含该 user 之后的内容)。
|
|
67
61
|
* 用于把 history 切成 Hot 区(tail 一段,endExclusive=history.length)与 Cold 区(0..endExclusive)。
|
|
@@ -79,8 +73,9 @@ export function userTurnBoundary(history, window) {
|
|
|
79
73
|
}
|
|
80
74
|
/** 评估当前 history 的五区预算(纯函数,改不动 history)。
|
|
81
75
|
* 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。
|
|
82
|
-
* correction:API 实测 / 估算的校正系数(默认 1);>1 表示粗估偏低,乘以系数后 actual 更接近真实值。
|
|
83
|
-
|
|
76
|
+
* correction:API 实测 / 估算的校正系数(默认 1);>1 表示粗估偏低,乘以系数后 actual 更接近真实值。
|
|
77
|
+
* activeTools 必须与下一次 chat() 实际发送的工具集合一致,避免 plan/子 agent 误算 schema。 */
|
|
78
|
+
export function evaluateBudget(history, window, step = 0, correction = 1, activeTools = chatTools) {
|
|
84
79
|
const layers = {};
|
|
85
80
|
for (const k of BUDGET_LAYERS) {
|
|
86
81
|
const budget = Math.floor(BUDGET_RATIO[k] * window);
|
|
@@ -89,8 +84,8 @@ export function evaluateBudget(history, window, step = 0, correction = 1) {
|
|
|
89
84
|
// 校正后的 token 数:raw * correction,最小 1(raw > 0 时)。
|
|
90
85
|
const adj = (raw) => (raw > 0 ? Math.max(1, Math.round(raw * correction)) : 0);
|
|
91
86
|
const sysMsg = history[0];
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
// 工具 schema 与 system prompt 同属请求固定开销;必须计入总量才能可靠触发压缩。
|
|
88
|
+
layers.system.actual = adj((sysMsg ? msgTokens(sysMsg) : 0) + estimateToolSchemaTokens(activeTools));
|
|
94
89
|
// Summary 检测:role:'system' 且不是 history[0] 的,视为摘要(compact.ts 摘要插 index 1)。
|
|
95
90
|
// 简单启发:若 history[1]?.role === 'system' 且 content 含「# 会话摘要」特征串,计入 summary。
|
|
96
91
|
// 命中时循环跳过 i=1;不命中时当作普通 message(罕见,落到下方 user/assistant 分支)。
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { stripAnsi } from './_util.js';
|
|
2
|
+
const PAREN_DIAGNOSTIC = /^(.*)\((\d+),(\d+)\):\s*(error|warning|warn|fatal|note)\b\s*:?\s*(.*)$/i;
|
|
3
|
+
const COL_DIAGNOSTIC = /^(.*):(\d+):(\d+):\s*(error|warning|warn|fatal|note)\b\s*:?\s*(.*)$/i;
|
|
4
|
+
const LINE_DIAGNOSTIC = /^(.*):(\d+):\s*(error|warning|warn|fatal|note)\b\s*:?\s*(.*)$/i;
|
|
5
|
+
const PASS_LINE = /^\s*(?:PASS\b|PASSED\b|✓|✔|✅|ok\b)/i;
|
|
6
|
+
const FAIL_LINE = /^\s*(?:FAIL\b|FAILED\b|ERROR\b|✗|×|❌|not ok\b|●)/i;
|
|
7
|
+
const TEST_SUMMARY = /^\s*(?:Test Suites?|Tests?|Ran\s+\d+|=+\s|\d+\s+(?:passed|failed|errors?))/i;
|
|
8
|
+
const IMPORTANT_LINE = /\b(?:error|failed|failure|fatal|exception|panic)\b|not ok|[✗×❌]/i;
|
|
9
|
+
const TEST_COMMAND = /\b(?:test|vitest|jest|pytest|mocha|ava|tap|cargo\s+test|go\s+test|dotnet\s+test)\b/i;
|
|
10
|
+
function parseDiagnostic(line) {
|
|
11
|
+
const m = PAREN_DIAGNOSTIC.exec(line) ?? COL_DIAGNOSTIC.exec(line);
|
|
12
|
+
if (m) {
|
|
13
|
+
return { file: m[1], line: m[2], column: m[3], severity: m[4], message: m[5], continuation: [] };
|
|
14
|
+
}
|
|
15
|
+
const lineOnly = LINE_DIAGNOSTIC.exec(line);
|
|
16
|
+
if (!lineOnly)
|
|
17
|
+
return null;
|
|
18
|
+
return {
|
|
19
|
+
file: lineOnly[1],
|
|
20
|
+
line: lineOnly[2],
|
|
21
|
+
severity: lineOnly[3],
|
|
22
|
+
message: lineOnly[4],
|
|
23
|
+
continuation: [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function splitStatus(text) {
|
|
27
|
+
const lines = text.split('\n');
|
|
28
|
+
const status = /^\[(?:退出码 [^\]]+|已中断|超时,已终止)\]$/.test(lines[0] ?? '')
|
|
29
|
+
? lines.shift()
|
|
30
|
+
: null;
|
|
31
|
+
return { status, lines };
|
|
32
|
+
}
|
|
33
|
+
function formatDiagnostics(lines) {
|
|
34
|
+
const diagnostics = [];
|
|
35
|
+
const other = [];
|
|
36
|
+
let current = null;
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
const diagnostic = parseDiagnostic(line);
|
|
39
|
+
if (diagnostic) {
|
|
40
|
+
diagnostics.push(diagnostic);
|
|
41
|
+
current = diagnostic;
|
|
42
|
+
}
|
|
43
|
+
else if (current && (/^\s+/.test(line) || /^[\^~|]/.test(line))) {
|
|
44
|
+
current.continuation.push(line);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
current = null;
|
|
48
|
+
other.push(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (diagnostics.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
const groups = new Map();
|
|
54
|
+
for (const diagnostic of diagnostics) {
|
|
55
|
+
const group = groups.get(diagnostic.file) ?? [];
|
|
56
|
+
group.push(diagnostic);
|
|
57
|
+
groups.set(diagnostic.file, group);
|
|
58
|
+
}
|
|
59
|
+
const errors = diagnostics.filter((d) => /^(?:error|fatal)$/i.test(d.severity)).length;
|
|
60
|
+
const warnings = diagnostics.filter((d) => /^(?:warning|warn)$/i.test(d.severity)).length;
|
|
61
|
+
const out = [`# Build diagnostics · ${diagnostics.length} issues · ${groups.size} files · command-encoded`];
|
|
62
|
+
if (errors || warnings)
|
|
63
|
+
out.push(`# ${errors} errors · ${warnings} warnings`);
|
|
64
|
+
for (const [file, items] of groups) {
|
|
65
|
+
out.push(`${file}:`);
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
const location = item.column ? `${item.line}:${item.column}` : item.line;
|
|
68
|
+
out.push(` ${location}: ${item.severity}${item.message ? `: ${item.message}` : ''}`);
|
|
69
|
+
for (const continuation of item.continuation)
|
|
70
|
+
out.push(` ${continuation}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (other.some((line) => line.length > 0))
|
|
74
|
+
out.push('# Other output', ...other);
|
|
75
|
+
return { text: out.join('\n'), count: diagnostics.length };
|
|
76
|
+
}
|
|
77
|
+
function formatTests(lines, command) {
|
|
78
|
+
const blocks = [];
|
|
79
|
+
const other = [];
|
|
80
|
+
let current = null;
|
|
81
|
+
let markers = 0;
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const kind = FAIL_LINE.test(line) ? 'fail' : PASS_LINE.test(line) ? 'pass' : null;
|
|
84
|
+
if (kind) {
|
|
85
|
+
current = { kind, lines: [line] };
|
|
86
|
+
blocks.push(current);
|
|
87
|
+
markers++;
|
|
88
|
+
}
|
|
89
|
+
else if (current && line.length > 0 && /^\s+/.test(line) && !TEST_SUMMARY.test(line)) {
|
|
90
|
+
current.lines.push(line);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
current = null;
|
|
94
|
+
other.push(line);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (markers === 0 || (markers < 2 && !TEST_COMMAND.test(command)))
|
|
98
|
+
return null;
|
|
99
|
+
const failed = blocks.filter((block) => block.kind === 'fail');
|
|
100
|
+
const passed = blocks.filter((block) => block.kind === 'pass');
|
|
101
|
+
const out = [`# Test results · ${passed.length} passed · ${failed.length} failed · command-encoded`];
|
|
102
|
+
if (failed.length) {
|
|
103
|
+
out.push(`# Failed tests (${failed.length})`);
|
|
104
|
+
for (const block of failed)
|
|
105
|
+
out.push(...block.lines);
|
|
106
|
+
}
|
|
107
|
+
if (passed.length) {
|
|
108
|
+
out.push(`# Passed tests (${passed.length})`);
|
|
109
|
+
for (const block of passed)
|
|
110
|
+
out.push(...block.lines);
|
|
111
|
+
}
|
|
112
|
+
if (other.some((line) => line.length > 0))
|
|
113
|
+
out.push('# Test summary / other output', ...other);
|
|
114
|
+
return { text: out.join('\n'), count: markers };
|
|
115
|
+
}
|
|
116
|
+
function collapseDuplicateLines(text) {
|
|
117
|
+
const lines = text.split('\n');
|
|
118
|
+
const out = [];
|
|
119
|
+
let runs = 0;
|
|
120
|
+
for (let i = 0; i < lines.length;) {
|
|
121
|
+
let end = i + 1;
|
|
122
|
+
while (end < lines.length && lines[end] === lines[i])
|
|
123
|
+
end++;
|
|
124
|
+
const count = end - i;
|
|
125
|
+
if (count >= 3) {
|
|
126
|
+
out.push(`${lines[i]} [×${count}]`);
|
|
127
|
+
runs++;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
for (let j = i; j < end; j++)
|
|
131
|
+
out.push(lines[j]);
|
|
132
|
+
}
|
|
133
|
+
i = end;
|
|
134
|
+
}
|
|
135
|
+
return { text: out.join('\n'), runs };
|
|
136
|
+
}
|
|
137
|
+
function errorTail(text, max) {
|
|
138
|
+
if (max <= 0)
|
|
139
|
+
return '';
|
|
140
|
+
const lines = text.split('\n');
|
|
141
|
+
let important = -1;
|
|
142
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
143
|
+
if (IMPORTANT_LINE.test(lines[i])) {
|
|
144
|
+
important = i;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (important < 0)
|
|
149
|
+
return text.slice(-max);
|
|
150
|
+
const fromError = lines.slice(Math.max(0, important - 1)).join('\n');
|
|
151
|
+
if (fromError.length <= max)
|
|
152
|
+
return fromError;
|
|
153
|
+
const joiner = '\n…[错误详情中段省略]…\n';
|
|
154
|
+
const errorHead = Math.max(0, Math.floor((max - joiner.length) * 0.65));
|
|
155
|
+
const finalTail = Math.max(0, max - joiner.length - errorHead);
|
|
156
|
+
return fromError.slice(0, errorHead) + joiner + fromError.slice(-finalTail);
|
|
157
|
+
}
|
|
158
|
+
function truncateCommand(text, budget) {
|
|
159
|
+
if (!budget || text.length <= budget)
|
|
160
|
+
return { text, truncated: false };
|
|
161
|
+
const marker = '\n…[command 输出已结构化截断;保留开头与错误尾部]…\n';
|
|
162
|
+
const available = budget - marker.length;
|
|
163
|
+
if (available <= 0)
|
|
164
|
+
return { text: marker.slice(0, budget), truncated: true };
|
|
165
|
+
const headSize = Math.floor(available * 0.45);
|
|
166
|
+
const tailSize = available - headSize;
|
|
167
|
+
return {
|
|
168
|
+
text: text.slice(0, headSize) + marker + errorTail(text, tailSize),
|
|
169
|
+
truncated: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** run_command 专用 encoder:构建诊断分文件、测试结果分 pass/fail,并按错误尾部优先截断。 */
|
|
173
|
+
export const commandEncoder = {
|
|
174
|
+
kind: 'log',
|
|
175
|
+
encode({ output, args, budget }) {
|
|
176
|
+
const stripped = stripAnsi(output).replace(/\r\n?/g, '\n');
|
|
177
|
+
const { status, lines } = splitStatus(stripped);
|
|
178
|
+
const command = typeof args?.command === 'string' ? args.command : '';
|
|
179
|
+
const diagnostics = formatDiagnostics(lines);
|
|
180
|
+
const tests = diagnostics ? null : formatTests(lines, command);
|
|
181
|
+
const structured = diagnostics?.text ?? tests?.text ?? lines.join('\n');
|
|
182
|
+
const withStatus = status ? `${status}\n${structured}` : structured;
|
|
183
|
+
const collapsed = collapseDuplicateLines(withStatus);
|
|
184
|
+
const fitted = truncateCommand(collapsed.text, budget);
|
|
185
|
+
const mode = diagnostics ? `build:${diagnostics.count}` : tests ? `tests:${tests.count}` : 'generic';
|
|
186
|
+
const notes = [mode, 'ANSI stripped'];
|
|
187
|
+
if (collapsed.runs)
|
|
188
|
+
notes.push(`${collapsed.runs} dup runs collapsed`);
|
|
189
|
+
if (fitted.truncated)
|
|
190
|
+
notes.push('head+error-tail truncated');
|
|
191
|
+
return {
|
|
192
|
+
text: fitted.text,
|
|
193
|
+
meta: {
|
|
194
|
+
kind: 'log',
|
|
195
|
+
originalLen: output.length,
|
|
196
|
+
encodedLen: fitted.text.length,
|
|
197
|
+
note: notes.join(', '),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
};
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { passthroughEncoder } from './passthrough.js';
|
|
12
12
|
import { treeEncoder } from './tree.js';
|
|
13
13
|
import { searchEncoder } from './search.js';
|
|
14
|
-
import {
|
|
14
|
+
import { commandEncoder } from './command.js';
|
|
15
15
|
import { tableEncoder } from './table.js';
|
|
16
16
|
import { memoryEncoder } from './memory.js';
|
|
17
17
|
import { codeEncoder } from './code.js';
|
|
@@ -22,7 +22,7 @@ export const builtinEncoders = [
|
|
|
22
22
|
passthroughEncoder,
|
|
23
23
|
treeEncoder,
|
|
24
24
|
searchEncoder,
|
|
25
|
-
|
|
25
|
+
commandEncoder,
|
|
26
26
|
tableEncoder,
|
|
27
27
|
memoryEncoder,
|
|
28
28
|
codeEncoder,
|
|
@@ -1,52 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* 输入:run_command 返回的 `[退出码 N]\n<合并 stdout+stderr>`,可能含 ANSI(tsc --pretty / 测试框架)、
|
|
5
|
-
* 重复行(构建 / 编译日志)、尾部 `...(输出已截断)`、`[已中断]` / `[超时,已终止]` 前缀。
|
|
6
|
-
* 输出:去 ANSI CSI 序列 + 连续重复行折叠;行顺序不变(退出码头恒在首、错误行与尾部原位保留)。
|
|
7
|
-
*
|
|
8
|
-
* 不变量(离线脚本断言):退出码行 `[退出码 N]` / `[已中断]` / `[超时,已终止]` 保留;
|
|
9
|
-
* 重复行以 `[×N]` 标注计数(语义不丢——LLM 仍知该行重复 N 次);ANSI 去除语义无损(颜色码不含信息)。
|
|
10
|
-
* 不做长度裁剪(由 pipeline 末尾 capToolResultForHistory 兜底 head+标记+tail,与改造前一致)。
|
|
11
|
-
* run ≤2 原样保留(常见输出不必标注,避免噪音);run ≥3 才折叠(真正的大规模重复才省)。
|
|
12
|
-
*/
|
|
13
|
-
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
14
|
-
export const logEncoder = {
|
|
15
|
-
kind: 'log',
|
|
16
|
-
encode({ output }) {
|
|
17
|
-
// 去 ANSI CSI 序列(颜色 / 光标 / 清屏等),保留所有可见文本。
|
|
18
|
-
const stripped = output.replace(ANSI_RE, '');
|
|
19
|
-
const lines = stripped.split('\n');
|
|
20
|
-
// 折叠连续重复行:run ≥3 → 单行 + [×N];run ≤2 原样。
|
|
21
|
-
const out = [];
|
|
22
|
-
let i = 0;
|
|
23
|
-
let collapsedRuns = 0;
|
|
24
|
-
while (i < lines.length) {
|
|
25
|
-
let j = i;
|
|
26
|
-
while (j < lines.length && lines[j] === lines[i])
|
|
27
|
-
j++;
|
|
28
|
-
const run = j - i;
|
|
29
|
-
if (run >= 3) {
|
|
30
|
-
out.push(`${lines[i]} [×${run}]`);
|
|
31
|
-
collapsedRuns++;
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
for (let k = 0; k < run; k++)
|
|
35
|
-
out.push(lines[i]);
|
|
36
|
-
}
|
|
37
|
-
i = j;
|
|
38
|
-
}
|
|
39
|
-
const text = out.join('\n');
|
|
40
|
-
return {
|
|
41
|
-
text,
|
|
42
|
-
meta: {
|
|
43
|
-
kind: 'log',
|
|
44
|
-
originalLen: output.length,
|
|
45
|
-
encodedLen: text.length,
|
|
46
|
-
note: collapsedRuns > 0
|
|
47
|
-
? `ANSI stripped, ${collapsedRuns} dup runs collapsed`
|
|
48
|
-
: 'ANSI stripped',
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
},
|
|
52
|
-
};
|
|
1
|
+
/** @deprecated 使用 commandEncoder;保留别名避免破坏已有直接导入。 */
|
|
2
|
+
export { commandEncoder as logEncoder } from './command.js';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
const CACHE_VERSION = 1;
|
|
6
|
+
const EWMA_ALPHA = 0.2;
|
|
7
|
+
const MIN_CORRECTION = 0.5;
|
|
8
|
+
const MAX_CORRECTION = 2;
|
|
9
|
+
const MAX_ENTRIES = 64;
|
|
10
|
+
let cache;
|
|
11
|
+
const toolFingerprints = new WeakMap();
|
|
12
|
+
function cachePath() {
|
|
13
|
+
return process.env.MOCODE_TOKEN_CALIBRATION_CACHE
|
|
14
|
+
|| path.join(os.homedir(), '.mocode', 'token-calibration.json');
|
|
15
|
+
}
|
|
16
|
+
function hash(value) {
|
|
17
|
+
return createHash('sha256').update(value).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
function toolFingerprint(tools) {
|
|
20
|
+
const objectKey = tools;
|
|
21
|
+
const hit = toolFingerprints.get(objectKey);
|
|
22
|
+
if (hit)
|
|
23
|
+
return hit;
|
|
24
|
+
const fingerprint = hash(JSON.stringify(tools));
|
|
25
|
+
toolFingerprints.set(objectKey, fingerprint);
|
|
26
|
+
return fingerprint;
|
|
27
|
+
}
|
|
28
|
+
function calibrationKey(baseURL, model, tools) {
|
|
29
|
+
// 只把摘要写盘,避免 URL 中偶然携带的凭据出现在缓存文件。
|
|
30
|
+
return hash(`${baseURL}\0${model}\0${toolFingerprint(tools)}`);
|
|
31
|
+
}
|
|
32
|
+
function readCache() {
|
|
33
|
+
if (cache)
|
|
34
|
+
return cache;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
|
|
37
|
+
if (parsed.version === CACHE_VERSION && parsed.entries && typeof parsed.entries === 'object') {
|
|
38
|
+
cache = parsed;
|
|
39
|
+
return cache;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// 不存在或损坏都从空缓存开始;校准是增强能力,不能阻断请求。
|
|
44
|
+
}
|
|
45
|
+
cache = { version: CACHE_VERSION, entries: {} };
|
|
46
|
+
return cache;
|
|
47
|
+
}
|
|
48
|
+
function writeCache() {
|
|
49
|
+
if (!cache)
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
const entries = Object.entries(cache.entries)
|
|
53
|
+
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
|
|
54
|
+
.slice(0, MAX_ENTRIES);
|
|
55
|
+
cache.entries = Object.fromEntries(entries);
|
|
56
|
+
const target = cachePath();
|
|
57
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
58
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
59
|
+
fs.writeFileSync(tmp, JSON.stringify(cache), 'utf8');
|
|
60
|
+
fs.renameSync(tmp, target);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// 只影响跨进程复用;当前进程仍继续使用内存中的校准值。
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function validEntry(entry) {
|
|
67
|
+
return !!entry
|
|
68
|
+
&& Number.isFinite(entry.correction)
|
|
69
|
+
&& entry.correction >= MIN_CORRECTION
|
|
70
|
+
&& entry.correction <= MAX_CORRECTION
|
|
71
|
+
&& Number.isInteger(entry.samples)
|
|
72
|
+
&& entry.samples > 0;
|
|
73
|
+
}
|
|
74
|
+
/** 读取指定 provider/model/工具集合的历史校准;未命中时退回 1。 */
|
|
75
|
+
export function getTokenCalibration(baseURL, model, tools) {
|
|
76
|
+
const entry = readCache().entries[calibrationKey(baseURL, model, tools)];
|
|
77
|
+
return validEntry(entry)
|
|
78
|
+
? { correction: entry.correction, samples: entry.samples }
|
|
79
|
+
: { correction: 1, samples: 0 };
|
|
80
|
+
}
|
|
81
|
+
/** 用一次真实 prompt usage 更新 EWMA;只落比例和样本数,不保存任何消息内容。 */
|
|
82
|
+
export function updateTokenCalibration(baseURL, model, tools, estimatedTokens, actualTokens) {
|
|
83
|
+
if (estimatedTokens <= 100
|
|
84
|
+
|| actualTokens <= 100
|
|
85
|
+
|| !Number.isFinite(estimatedTokens)
|
|
86
|
+
|| !Number.isFinite(actualTokens)) {
|
|
87
|
+
return getTokenCalibration(baseURL, model, tools);
|
|
88
|
+
}
|
|
89
|
+
const key = calibrationKey(baseURL, model, tools);
|
|
90
|
+
const store = readCache();
|
|
91
|
+
const previous = store.entries[key];
|
|
92
|
+
const raw = Math.max(MIN_CORRECTION, Math.min(MAX_CORRECTION, actualTokens / estimatedTokens));
|
|
93
|
+
const correction = validEntry(previous)
|
|
94
|
+
? previous.correction * (1 - EWMA_ALPHA) + raw * EWMA_ALPHA
|
|
95
|
+
: raw;
|
|
96
|
+
const next = {
|
|
97
|
+
correction,
|
|
98
|
+
samples: validEntry(previous) ? previous.samples + 1 : 1,
|
|
99
|
+
updatedAt: Date.now(),
|
|
100
|
+
};
|
|
101
|
+
store.entries[key] = next;
|
|
102
|
+
writeCache();
|
|
103
|
+
return { correction: next.correction, samples: next.samples };
|
|
104
|
+
}
|
package/dist/llm/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import OpenAI from 'openai';
|
|
|
2
2
|
import { config } from '../config/index.js';
|
|
3
3
|
import { tools } from '../tools/registry.js';
|
|
4
4
|
import { getPlanDisabledTools } from '../tools/constants.js';
|
|
5
|
+
import { ThinkTagFilter } from './think-filter.js';
|
|
5
6
|
/**
|
|
6
7
|
* LLM 调用重试策略:
|
|
7
8
|
* 可重试 → 429 (rate limit) / 5xx (server) / APIConnectionError / Node 网络错 (ETIMEDOUT 等)
|
|
@@ -22,9 +23,6 @@ const RETRY_JITTER = 0.2;
|
|
|
22
23
|
* 与 OpenAI 兼容协议的独立 `reasoning_content` 字段不同,这些模型把 thinking 直接嵌进 content
|
|
23
24
|
* 字符串,期间不调 onText(spinner 持续转 ⠹ 思考中…),也不写入可见 content(history 不被思考段污染)。
|
|
24
25
|
*/
|
|
25
|
-
// 用 \u003c 表示 <,绕开本工具对 < 的处理(直接写 '<\u003cthink\u003e' 里 < 会被吃掉)。
|
|
26
|
-
const THINK_OPEN = '<think>';
|
|
27
|
-
const THINK_CLOSE = '</think>';
|
|
28
26
|
let client = new OpenAI({
|
|
29
27
|
baseURL: config.baseURL,
|
|
30
28
|
apiKey: config.apiKey,
|
|
@@ -276,15 +274,20 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
|
|
|
276
274
|
...(config.maxTokens ? { max_tokens: config.maxTokens } : {}),
|
|
277
275
|
...(config.includeUsage ? { stream_options: { include_usage: true } } : {}),
|
|
278
276
|
}, signal ? { signal } : undefined);
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// 普通字符,buf 末尾为当前态保留 (label.length - 1) 个字符给下一 chunk 看。
|
|
277
|
+
// content 内嵌 think 标签由独立增量状态机过滤。它只暂存“可能组成标签”的后缀,
|
|
278
|
+
// 因而既能覆盖标签任意位置跨 chunk,也不会让普通正文固定延迟数个字符。
|
|
282
279
|
let visibleContent = '';
|
|
283
280
|
let consumedAny = false;
|
|
284
|
-
|
|
285
|
-
let buf = '';
|
|
281
|
+
const thinkFilter = new ThinkTagFilter();
|
|
286
282
|
let usage;
|
|
287
283
|
const toolAcc = new Map();
|
|
284
|
+
const emitVisible = (text) => {
|
|
285
|
+
if (!text)
|
|
286
|
+
return;
|
|
287
|
+
visibleContent += text;
|
|
288
|
+
handlers.onText?.(text);
|
|
289
|
+
consumedAny = true;
|
|
290
|
+
};
|
|
288
291
|
for await (const chunk of stream) {
|
|
289
292
|
// usage:末尾 chunk(choices 可能为空)在 include_usage 时携带;先读再 continue。
|
|
290
293
|
if (chunk.usage) {
|
|
@@ -300,78 +303,12 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
|
|
|
300
303
|
const delta = chunk.choices?.[0]?.delta;
|
|
301
304
|
if (!delta)
|
|
302
305
|
continue; // 末尾 usage-only chunk 等无 delta
|
|
303
|
-
if (delta.content)
|
|
304
|
-
|
|
305
|
-
// inThink 内丢弃;标签起始/闭合用 indexOf 在 buf 里扫描。
|
|
306
|
-
// 末尾预留 (label.length - 1) 字符给下一 chunk 防切分误判。
|
|
307
|
-
buf += delta.content;
|
|
308
|
-
let i = 0;
|
|
309
|
-
while (true) {
|
|
310
|
-
if (inThink) {
|
|
311
|
-
// 思考段内,扫描 THINK_CLOSE;末尾预留 THINK_CLOSE.length - 1 防跨 chunk 切分
|
|
312
|
-
const endIdx = buf.indexOf(THINK_CLOSE, i);
|
|
313
|
-
if (endIdx === -1) {
|
|
314
|
-
// 思考段内未找到闭合;buf 短到不可能包含 </think> 时全丢(都是思考段内容),
|
|
315
|
-
// 否则留 (THINK_CLOSE.length - 1) 给下一 chunk 防跨边界切分。
|
|
316
|
-
const safeLen = buf.length >= THINK_CLOSE.length
|
|
317
|
-
? buf.length - (THINK_CLOSE.length - 1)
|
|
318
|
-
: buf.length;
|
|
319
|
-
i = safeLen;
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
inThink = false;
|
|
323
|
-
i = endIdx + THINK_CLOSE.length;
|
|
324
|
-
}
|
|
325
|
-
else {
|
|
326
|
-
// 普通段,扫描 THINK_OPEN;末尾预留 THINK_OPEN.length - 1 防跨 chunk 切分
|
|
327
|
-
const startIdx = buf.indexOf(THINK_OPEN, i);
|
|
328
|
-
if (startIdx === -1) {
|
|
329
|
-
// buf 短到不可能包含 <think> 时全输出(无 think 标签的普通模型不受影响);
|
|
330
|
-
// 否则留 (THINK_OPEN.length - 1) 给下一 chunk 防跨边界切分误判。
|
|
331
|
-
const safeLen = buf.length >= THINK_OPEN.length
|
|
332
|
-
? buf.length - (THINK_OPEN.length - 1)
|
|
333
|
-
: buf.length;
|
|
334
|
-
const seg = buf.slice(i, safeLen);
|
|
335
|
-
if (seg) {
|
|
336
|
-
visibleContent += seg;
|
|
337
|
-
handlers.onText?.(seg);
|
|
338
|
-
consumedAny = true;
|
|
339
|
-
}
|
|
340
|
-
i = safeLen;
|
|
341
|
-
break;
|
|
342
|
-
}
|
|
343
|
-
// THINK_OPEN 之前的普通段:输出
|
|
344
|
-
if (startIdx > i) {
|
|
345
|
-
const seg = buf.slice(i, startIdx);
|
|
346
|
-
visibleContent += seg;
|
|
347
|
-
handlers.onText?.(seg);
|
|
348
|
-
consumedAny = true;
|
|
349
|
-
}
|
|
350
|
-
inThink = true;
|
|
351
|
-
i = startIdx + THINK_OPEN.length;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
buf = buf.slice(i);
|
|
355
|
-
}
|
|
306
|
+
if (delta.content)
|
|
307
|
+
emitVisible(thinkFilter.push(delta.content));
|
|
356
308
|
if (delta.tool_calls) {
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
// spinner,用户看到「话没说完就去调工具」——history 完整但屏幕渲染顺序错位。
|
|
361
|
-
// inThink 段照旧丢弃(思考中模型不会同时吐 tool_call,理论上 buf 不会有思考段);
|
|
362
|
-
// 防御性保留 !inThink 判断。
|
|
363
|
-
if (buf && !inThink) {
|
|
364
|
-
visibleContent += buf;
|
|
365
|
-
// 给 onText 渲染时剥掉尾部 \n:md 渲染器(contentWriteMd)把尾部 \n 当段落分隔 → 产空行;
|
|
366
|
-
// 随后 onToolCall 检测到 lastChar !== '\n' 会经 contentWrite('\n') 补一个原始换行
|
|
367
|
-
// (不走 md,只是普通行分隔,无空行)—— 与改造前 onToolCall 补 \n 的行为一致。
|
|
368
|
-
// visibleContent 保留原 buf(含 \n),history 完整不受影响。
|
|
369
|
-
const tail = buf.replace(/\n+$/, '');
|
|
370
|
-
if (tail)
|
|
371
|
-
handlers.onText?.(tail);
|
|
372
|
-
consumedAny = true;
|
|
373
|
-
buf = '';
|
|
374
|
-
}
|
|
309
|
+
// 不在这里 flush thinkFilter:其内部若有残留,只可能是 `<th` / `</thi` 一类
|
|
310
|
+
// 潜在标签前缀。旧实现把这段在工具转折点强制送进 onText,正是 `k>` 等残片
|
|
311
|
+
// 偶发混到工具摘要附近的来源。普通文本不会被状态机滞留。
|
|
375
312
|
for (const tc of delta.tool_calls) {
|
|
376
313
|
const idx = tc.index ?? 0;
|
|
377
314
|
let entry = toolAcc.get(idx);
|
|
@@ -392,19 +329,8 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
|
|
|
392
329
|
}
|
|
393
330
|
}
|
|
394
331
|
}
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
// (之前注释说"不再调 onText"是 bug——安全尾里的真实文本会被屏幕吞掉,用户看到模型
|
|
398
|
-
// 话没说完就去调工具 / 直接结束;history 有但显示缺。现在补上 onText 让屏幕与 history 一致。)
|
|
399
|
-
// - 思考段未闭合:丢弃,防 thinking 文本泄漏到 history
|
|
400
|
-
if (buf) {
|
|
401
|
-
if (!inThink) {
|
|
402
|
-
visibleContent += buf;
|
|
403
|
-
handlers.onText?.(buf);
|
|
404
|
-
consumedAny = true;
|
|
405
|
-
}
|
|
406
|
-
buf = '';
|
|
407
|
-
}
|
|
332
|
+
// 流结束后只释放普通态下真实的文本尾;未闭合思考段继续丢弃。
|
|
333
|
+
emitVisible(thinkFilter.finish());
|
|
408
334
|
const toolCalls = [...toolAcc.entries()]
|
|
409
335
|
.sort((a, b) => a[0] - b[0])
|
|
410
336
|
.map(([, e]) => ({
|
|
@@ -510,11 +436,28 @@ export function estimateMessagesTokens(messages) {
|
|
|
510
436
|
sum += messageTokens(m);
|
|
511
437
|
return sum;
|
|
512
438
|
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
export function estimateToolSchemaTokens() {
|
|
516
|
-
if (
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
439
|
+
const schemaTokensCache = new WeakMap();
|
|
440
|
+
/** 估算本次实际发送的工具 schema token;按工具数组实例缓存。 */
|
|
441
|
+
export function estimateToolSchemaTokens(activeTools = chatTools) {
|
|
442
|
+
if (activeTools.length === 0)
|
|
443
|
+
return 0;
|
|
444
|
+
const key = activeTools;
|
|
445
|
+
const cached = schemaTokensCache.get(key);
|
|
446
|
+
if (cached !== undefined)
|
|
447
|
+
return cached;
|
|
448
|
+
const estimated = estimateTokens(JSON.stringify(activeTools)) + 16;
|
|
449
|
+
schemaTokensCache.set(key, estimated);
|
|
450
|
+
return estimated;
|
|
451
|
+
}
|
|
452
|
+
/** 把模型级校正系数统一应用到原始估算。 */
|
|
453
|
+
export function correctTokenEstimate(estimate, correction = 1) {
|
|
454
|
+
const safeCorrection = Number.isFinite(correction)
|
|
455
|
+
? Math.max(0.5, Math.min(2, correction))
|
|
456
|
+
: 1;
|
|
457
|
+
return estimate > 0 ? Math.max(1, Math.ceil(estimate * safeCorrection)) : 0;
|
|
458
|
+
}
|
|
459
|
+
/** 估算一次完整请求的 prompt token(messages + 本次实际工具 schema)。 */
|
|
460
|
+
export function estimatePromptTokens(messages, activeTools = chatTools, correction = 1) {
|
|
461
|
+
const raw = estimateMessagesTokens(messages) + estimateToolSchemaTokens(activeTools);
|
|
462
|
+
return correctTokenEstimate(raw, correction);
|
|
520
463
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 增量过滤部分 OpenAI 兼容后端直接混在 content 中的 <think>...</think>。
|
|
3
|
+
*
|
|
4
|
+
* 关键点:流式 chunk 可以在标签任意字符间切开,因此不能仅在当前 chunk 内查找,
|
|
5
|
+
* 也不能把“短于标签”的缓冲直接输出。普通态还要吞掉孤立 </think>:部分后端把
|
|
6
|
+
* reasoning 放在独立字段,却仍在 content 的开头附带闭标签。
|
|
7
|
+
*/
|
|
8
|
+
const THINK_OPEN = '<think>';
|
|
9
|
+
const THINK_CLOSE = '</think>';
|
|
10
|
+
/** 返回 text 末尾与任一 tag 前缀重合的最长长度。 */
|
|
11
|
+
function trailingTagPrefixLength(text, tags) {
|
|
12
|
+
const max = Math.min(text.length, Math.max(...tags.map((tag) => tag.length - 1)));
|
|
13
|
+
for (let length = max; length > 0; length--) {
|
|
14
|
+
const suffix = text.slice(-length);
|
|
15
|
+
if (tags.some((tag) => tag.startsWith(suffix)))
|
|
16
|
+
return length;
|
|
17
|
+
}
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 每次 push 返回当前已经能够确认是正文的文本;标签和思考内容永不返回。
|
|
22
|
+
* finish 必须在流结束时调用,以释放普通正文末尾暂存的 `<` 等潜在标签前缀。
|
|
23
|
+
*/
|
|
24
|
+
export class ThinkTagFilter {
|
|
25
|
+
buffer = '';
|
|
26
|
+
inThink = false;
|
|
27
|
+
push(chunk) {
|
|
28
|
+
if (!chunk)
|
|
29
|
+
return '';
|
|
30
|
+
this.buffer += chunk;
|
|
31
|
+
return this.drain(false);
|
|
32
|
+
}
|
|
33
|
+
finish() {
|
|
34
|
+
return this.drain(true);
|
|
35
|
+
}
|
|
36
|
+
drain(final) {
|
|
37
|
+
let visible = '';
|
|
38
|
+
while (this.buffer) {
|
|
39
|
+
if (this.inThink) {
|
|
40
|
+
const closeIdx = this.buffer.indexOf(THINK_CLOSE);
|
|
41
|
+
if (closeIdx >= 0) {
|
|
42
|
+
this.buffer = this.buffer.slice(closeIdx + THINK_CLOSE.length);
|
|
43
|
+
this.inThink = false;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (final) {
|
|
47
|
+
// 未闭合思考段一直丢弃,不能在流结束时误当正文释放。
|
|
48
|
+
this.buffer = '';
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
// 思考正文可立即丢弃,只保留可能跨 chunk 组成 </think> 的后缀。
|
|
52
|
+
const keep = trailingTagPrefixLength(this.buffer, [THINK_CLOSE]);
|
|
53
|
+
this.buffer = keep > 0 ? this.buffer.slice(-keep) : '';
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
const openIdx = this.buffer.indexOf(THINK_OPEN);
|
|
57
|
+
const closeIdx = this.buffer.indexOf(THINK_CLOSE);
|
|
58
|
+
let tagIdx = -1;
|
|
59
|
+
let tag = '';
|
|
60
|
+
if (openIdx >= 0 && (closeIdx < 0 || openIdx < closeIdx)) {
|
|
61
|
+
tagIdx = openIdx;
|
|
62
|
+
tag = THINK_OPEN;
|
|
63
|
+
}
|
|
64
|
+
else if (closeIdx >= 0) {
|
|
65
|
+
// 独立 reasoning_content 后偶发残留的孤立闭标签也属于协议噪声。
|
|
66
|
+
tagIdx = closeIdx;
|
|
67
|
+
tag = THINK_CLOSE;
|
|
68
|
+
}
|
|
69
|
+
if (tagIdx >= 0) {
|
|
70
|
+
visible += this.buffer.slice(0, tagIdx);
|
|
71
|
+
this.buffer = this.buffer.slice(tagIdx + tag.length);
|
|
72
|
+
if (tag === THINK_OPEN)
|
|
73
|
+
this.inThink = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (final) {
|
|
77
|
+
visible += this.buffer;
|
|
78
|
+
this.buffer = '';
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
// 只暂存“确实可能成为标签”的后缀;普通文本立即输出,不引入固定 6/7 字符延迟。
|
|
82
|
+
const keep = trailingTagPrefixLength(this.buffer, [THINK_OPEN, THINK_CLOSE]);
|
|
83
|
+
const emitLength = this.buffer.length - keep;
|
|
84
|
+
visible += this.buffer.slice(0, emitLength);
|
|
85
|
+
this.buffer = this.buffer.slice(emitLength);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
return visible;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/repl/index.js
CHANGED
|
@@ -888,7 +888,6 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
888
888
|
if (!loadSnapshots(loaded.id))
|
|
889
889
|
rebuildFromHistory(history);
|
|
890
890
|
contextState.lastUsage = undefined;
|
|
891
|
-
contextState.correction = 1;
|
|
892
891
|
contextState.lifecycleStats = undefined;
|
|
893
892
|
lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
|
|
894
893
|
layout.clearContent();
|
|
@@ -1024,7 +1023,6 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1024
1023
|
setCurrentSessionId(undefined, process.cwd()); // 同步清空 session/state
|
|
1025
1024
|
turnCount = 0; // 反思 cadence 重新计数
|
|
1026
1025
|
contextState.lastUsage = undefined;
|
|
1027
|
-
contextState.correction = 1;
|
|
1028
1026
|
contextState.lifecycleStats = undefined;
|
|
1029
1027
|
lastTurnUsage = undefined; // 清空旧轮的 token 累计
|
|
1030
1028
|
pendingAttachments = []; // 一并清空待发图片
|
package/dist/session/compact.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chat,
|
|
1
|
+
import { chat, chatTools, correctTokenEstimate, estimatePromptTokens, estimateTokens, } from '../llm/index.js';
|
|
2
2
|
import { config } from '../config/index.js';
|
|
3
3
|
import { MAX_HISTORY_RESULT, MAX_MEMORY_RESULT, MAX_OLD_TOOL_STUB, MAX_SKILL_RESULT } from '../tools/constants.js';
|
|
4
4
|
import { ui } from '../ui/theme.js';
|
|
@@ -7,12 +7,9 @@ import * as layout from '../ui/layout.js';
|
|
|
7
7
|
import { pruneAfterCompaction } from '../rollback/index.js';
|
|
8
8
|
import { toText } from '../context/utils.js';
|
|
9
9
|
export function createContextState() {
|
|
10
|
-
return { lastEstimate: 0, correction: 1 };
|
|
10
|
+
return { lastEstimate: 0, correction: 1, calibrationSamples: 0 };
|
|
11
11
|
}
|
|
12
|
-
export const contextState =
|
|
13
|
-
lastEstimate: 0,
|
|
14
|
-
correction: 1,
|
|
15
|
-
};
|
|
12
|
+
export const contextState = createContextState();
|
|
16
13
|
/** 中截:text 太长时保 head + 标记 + tail,总长 ≤ max。 */
|
|
17
14
|
export function truncateMid(text, max) {
|
|
18
15
|
if (text.length <= max)
|
|
@@ -240,20 +237,22 @@ async function defaultSummarize(older, focus) {
|
|
|
240
237
|
*/
|
|
241
238
|
export async function compactHistory(history, opts) {
|
|
242
239
|
const state = opts.contextState ?? contextState;
|
|
243
|
-
const
|
|
244
|
-
const estimateBefore =
|
|
240
|
+
const activeTools = opts.tools ?? chatTools;
|
|
241
|
+
const estimateBefore = estimatePromptTokens(history, activeTools, state.correction);
|
|
245
242
|
state.lastEstimate = estimateBefore;
|
|
246
243
|
const groups = groupFromEnd(history);
|
|
247
|
-
//
|
|
244
|
+
// 保近期:按校正后的 token 累积到 40% window(至少保 1 组),永不劈开 group。
|
|
248
245
|
const keepBudget = Math.floor(opts.window * 0.4);
|
|
249
246
|
const kept = [];
|
|
250
247
|
let keptTokens = 0;
|
|
251
248
|
for (let k = groups.length - 1; k >= 0; k--) {
|
|
252
249
|
const g = groups[k];
|
|
253
|
-
|
|
250
|
+
const nextTokens = keptTokens + groupTokens(g);
|
|
251
|
+
if (kept.length >= 1
|
|
252
|
+
&& correctTokenEstimate(nextTokens, state.correction) > keepBudget)
|
|
254
253
|
break;
|
|
255
254
|
kept.unshift(g);
|
|
256
|
-
keptTokens
|
|
255
|
+
keptTokens = nextTokens;
|
|
257
256
|
}
|
|
258
257
|
const oldGroups = groups.slice(0, groups.length - kept.length);
|
|
259
258
|
const noop = {
|
|
@@ -312,10 +311,9 @@ export async function compactHistory(history, opts) {
|
|
|
312
311
|
history.length = 0;
|
|
313
312
|
history.push(...rebuilt);
|
|
314
313
|
pruneAfterCompaction(history);
|
|
315
|
-
const estimateAfter =
|
|
314
|
+
const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
|
|
316
315
|
state.lastEstimate = estimateAfter;
|
|
317
316
|
state.lastUsage = undefined;
|
|
318
|
-
state.correction = 1;
|
|
319
317
|
layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}强制压缩(focus on early history)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
320
318
|
return {
|
|
321
319
|
compacted: true,
|
|
@@ -400,10 +398,9 @@ export async function compactHistory(history, opts) {
|
|
|
400
398
|
history.length = 0;
|
|
401
399
|
history.push(...rebuilt);
|
|
402
400
|
pruneAfterCompaction(history); // 摘要删了旧轮次 → 按存活轮次裁剪回滚日志
|
|
403
|
-
const estimateAfter =
|
|
401
|
+
const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
|
|
404
402
|
state.lastEstimate = estimateAfter;
|
|
405
|
-
state.lastUsage = undefined; // 压缩后旧 usage 失效,/context
|
|
406
|
-
state.correction = 1;
|
|
403
|
+
state.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用校正估算
|
|
407
404
|
layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}压缩上下文${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
408
405
|
// 抖动保护:压缩后仍超阈 → 提示 /clear,不死循环
|
|
409
406
|
if (estimateAfter >= opts.threshold * opts.window) {
|
|
@@ -419,10 +416,9 @@ export async function compactHistory(history, opts) {
|
|
|
419
416
|
};
|
|
420
417
|
}
|
|
421
418
|
// 摘要失败:回退仅微压缩(tool content 已原地改),结构不动
|
|
422
|
-
const estimateAfter =
|
|
419
|
+
const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
|
|
423
420
|
state.lastEstimate = estimateAfter;
|
|
424
|
-
state.lastUsage = undefined; //
|
|
425
|
-
state.correction = 1;
|
|
421
|
+
state.lastUsage = undefined; // token 数已变,旧 usage 失效
|
|
426
422
|
if (microcompactDone) {
|
|
427
423
|
layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}微压缩旧工具结果${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
428
424
|
return {
|
|
@@ -460,9 +456,8 @@ export async function compactHistory(history, opts) {
|
|
|
460
456
|
* 强制走 compactHistory(manual/force 参数透传)。返 CompactResult 给 caller 文案展示。
|
|
461
457
|
* 默认 manual=false 自动路径完全不变。
|
|
462
458
|
*/
|
|
463
|
-
export async function maybeCompact(history, report, manualOpts, state = contextState) {
|
|
464
|
-
const
|
|
465
|
-
const est = estimateMessagesTokens(history) + schemaTokens;
|
|
459
|
+
export async function maybeCompact(history, report, manualOpts, state = contextState, activeTools = chatTools) {
|
|
460
|
+
const est = estimatePromptTokens(history, activeTools, state.correction);
|
|
466
461
|
state.lastEstimate = est;
|
|
467
462
|
const isManual = manualOpts?.manual === true;
|
|
468
463
|
// 手动路径:旁路 autoCompact / report / 总阈三重门
|
|
@@ -487,6 +482,7 @@ export async function maybeCompact(history, report, manualOpts, state = contextS
|
|
|
487
482
|
focus: manualOpts?.focus,
|
|
488
483
|
manual: isManual,
|
|
489
484
|
force: manualOpts?.force,
|
|
485
|
+
tools: activeTools,
|
|
490
486
|
contextState: state,
|
|
491
487
|
});
|
|
492
488
|
return r;
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// - 关时 agent 仍走原 maybeCompact(history)无 report 路径,零行为变化
|
|
32
32
|
// - 手动 /compact 走 manualCompact;关时退化直接调 compactHistory(history, { focus })
|
|
33
33
|
import { evaluateBudget, scheduleActions, formatReport, } from '../context/budget.js';
|
|
34
|
+
import { chatTools } from '../llm/index.js';
|
|
34
35
|
import { config } from '../config/index.js';
|
|
35
36
|
import { maybeCompact, contextState } from './compact.js';
|
|
36
37
|
import * as layout from '../ui/layout.js';
|
|
@@ -44,8 +45,8 @@ export function createBudgetScheduler(state = contextState) {
|
|
|
44
45
|
observePush(_history, _idx) {
|
|
45
46
|
// 占位:push-time 三闸(cap / pruner / lifecycle)已自动跑;此接缝供将来演进。
|
|
46
47
|
},
|
|
47
|
-
async runStep(history, step) {
|
|
48
|
-
const report = evaluateBudget(history, config.contextWindowTokens, step, state.correction);
|
|
48
|
+
async runStep(history, step, activeTools = chatTools) {
|
|
49
|
+
const report = evaluateBudget(history, config.contextWindowTokens, step, state.correction, activeTools);
|
|
49
50
|
const actions = scheduleActions(report);
|
|
50
51
|
let compactHistoryCalled = false;
|
|
51
52
|
let historyRebuilt = false;
|
|
@@ -56,7 +57,7 @@ export function createBudgetScheduler(state = contextState) {
|
|
|
56
57
|
}
|
|
57
58
|
else if (a.kind === 'compact_history') {
|
|
58
59
|
// 路由到 maybeCompact;把结构重建信号传回 core,使 lifecycle 按新 index 恢复。
|
|
59
|
-
const result = await maybeCompact(history, report, undefined, state);
|
|
60
|
+
const result = await maybeCompact(history, report, undefined, state, activeTools);
|
|
60
61
|
compactHistoryCalled = true;
|
|
61
62
|
historyRebuilt ||= result?.historyRebuilt === true;
|
|
62
63
|
}
|
|
@@ -80,9 +81,9 @@ export function createBudgetScheduler(state = contextState) {
|
|
|
80
81
|
return obs;
|
|
81
82
|
}
|
|
82
83
|
/** 便捷:agent/core.ts 不需要每次 createBudgetScheduler,直接 runScheduler(history, step)。 */
|
|
83
|
-
export async function runScheduler(history, step, state = contextState) {
|
|
84
|
+
export async function runScheduler(history, step, state = contextState, activeTools = chatTools) {
|
|
84
85
|
const s = createBudgetScheduler(state);
|
|
85
|
-
return s.runStep(history, step);
|
|
86
|
+
return s.runStep(history, step, activeTools);
|
|
86
87
|
}
|
|
87
88
|
/** 手动 /compact 入口(repl):与自动路径完全一致——五区 ROI 调度,但 history 摘要强制执行。
|
|
88
89
|
* 即便 layers.history.overBudget=false 或 totalOver=false,manual 仍产 compact_history action
|
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { MAX_OUTPUT } from '../constants.js';
|
|
3
3
|
import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.js';
|
|
4
|
+
const OUTPUT_HEAD_LIMIT = Math.floor(MAX_OUTPUT * 0.4);
|
|
5
|
+
const OUTPUT_TAIL_LIMIT = MAX_OUTPUT - OUTPUT_HEAD_LIMIT;
|
|
6
|
+
/** 有界采集:短输出逐字保留;超限后保留 head+tail,避免构建/测试错误只出现在尾部时被丢弃。 */
|
|
7
|
+
class BoundedCommandOutput {
|
|
8
|
+
head = '';
|
|
9
|
+
tail = '';
|
|
10
|
+
total = 0;
|
|
11
|
+
append(text) {
|
|
12
|
+
this.total += text.length;
|
|
13
|
+
const headRoom = OUTPUT_HEAD_LIMIT - this.head.length;
|
|
14
|
+
const headPart = headRoom > 0 ? text.slice(0, headRoom) : '';
|
|
15
|
+
this.head += headPart;
|
|
16
|
+
const rest = text.slice(headPart.length);
|
|
17
|
+
if (rest)
|
|
18
|
+
this.tail = (this.tail + rest).slice(-OUTPUT_TAIL_LIMIT);
|
|
19
|
+
}
|
|
20
|
+
render() {
|
|
21
|
+
if (this.total <= MAX_OUTPUT)
|
|
22
|
+
return this.head + this.tail;
|
|
23
|
+
const removed = this.total - MAX_OUTPUT;
|
|
24
|
+
return `${this.head}\n...(输出已截断 ${removed} 字符,保留开头与结尾)...\n${this.tail}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
4
27
|
// ---------- run_command ----------
|
|
5
28
|
export const runCommandTool = {
|
|
6
29
|
name: 'run_command',
|
|
@@ -25,7 +48,7 @@ export const runCommandTool = {
|
|
|
25
48
|
const isWin = process.platform === 'win32';
|
|
26
49
|
// 沙箱 best-effort:cwd 钉死 sandbox root(相对路径写落在牢内)+ env 脱敏(剥 *KEY/*TOKEN 等,防 LLM_API_KEY 泄子进程)
|
|
27
50
|
const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: getSandboxRoot() ?? process.cwd(), env: filterEnv(process.env) });
|
|
28
|
-
|
|
51
|
+
const output = new BoundedCommandOutput();
|
|
29
52
|
let finished = false;
|
|
30
53
|
let timer;
|
|
31
54
|
// 杀整棵进程树。child.kill() 在 Windows 只杀 cmd.exe、npm 等子进程会孤儿继续跑(占锁、污染下一步),
|
|
@@ -50,7 +73,7 @@ export const runCommandTool = {
|
|
|
50
73
|
// abort(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 杀子进程树 + 返[已中断]
|
|
51
74
|
const onAbort = () => {
|
|
52
75
|
killTree();
|
|
53
|
-
finish(`[已中断]\n${
|
|
76
|
+
finish(`[已中断]\n${output.render().trim()}`);
|
|
54
77
|
};
|
|
55
78
|
const finish = (s) => {
|
|
56
79
|
if (finished)
|
|
@@ -61,21 +84,18 @@ export const runCommandTool = {
|
|
|
61
84
|
done(s);
|
|
62
85
|
};
|
|
63
86
|
const onChunk = (chunk) => {
|
|
64
|
-
|
|
65
|
-
out += chunk.toString('utf8');
|
|
87
|
+
output.append(chunk.toString('utf8'));
|
|
66
88
|
};
|
|
67
89
|
child.stdout.on('data', onChunk);
|
|
68
90
|
child.stderr.on('data', onChunk);
|
|
69
91
|
child.on('error', (e) => finish(`执行失败: ${e.message}`));
|
|
70
92
|
child.on('close', (code) => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
r += '\n...(输出已截断)';
|
|
74
|
-
finish(`[退出码 ${code}]\n${r || '(无输出)'}`);
|
|
93
|
+
const result = output.render().trim();
|
|
94
|
+
finish(`[退出码 ${code}]\n${result || '(无输出)'}`);
|
|
75
95
|
});
|
|
76
96
|
timer = setTimeout(() => {
|
|
77
97
|
killTree();
|
|
78
|
-
finish(`[超时,已终止]\n${
|
|
98
|
+
finish(`[超时,已终止]\n${output.render().trim()}`);
|
|
79
99
|
}, timeout);
|
|
80
100
|
// 外部 abort signal:已 aborted 即时杀(防御;agent 循环顶检查通常会先拦),否则挂监听
|
|
81
101
|
if (ctx?.signal) {
|