minimal-agent 0.6.2 → 0.6.4
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/package.json +1 -1
- package/plugins/workflow-runner/src/expressions.js +13 -2
- package/plugins/workflow-runner/src/loader.js +4 -2
- package/src/cli/args.js +27 -0
- package/src/cli/print.js +30 -44
- package/src/cli/streamJson.js +117 -0
- package/src/config/configFile.js +7 -3
- package/src/config.js +19 -0
- package/src/context/compact.js +44 -19
- package/src/context/reactiveCompact.js +40 -19
- package/src/context/recentDirs.js +66 -0
- package/src/context/tokenCounter.js +23 -0
- package/src/llm/client.js +17 -4
- package/src/loop.js +161 -91
- package/src/main.js +31 -4
- package/src/tools/bash/bash.js +34 -4
- package/src/ui/hooks/useTokenUsage.js +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "最小化 Agent 系统 —— 10 工具 + 插件系统 + workflow DSL + 自动压缩 + OpenAI 兼容 + Ink TUI;NodeNext + tsc 原地编译,dev 用 Bun .ts、install 用 Node .js(学习/教学用)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Bill Wang <leiwang0359@gmail.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ============================================================
|
|
3
|
-
* src/
|
|
3
|
+
* plugins/workflow-runner/src/expressions.ts —— workflow 表达式引擎(mini)
|
|
4
4
|
* ------------------------------------------------------------
|
|
5
5
|
* WHY 不直接 eval / Function?
|
|
6
6
|
* Workflow 来源不可信(用户编辑器生成 / 别人贡献的 yaml),允许任意
|
|
@@ -310,7 +310,18 @@ function parsePrimary(c, vars) {
|
|
|
310
310
|
const next = eat(c);
|
|
311
311
|
if (next.kind !== 'ident')
|
|
312
312
|
throw new Error('点号后必须是标识符');
|
|
313
|
-
|
|
313
|
+
// S1:禁止经点路径访问原型链(__proto__ / constructor / prototype),防原型污染 / 函数对象逃逸。
|
|
314
|
+
if (next.value === '__proto__' ||
|
|
315
|
+
next.value === 'constructor' ||
|
|
316
|
+
next.value === 'prototype') {
|
|
317
|
+
cur = undefined;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
cur =
|
|
321
|
+
cur != null && typeof cur === 'object'
|
|
322
|
+
? cur[next.value]
|
|
323
|
+
: undefined;
|
|
324
|
+
}
|
|
314
325
|
}
|
|
315
326
|
return cur;
|
|
316
327
|
}
|
|
@@ -126,7 +126,9 @@ function validate(obj, file) {
|
|
|
126
126
|
}
|
|
127
127
|
// ADR-05: 控制流 type 集合——这些节点的"动作"由 type 自身定义,
|
|
128
128
|
// 因此不能附带 tool/skill/llm 也不能配 output_schema / context_files / allowed_tools。
|
|
129
|
-
|
|
129
|
+
// 导出供「editor ↔ 后端」schema 一致性测试比对(test/editorSchemaSync.test.ts)。
|
|
130
|
+
// editor 因 D2 红线禁止 import 后端、手抄了同一份;后端在此演进时该测试会红,提示同步 editor。
|
|
131
|
+
export const CONTROL_FLOW_TYPES = new Set([
|
|
130
132
|
'assert',
|
|
131
133
|
'branch',
|
|
132
134
|
'loop',
|
|
@@ -134,7 +136,7 @@ const CONTROL_FLOW_TYPES = new Set([
|
|
|
134
136
|
'parallel',
|
|
135
137
|
'vote',
|
|
136
138
|
]);
|
|
137
|
-
const VALID_STEP_TYPES = new Set([
|
|
139
|
+
export const VALID_STEP_TYPES = new Set([
|
|
138
140
|
'assert',
|
|
139
141
|
'branch',
|
|
140
142
|
'loop',
|
package/src/cli/args.js
CHANGED
|
@@ -31,3 +31,30 @@ export function extractFlagValue(args, names) {
|
|
|
31
31
|
}
|
|
32
32
|
return undefined;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* CLI 布尔标志(出现即生效、不带值)。集中定义一份,供 print.ts 的 extractPromptArgs
|
|
36
|
+
* 共用 —— 避免 flag 列表在多处各维护一份、新增 flag 时漏改导致「flag 值被当 prompt 污染」。
|
|
37
|
+
*/
|
|
38
|
+
export const BOOLEAN_FLAGS = new Set([
|
|
39
|
+
'-p',
|
|
40
|
+
'--print',
|
|
41
|
+
'--verbose',
|
|
42
|
+
'-v',
|
|
43
|
+
'-h',
|
|
44
|
+
'--help',
|
|
45
|
+
'-V',
|
|
46
|
+
'--version',
|
|
47
|
+
// stream-json 控制位(webchat broker / 网关用);出现即生效、不带值。
|
|
48
|
+
'--clear',
|
|
49
|
+
'--compact',
|
|
50
|
+
]);
|
|
51
|
+
/**
|
|
52
|
+
* CLI 带值标志(标志本身 + 紧跟的下一个元素都不是位置参数)。
|
|
53
|
+
* 新增带值标志务必加到这里,否则它的值会被 extractPromptArgs 当成 prompt 文本。
|
|
54
|
+
*/
|
|
55
|
+
export const VALUE_FLAGS = new Set([
|
|
56
|
+
'-d',
|
|
57
|
+
'--cwd',
|
|
58
|
+
'--output-format',
|
|
59
|
+
'--max-turns',
|
|
60
|
+
]);
|
package/src/cli/print.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { getContextPath, saveContext } from '../context/persistContext.js';
|
|
26
26
|
import { runWithPlugins } from '../plugins/pluginRunner.js';
|
|
27
|
+
import { BOOLEAN_FLAGS, VALUE_FLAGS } from './args.js';
|
|
27
28
|
/** stderr 显示工具结果时的截断阈值;超过即末尾加 "..." */
|
|
28
29
|
const TOOL_OUTPUT_PREVIEW_MAX = 200;
|
|
29
30
|
/** stdin 读取超时(ms),防止非 TTY + 无管道时永久挂起 */
|
|
@@ -49,32 +50,14 @@ export function truncateForDisplay(content, max = TOOL_OUTPUT_PREVIEW_MAX) {
|
|
|
49
50
|
return content.slice(0, max) + '...';
|
|
50
51
|
}
|
|
51
52
|
export function extractPromptArgs(args) {
|
|
52
|
-
//
|
|
53
|
-
const FLAG_BOOLEAN = new Set([
|
|
54
|
-
'-p',
|
|
55
|
-
'--print',
|
|
56
|
-
'--verbose',
|
|
57
|
-
'-v',
|
|
58
|
-
'-h',
|
|
59
|
-
'--help',
|
|
60
|
-
'-V',
|
|
61
|
-
'--version',
|
|
62
|
-
]);
|
|
63
|
-
// 带值标志:本身 + 后面紧跟的值都要跳过
|
|
64
|
-
// (--output-format / --max-turns 必须在此,否则它们的值会被当成 prompt 文本污染)
|
|
65
|
-
const FLAG_WITH_VALUE = new Set([
|
|
66
|
-
'-d',
|
|
67
|
-
'--cwd',
|
|
68
|
-
'--output-format',
|
|
69
|
-
'--max-turns',
|
|
70
|
-
]);
|
|
53
|
+
// flag 定义集中在 cli/args.ts(BOOLEAN_FLAGS / VALUE_FLAGS),这里只消费、不再各维护一份。
|
|
71
54
|
const result = [];
|
|
72
55
|
for (let i = 0; i < args.length; i++) {
|
|
73
56
|
const a = args[i];
|
|
74
|
-
if (
|
|
57
|
+
if (BOOLEAN_FLAGS.has(a))
|
|
75
58
|
continue;
|
|
76
|
-
if (
|
|
77
|
-
i++; //
|
|
59
|
+
if (VALUE_FLAGS.has(a)) {
|
|
60
|
+
i++; // 跳过带值标志紧跟的值(否则 --output-format 的值 json 会被当成 prompt 文本污染)
|
|
78
61
|
continue;
|
|
79
62
|
}
|
|
80
63
|
result.push(a);
|
|
@@ -165,6 +148,23 @@ export async function runPrintMode(provider, args, initialHistory, options) {
|
|
|
165
148
|
stopReason: undefined,
|
|
166
149
|
error: null,
|
|
167
150
|
};
|
|
151
|
+
// 收尾守卫:saveContext + (json) emit 只执行一次,所有退出路径 await 同一个
|
|
152
|
+
// promise 再 process.exit。
|
|
153
|
+
// 没有它的话:SIGINT 触发 abort 后,SIGINT handler 与 for-await 的 AbortError
|
|
154
|
+
// catch 会同时奔向收尾 → 双写 session 文件 + 往 stdout 打两行 JSON(破坏「一行契约」)。
|
|
155
|
+
// 用「缓存 promise」而非「布尔 once」是关键:后到的路径必须 await 到 emit 真正
|
|
156
|
+
// 完成再退出,否则可能抢先把进程杀掉、导致那一行 JSON 根本没发出。
|
|
157
|
+
let finalizePromise = null;
|
|
158
|
+
function finalizeOnce() {
|
|
159
|
+
if (!finalizePromise) {
|
|
160
|
+
finalizePromise = (async () => {
|
|
161
|
+
await saveContext(history);
|
|
162
|
+
if (isJson)
|
|
163
|
+
emitJsonResult(result, trySessionFile());
|
|
164
|
+
})();
|
|
165
|
+
}
|
|
166
|
+
return finalizePromise;
|
|
167
|
+
}
|
|
168
168
|
process.on('SIGINT', () => {
|
|
169
169
|
if (interrupted)
|
|
170
170
|
process.exit(130);
|
|
@@ -172,16 +172,9 @@ export async function runPrintMode(provider, args, initialHistory, options) {
|
|
|
172
172
|
abortController.abort();
|
|
173
173
|
console.error('\n已中断');
|
|
174
174
|
// json 模式:SIGINT 也要给出结构化结局(stop_reason='interrupted')
|
|
175
|
-
if (isJson)
|
|
175
|
+
if (isJson)
|
|
176
176
|
result.stopReason = 'interrupted';
|
|
177
|
-
|
|
178
|
-
emitJsonResult(result, trySessionFile());
|
|
179
|
-
process.exit(130);
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
else {
|
|
183
|
-
void saveContext(history).finally(() => process.exit(130));
|
|
184
|
-
}
|
|
177
|
+
void finalizeOnce().finally(() => process.exit(130));
|
|
185
178
|
});
|
|
186
179
|
try {
|
|
187
180
|
for await (const event of runWithPlugins(prompt, {
|
|
@@ -193,35 +186,28 @@ export async function runPrintMode(provider, args, initialHistory, options) {
|
|
|
193
186
|
// event 是 LoopEvent | PluginEvent;handleEvent 只认 LoopEvent,未识别的插件私有事件走 default 静默忽略
|
|
194
187
|
const handled = handleEvent(event, result, options.verbose, outputFormat);
|
|
195
188
|
if (handled.exitCode !== undefined) {
|
|
196
|
-
await
|
|
197
|
-
if (isJson)
|
|
198
|
-
emitJsonResult(result, trySessionFile());
|
|
189
|
+
await finalizeOnce();
|
|
199
190
|
process.exit(handled.exitCode);
|
|
200
191
|
}
|
|
201
192
|
}
|
|
202
193
|
}
|
|
203
194
|
catch (e) {
|
|
204
195
|
if (e.name === 'AbortError') {
|
|
205
|
-
|
|
206
|
-
if (isJson) {
|
|
196
|
+
if (isJson)
|
|
207
197
|
result.stopReason = 'interrupted';
|
|
208
|
-
|
|
209
|
-
}
|
|
198
|
+
await finalizeOnce();
|
|
210
199
|
process.exit(130);
|
|
211
200
|
}
|
|
212
201
|
console.error(`\n未捕获异常: ${e.message}`);
|
|
213
|
-
await saveContext(history);
|
|
214
202
|
if (isJson) {
|
|
215
203
|
// 未分类异常按 error 收尾(stopReason 仍为空 → emitJsonResult 兜底 'error')
|
|
216
204
|
result.error = result.error ?? e.message;
|
|
217
|
-
emitJsonResult(result, trySessionFile());
|
|
218
205
|
}
|
|
206
|
+
await finalizeOnce();
|
|
219
207
|
process.exit(1);
|
|
220
208
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (isJson)
|
|
224
|
-
emitJsonResult(result, trySessionFile());
|
|
209
|
+
// 正常跑完(没触发任何 exitCode 退出点)也走同一个收尾(json 模式 emit 一行结局)。
|
|
210
|
+
await finalizeOnce();
|
|
225
211
|
}
|
|
226
212
|
/**
|
|
227
213
|
* 处理 runQuery yield 出来的 LoopEvent。
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/cli/streamJson.ts —— `-p --output-format stream-json` 流式事件模式
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 与 print.ts 的 text/json 一次性契约**并列、互不影响**(print.ts 一行未动):
|
|
6
|
+
* - text : 纯文本答案(向后兼容)
|
|
7
|
+
* - json : 退出时一行结局契约
|
|
8
|
+
* - stream-json(本文件): 把 runQuery 的**每个 LoopEvent 作为一行 JSON**
|
|
9
|
+
* 实时写 stdout(jsonl)。给 webchat 本地服务 broker 用 —— 它 spawn 本模式、
|
|
10
|
+
* 逐行转发成 WebSocket event 帧。也是给网关预留的"流式子进程"契约。
|
|
11
|
+
*
|
|
12
|
+
* 约定:
|
|
13
|
+
* - stdout 只允许 jsonl 事件流(一行一个 JSON.stringify(LoopEvent))
|
|
14
|
+
* - prompt 经 argv 或 stdin(与 print 一致);--compact 走压缩动作(无需 prompt)
|
|
15
|
+
* - 跑完 / 中断 / 出错都先 saveContext 再退出
|
|
16
|
+
* ============================================================
|
|
17
|
+
*/
|
|
18
|
+
import { getWorkingDir } from '../bootstrap/workingDir.js';
|
|
19
|
+
import { forceCompact } from '../context/compact.js';
|
|
20
|
+
import { recordRecentDir } from '../context/recentDirs.js';
|
|
21
|
+
import { saveContext } from '../context/persistContext.js';
|
|
22
|
+
import { runWithPlugins } from '../plugins/pluginRunner.js';
|
|
23
|
+
import { extractPromptArgs, readFromStdin } from './print.js';
|
|
24
|
+
/** 一行一个 JSON 事件写 stdout。EPIPE 时静默。 */
|
|
25
|
+
function emitLine(ev) {
|
|
26
|
+
try {
|
|
27
|
+
process.stdout.write(`${JSON.stringify(ev)}\n`);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* ignore */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function firstUserTitle(history) {
|
|
34
|
+
const u = history.find((m) => m.role === 'user');
|
|
35
|
+
return u && typeof u.content === 'string' ? u.content : undefined;
|
|
36
|
+
}
|
|
37
|
+
export async function runStreamJsonMode(provider, args, initialHistory, options) {
|
|
38
|
+
process.stdout.on('error', (err) => {
|
|
39
|
+
if (err.code === 'EPIPE')
|
|
40
|
+
process.stdout.destroy();
|
|
41
|
+
});
|
|
42
|
+
const history = initialHistory;
|
|
43
|
+
let finalized = false;
|
|
44
|
+
const finalize = async () => {
|
|
45
|
+
if (finalized)
|
|
46
|
+
return;
|
|
47
|
+
finalized = true;
|
|
48
|
+
await saveContext(history);
|
|
49
|
+
};
|
|
50
|
+
// --- 压缩动作(/compact):不需要 prompt ---
|
|
51
|
+
if (options.compact) {
|
|
52
|
+
try {
|
|
53
|
+
emitLine({ type: 'compact_start' });
|
|
54
|
+
const r = await forceCompact(history, provider);
|
|
55
|
+
history.length = 0;
|
|
56
|
+
history.push(...r.messages);
|
|
57
|
+
emitLine({ type: 'compact_done', before: r.before, after: r.after });
|
|
58
|
+
emitLine({ type: 'turn_done' });
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
emitLine({ type: 'error', error: `压缩失败:${e.message}`, code: 'compact_failed' });
|
|
62
|
+
}
|
|
63
|
+
await finalize();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// --- 普通一轮 ---
|
|
67
|
+
const promptArgs = extractPromptArgs(args);
|
|
68
|
+
let prompt;
|
|
69
|
+
if (promptArgs.length > 0) {
|
|
70
|
+
prompt = promptArgs.join(' ');
|
|
71
|
+
}
|
|
72
|
+
else if (!process.stdin.isTTY) {
|
|
73
|
+
prompt = await readFromStdin();
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
emitLine({ type: 'error', error: '未提供 prompt', code: 'llm_error' });
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
if (!prompt.trim()) {
|
|
80
|
+
emitLine({ type: 'error', error: 'prompt 为空', code: 'llm_error' });
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
// 记录最近目录(侧栏用);title 优先用历史里第一条用户消息(稳定),否则用当前 prompt
|
|
84
|
+
await recordRecentDir(getWorkingDir(), firstUserTitle(history) ?? prompt);
|
|
85
|
+
const abortController = new AbortController();
|
|
86
|
+
let interrupted = false;
|
|
87
|
+
process.on('SIGINT', () => {
|
|
88
|
+
if (interrupted)
|
|
89
|
+
process.exit(130);
|
|
90
|
+
interrupted = true;
|
|
91
|
+
abortController.abort();
|
|
92
|
+
emitLine({ type: 'interrupted' });
|
|
93
|
+
void finalize().finally(() => process.exit(130));
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
for await (const event of runWithPlugins(prompt, {
|
|
97
|
+
provider,
|
|
98
|
+
history,
|
|
99
|
+
signal: abortController.signal,
|
|
100
|
+
maxTurns: options.maxTurns,
|
|
101
|
+
})) {
|
|
102
|
+
// event 是 LoopEvent | PluginEvent,原样逐行 emit;前端不识别的插件私有事件会静默忽略
|
|
103
|
+
emitLine(event);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (e.name === 'AbortError') {
|
|
108
|
+
emitLine({ type: 'interrupted' });
|
|
109
|
+
await finalize();
|
|
110
|
+
process.exit(130);
|
|
111
|
+
}
|
|
112
|
+
emitLine({ type: 'error', error: e.message, code: 'llm_error' });
|
|
113
|
+
await finalize();
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
await finalize();
|
|
117
|
+
}
|
package/src/config/configFile.js
CHANGED
|
@@ -27,10 +27,9 @@ import { execSiblingDir } from '../utils/greenRoot.js';
|
|
|
27
27
|
* (U 盘可能只读,且不该污染产品目录)。读路径见 readSavedConfig 的分层。
|
|
28
28
|
*/
|
|
29
29
|
export function getConfigFilePath() {
|
|
30
|
-
return
|
|
31
|
-
join(homedir(), '.minimal-agent', 'config.json'));
|
|
30
|
+
return process.env.MINIMAL_AGENT_CONFIG_FILE ?? homeConfigPath();
|
|
32
31
|
}
|
|
33
|
-
/** home
|
|
32
|
+
/** home 默认配置路径(读分层的最后一档;也是 getConfigFilePath 的默认写入位置)。 */
|
|
34
33
|
function homeConfigPath() {
|
|
35
34
|
return join(homedir(), '.minimal-agent', 'config.json');
|
|
36
35
|
}
|
|
@@ -57,6 +56,11 @@ async function parseConfigFile(file) {
|
|
|
57
56
|
contextWindow: typeof data.contextWindow === 'number' && data.contextWindow > 0
|
|
58
57
|
? data.contextWindow
|
|
59
58
|
: undefined,
|
|
59
|
+
compactRatio: typeof data.compactRatio === 'number' &&
|
|
60
|
+
data.compactRatio > 0 &&
|
|
61
|
+
data.compactRatio <= 1
|
|
62
|
+
? data.compactRatio
|
|
63
|
+
: undefined,
|
|
60
64
|
tavilyApiKey: typeof data.tavilyApiKey === 'string' && data.tavilyApiKey.length > 0
|
|
61
65
|
? data.tavilyApiKey
|
|
62
66
|
: undefined,
|
package/src/config.js
CHANGED
|
@@ -16,6 +16,18 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { readSavedConfig } from './config/configFile.js';
|
|
18
18
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
19
|
+
/**
|
|
20
|
+
* 解析压缩阈值比例(0~1 小数)。
|
|
21
|
+
* 非法值(NaN、0、负数、>1)返回 undefined,由调用方决定是否用默认值 0.85。
|
|
22
|
+
*/
|
|
23
|
+
function parseCompactRatio(raw) {
|
|
24
|
+
if (!raw)
|
|
25
|
+
return undefined;
|
|
26
|
+
const n = parseFloat(raw);
|
|
27
|
+
if (Number.isNaN(n) || n <= 0 || n > 1)
|
|
28
|
+
return undefined;
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
19
31
|
/**
|
|
20
32
|
* 加载 Provider 配置(从环境变量)。
|
|
21
33
|
* 缺少必需变量时抛出明确错误。
|
|
@@ -47,12 +59,14 @@ export async function loadProvider() {
|
|
|
47
59
|
contextWindow = n;
|
|
48
60
|
}
|
|
49
61
|
}
|
|
62
|
+
const compactRatio = parseCompactRatio(process.env.MINIMAL_AGENT_COMPACT_RATIO);
|
|
50
63
|
return {
|
|
51
64
|
name: process.env.MINIMAL_AGENT_PROVIDER ?? 'env',
|
|
52
65
|
baseURL,
|
|
53
66
|
apiKey,
|
|
54
67
|
model,
|
|
55
68
|
contextWindow,
|
|
69
|
+
...(compactRatio !== undefined ? { compactRatio } : {}),
|
|
56
70
|
};
|
|
57
71
|
}
|
|
58
72
|
/**
|
|
@@ -90,12 +104,17 @@ export async function loadProviderLayered() {
|
|
|
90
104
|
else if (saved?.contextWindow) {
|
|
91
105
|
contextWindow = saved.contextWindow;
|
|
92
106
|
}
|
|
107
|
+
// compactRatio:env 优先(测试时临时 MINIMAL_AGENT_COMPACT_RATIO=0.2 即可),否则取 saved。
|
|
108
|
+
const envRatio = parseCompactRatio(process.env.MINIMAL_AGENT_COMPACT_RATIO);
|
|
109
|
+
const compactRatio = envRatio ??
|
|
110
|
+
(typeof saved?.compactRatio === 'number' ? saved.compactRatio : undefined);
|
|
93
111
|
return {
|
|
94
112
|
name: envName ?? saved?.provider ?? 'env',
|
|
95
113
|
baseURL,
|
|
96
114
|
apiKey,
|
|
97
115
|
model,
|
|
98
116
|
contextWindow,
|
|
117
|
+
...(compactRatio !== undefined ? { compactRatio } : {}),
|
|
99
118
|
};
|
|
100
119
|
}
|
|
101
120
|
/**
|
package/src/context/compact.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* ============================================================
|
|
5
5
|
* 做的事:
|
|
6
6
|
* 1. 估算当前历史的 token 数
|
|
7
|
-
* 2. 若超过阈值(contextWindow
|
|
7
|
+
* 2. 若超过阈值(contextWindow × compactRatio,默认 0.85),触发压缩
|
|
8
8
|
* 3. 压缩流程:
|
|
9
9
|
* a. 把整段历史 + 9 段式压缩 prompt 发给 LLM(非流式)
|
|
10
10
|
* b. 提取 <summary> 块
|
|
@@ -161,29 +161,32 @@ export function formatCompactSummary(rawResponse) {
|
|
|
161
161
|
const stripped = rawResponse.replace(/<analysis>[\s\S]*?<\/analysis>/g, '').trim();
|
|
162
162
|
return stripped || rawResponse.trim();
|
|
163
163
|
}
|
|
164
|
+
/** 自动压缩触发阈值默认占 contextWindow 的比例(可被 provider.compactRatio 覆盖)。 */
|
|
165
|
+
export const DEFAULT_COMPACT_RATIO = 0.85;
|
|
164
166
|
/**
|
|
165
|
-
*
|
|
167
|
+
* 阈值的绝对安全下限:至少给下一轮 LLM 输出留这么多 token。
|
|
166
168
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* - 发起压缩调用时把 9 段模板 prompt 也算上的余量 ~13K
|
|
170
|
-
*
|
|
171
|
-
* 对比:kakadeai 主项目用 ~33K(output 20K + buffer 13K),但它有
|
|
172
|
-
* prompt cache 收益所以可以更激进;我们没有 cache,25K 是稳健折中。
|
|
173
|
-
*
|
|
174
|
-
* 主动触发优先于 reactive 兜底——把 buffer 留宽一点,让 autoCompact
|
|
175
|
-
* 在 LLM 真撑爆之前先把上下文摘要掉,reactive 几乎不会被触发。
|
|
169
|
+
* 设计意图:无论 ratio 设多大,contextWindow - 8000 都必须空出来给
|
|
170
|
+
* 下一轮 LLM 输出(assistant 回答 + tool_calls),防止撑爆。
|
|
176
171
|
*/
|
|
177
|
-
export const
|
|
172
|
+
export const COMPACT_OUTPUT_BUFFER_TOKENS = 8_000;
|
|
178
173
|
/**
|
|
179
174
|
* 最少保留的消息数(即使没有 tool 调用也要保留这么多最近对话)
|
|
180
175
|
*/
|
|
181
176
|
export const MIN_KEEP_RECENT_MESSAGES = 4;
|
|
182
177
|
/**
|
|
183
|
-
* 给定 provider
|
|
178
|
+
* 给定 provider 的压缩触发阈值(按 contextWindow 比例缩放,provider 无关)。
|
|
179
|
+
*
|
|
180
|
+
* 公式:min(contextWindow × compactRatio, contextWindow − 8000),且至少 1000。
|
|
181
|
+
*
|
|
182
|
+
* 为何改成比例而不是固定 buffer:
|
|
183
|
+
* - 128K 窗口的 25K buffer = 81%,4K 窗口的 25K buffer = 负数(不合法)
|
|
184
|
+
* - 比例自动适配所有模型,测试时把 ratio 调到 0.2 即可轻松触发压缩
|
|
185
|
+
* - COMPACT_OUTPUT_BUFFER_TOKENS = 8000 是绝对安全下限,保证 LLM 有足够的输出空间
|
|
184
186
|
*/
|
|
185
187
|
export function getCompactThreshold(provider) {
|
|
186
|
-
|
|
188
|
+
const ratio = provider.compactRatio ?? DEFAULT_COMPACT_RATIO;
|
|
189
|
+
return Math.max(1000, Math.min(Math.floor(provider.contextWindow * ratio), provider.contextWindow - COMPACT_OUTPUT_BUFFER_TOKENS));
|
|
187
190
|
}
|
|
188
191
|
/**
|
|
189
192
|
* ✅ 核心修复函数:智能截取尾部消息,保证 tool_call / tool_result 完整性。
|
|
@@ -256,21 +259,43 @@ export function findTailWithCompleteToolChains(messages, minKeep = MIN_KEEP_RECE
|
|
|
256
259
|
}
|
|
257
260
|
}
|
|
258
261
|
}
|
|
259
|
-
|
|
262
|
+
// 不变量(Y3):尾部不能以「未配对的 assistant.tool_calls」结尾 —— 它的 tool 响应不在
|
|
263
|
+
// 本切片内,直接发 OpenAI 会 400(assistant 的每个 tool_calls 必须有后续对应的 tool 消息)。
|
|
264
|
+
// 当前 autoCompact 在每轮轮首调用、输入末尾天然完整;这里仍主动裁剪,解除对「调用时机」的
|
|
265
|
+
// 隐式依赖,让算法自身健壮(被裁的是裸 assistant,其 tool 响应本就不存在,不会留下孤儿 tool)。
|
|
266
|
+
let tail = messages.slice(tailStart);
|
|
267
|
+
while (tail.length > 0 &&
|
|
268
|
+
tail[tail.length - 1].role === 'assistant' &&
|
|
269
|
+
(tail[tail.length - 1].tool_calls
|
|
270
|
+
?.length ?? 0) > 0) {
|
|
271
|
+
tail = tail.slice(0, -1);
|
|
272
|
+
}
|
|
273
|
+
return tail;
|
|
260
274
|
}
|
|
261
275
|
/**
|
|
262
276
|
* 检查并按需压缩历史。
|
|
263
277
|
*
|
|
264
278
|
* @param messages 当前完整历史(必含一条 system)
|
|
265
|
-
* @param provider 当前 provider(取 contextWindow)
|
|
279
|
+
* @param provider 当前 provider(取 contextWindow / compactRatio)
|
|
280
|
+
* @param opts 可选的辅助数据,用于校正系统性低估:
|
|
281
|
+
* - actualPromptTokens:本轮 LLM done 事件返回的真实 prompt token 数(如有)
|
|
282
|
+
* - toolsTokens:工具 schema 占的估算 token 数(由 estimateToolsTokens 算出)
|
|
266
283
|
* @returns 可能更短的新 messages 数组(不修改原数组)
|
|
267
284
|
*
|
|
285
|
+
* 判据(取"真实 usage"与"估算+工具token"两者之大者):
|
|
286
|
+
* - countMessagesTokens 只数历史消息,漏了工具 schema token → 系统性低估
|
|
287
|
+
* - LLM 返回的真实 usage 是最精确的,但不是每次都有(老 provider 或关了 include_usage)
|
|
288
|
+
* - 取 max 确保:有真实值时用真实值;没有时用估算+工具补偿;二者都有时取最保守的
|
|
289
|
+
*
|
|
268
290
|
* 当 tokens 不超阈值时**直接返回原数组**(同引用),调用方可据此判断"是否压缩了"。
|
|
269
291
|
*/
|
|
270
|
-
export async function autoCompactIfNeeded(messages, provider) {
|
|
271
|
-
const before = countMessagesTokens(messages);
|
|
292
|
+
export async function autoCompactIfNeeded(messages, provider, opts) {
|
|
293
|
+
const before = countMessagesTokens(messages); // 纯历史 token,保持语义不变(UI/snip/对比均依赖此值)
|
|
294
|
+
// 判据:真实 usage(若有)与「估算 + 工具 schema」取大者,校正系统性低估
|
|
295
|
+
const estimated = before + (opts?.toolsTokens ?? 0);
|
|
296
|
+
const judge = Math.max(opts?.actualPromptTokens ?? 0, estimated);
|
|
272
297
|
const threshold = getCompactThreshold(provider);
|
|
273
|
-
if (
|
|
298
|
+
if (judge < threshold) {
|
|
274
299
|
return { messages, compacted: false, before, after: before };
|
|
275
300
|
}
|
|
276
301
|
// 真正要压缩了
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* src/context/reactiveCompact.ts —— 反应式压缩(错误自救)
|
|
4
4
|
* ------------------------------------------------------------
|
|
5
5
|
* 对齐 kakadeai 主项目 services/compact/reactiveCompact.ts:
|
|
6
|
-
* 当 API 返回 "prompt too long"
|
|
6
|
+
* 当 API 返回 "prompt too long" 类错误时,自动触发压缩重试。
|
|
7
7
|
*
|
|
8
8
|
* 典型场景:
|
|
9
9
|
* 用户灌了一大段上下文 → 调 LLM → 返回 400 prompt_too_long
|
|
@@ -11,27 +11,39 @@
|
|
|
11
11
|
* → 摘要失败再用 snipCompact 砍头兜底
|
|
12
12
|
* → 把新上下文交还给调用方,调用方重试一次 chat()
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* 防爆约束(circuit breaker):
|
|
15
|
+
* 连续自救失败 MAX_CONSECUTIVE_REACTIVE_FAILURES 次才熔断。
|
|
16
|
+
* 任意一次自救成功(LLM 压缩或 snip 兜底)→ 计数清零,
|
|
17
|
+
* 允许后续再次触发。行为:压缩成功→继续→再溢出→再压→正常往复。
|
|
18
|
+
* 用户 /new 重启会话后 consecutiveFailures 也归零。
|
|
18
19
|
* ============================================================
|
|
19
20
|
*/
|
|
20
21
|
import { forceCompact } from './compact.js';
|
|
21
22
|
import { snipCompactIfNeeded } from './snipCompact.js';
|
|
22
23
|
import { countMessagesTokens } from './tokenCounter.js';
|
|
23
24
|
export function createReactiveCompactState() {
|
|
24
|
-
return {
|
|
25
|
+
return { consecutiveFailures: 0 };
|
|
25
26
|
}
|
|
27
|
+
/** 连续失败几次触发熔断,拒绝继续自救 */
|
|
28
|
+
export const MAX_CONSECUTIVE_REACTIVE_FAILURES = 3;
|
|
26
29
|
const defaultState = createReactiveCompactState();
|
|
27
30
|
/** /new 时调用,允许下一个 session 再次自救 */
|
|
28
31
|
export function resetReactiveCompactState(state = defaultState) {
|
|
29
|
-
state.
|
|
32
|
+
state.consecutiveFailures = 0;
|
|
30
33
|
}
|
|
31
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* 测试 / 调试用:查询 circuit breaker 是否已熔断(连续失败达上限)。
|
|
36
|
+
*
|
|
37
|
+
* 原签名 hasAttemptedReactiveCompact 在新语义下映射为「电路是否已断开」:
|
|
38
|
+
* 单次成功就清零,只有连续失败 ≥ MAX_CONSECUTIVE_REACTIVE_FAILURES 才返回 true。
|
|
39
|
+
* 效果等价于原先的 attempted 旗标语义的"超集"(原先=1次,新=N次)。
|
|
40
|
+
* 别名 isReactiveCircuitOpen 供新代码使用,两者完全等价。
|
|
41
|
+
*/
|
|
32
42
|
export function hasAttemptedReactiveCompact(state = defaultState) {
|
|
33
|
-
return state.
|
|
43
|
+
return state.consecutiveFailures >= MAX_CONSECUTIVE_REACTIVE_FAILURES;
|
|
34
44
|
}
|
|
45
|
+
/** hasAttemptedReactiveCompact 的语义化别名 */
|
|
46
|
+
export const isReactiveCircuitOpen = hasAttemptedReactiveCompact;
|
|
35
47
|
// ==================== 错误识别 ====================
|
|
36
48
|
/**
|
|
37
49
|
* 判断一个错误是否是"提示词太长"类错误。
|
|
@@ -63,29 +75,37 @@ function errorMessage(error) {
|
|
|
63
75
|
return String(error ?? '');
|
|
64
76
|
}
|
|
65
77
|
/**
|
|
66
|
-
* 如果当前错误是 prompt_too_long
|
|
78
|
+
* 如果当前错误是 prompt_too_long 且 circuit breaker 未熔断,
|
|
67
79
|
* 执行一次"先 LLM 压缩、失败兜底 snip"的恢复流程。
|
|
68
80
|
*
|
|
81
|
+
* circuit breaker 规则:
|
|
82
|
+
* - 任意成功(LLM 压缩 or snip 兜底)→ consecutiveFailures 清零
|
|
83
|
+
* - 两个步骤都失败 → consecutiveFailures +1
|
|
84
|
+
* - consecutiveFailures ≥ MAX_CONSECUTIVE_REACTIVE_FAILURES → 熔断拒绝
|
|
85
|
+
*
|
|
69
86
|
* @param messages 当前历史(不修改)
|
|
70
87
|
* @param provider 当前 provider(用于 LLM 压缩)
|
|
71
88
|
* @param error 刚刚抛出的错误
|
|
89
|
+
* @param state 可选的独立状态(默认使用进程级单例)
|
|
72
90
|
*/
|
|
73
91
|
export async function reactiveCompactIfApplicable(messages, provider, error, state = defaultState) {
|
|
92
|
+
// 非 prompt_too_long 错误:直接短路,不消耗计数
|
|
74
93
|
if (!isPromptTooLongError(error)) {
|
|
75
94
|
return { recovered: false, messages, reason: 'not a prompt-too-long error' };
|
|
76
95
|
}
|
|
77
|
-
|
|
96
|
+
// 电路已熔断:连续失败达上限,拒绝继续
|
|
97
|
+
if (state.consecutiveFailures >= MAX_CONSECUTIVE_REACTIVE_FAILURES) {
|
|
78
98
|
return {
|
|
79
99
|
recovered: false,
|
|
80
100
|
messages,
|
|
81
|
-
reason: '
|
|
101
|
+
reason: '反应式压缩已熔断(连续失败达上限)——请 /new 或手动 /compact',
|
|
82
102
|
};
|
|
83
103
|
}
|
|
84
|
-
// 占位:即使下面失败也算"用过一次",防止反复触发
|
|
85
|
-
state.attempted = true;
|
|
86
104
|
// Step 1: 先试 LLM 全量压缩
|
|
87
105
|
try {
|
|
88
106
|
const r = await forceCompact(messages, provider);
|
|
107
|
+
// 救活成功 → 清零计数,让后续继续可用
|
|
108
|
+
state.consecutiveFailures = 0;
|
|
89
109
|
return {
|
|
90
110
|
recovered: true,
|
|
91
111
|
messages: r.messages,
|
|
@@ -94,16 +114,15 @@ export async function reactiveCompactIfApplicable(messages, provider, error, sta
|
|
|
94
114
|
after: r.after,
|
|
95
115
|
};
|
|
96
116
|
}
|
|
97
|
-
catch
|
|
117
|
+
catch {
|
|
98
118
|
// 压缩失败 → 走 snip 兜底
|
|
99
119
|
}
|
|
100
120
|
// Step 2: snip 兜底(更激进 40%)
|
|
101
121
|
const beforeSnip = countMessagesTokens(messages);
|
|
102
|
-
const snipped = snipCompactIfNeeded(messages, {
|
|
103
|
-
force: true,
|
|
104
|
-
snipPercent: 0.4,
|
|
105
|
-
});
|
|
122
|
+
const snipped = snipCompactIfNeeded(messages, { force: true, snipPercent: 0.4 });
|
|
106
123
|
if (snipped.messagesRemoved > 0) {
|
|
124
|
+
// snip 也算救活 → 清零计数
|
|
125
|
+
state.consecutiveFailures = 0;
|
|
107
126
|
const afterSnip = countMessagesTokens(snipped.messages);
|
|
108
127
|
return {
|
|
109
128
|
recovered: true,
|
|
@@ -113,6 +132,8 @@ export async function reactiveCompactIfApplicable(messages, provider, error, sta
|
|
|
113
132
|
after: afterSnip,
|
|
114
133
|
};
|
|
115
134
|
}
|
|
135
|
+
// 两步都没救活 → 失败计数 +1
|
|
136
|
+
state.consecutiveFailures++;
|
|
116
137
|
return {
|
|
117
138
|
recovered: false,
|
|
118
139
|
messages,
|