minimal-agent 0.6.1 → 0.6.3
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/README.md +144 -486
- package/package.json +3 -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 +42 -8
- 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 +43 -5
- package/src/plugins/pluginLoader.js +41 -1
- package/src/plugins/transcript.js +3 -1
- package/src/tools/bash/bash.js +34 -4
- package/src/tools/grep/rgPath.js +10 -0
- package/src/ui/hooks/useTokenUsage.js +3 -2
- package/src/utils/greenRoot.js +33 -0
- package/src/utils/resourcePaths.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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>",
|
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
"clean": "bun scripts/clean-build.ts",
|
|
55
55
|
"test": "bun test",
|
|
56
56
|
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json",
|
|
57
|
+
"compile:green": "bun scripts/build-green.ts",
|
|
58
|
+
"compile:green:host": "bun scripts/build-green.ts --host",
|
|
57
59
|
"prepublishOnly": "bun run clean && bun run build"
|
|
58
60
|
},
|
|
59
61
|
"dependencies": {
|
|
@@ -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
|
@@ -20,18 +20,23 @@
|
|
|
20
20
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
22
|
import { dirname, join } from 'node:path';
|
|
23
|
-
|
|
23
|
+
import { execSiblingDir } from '../utils/greenRoot.js';
|
|
24
|
+
/**
|
|
25
|
+
* 配置文件的**写**路径(带环境变量覆盖,主要给测试用)。
|
|
26
|
+
* `saveConfig()` / 首次配置向导只写这里——env override 或 home,**绝不写 exe 同级**
|
|
27
|
+
* (U 盘可能只读,且不该污染产品目录)。读路径见 readSavedConfig 的分层。
|
|
28
|
+
*/
|
|
24
29
|
export function getConfigFilePath() {
|
|
25
|
-
return
|
|
26
|
-
|
|
30
|
+
return process.env.MINIMAL_AGENT_CONFIG_FILE ?? homeConfigPath();
|
|
31
|
+
}
|
|
32
|
+
/** home 默认配置路径(读分层的最后一档;也是 getConfigFilePath 的默认写入位置)。 */
|
|
33
|
+
function homeConfigPath() {
|
|
34
|
+
return join(homedir(), '.minimal-agent', 'config.json');
|
|
27
35
|
}
|
|
28
36
|
/**
|
|
29
|
-
*
|
|
30
|
-
* - 文件不存在 / JSON 损坏 / 必填字段缺失 → 返回 null
|
|
31
|
-
* - 成功 → 返回 SavedConfig
|
|
37
|
+
* 解析单个 config.json:不存在 / JSON 损坏 / 必填字段缺失 → null;成功 → SavedConfig。
|
|
32
38
|
*/
|
|
33
|
-
|
|
34
|
-
const file = getConfigFilePath();
|
|
39
|
+
async function parseConfigFile(file) {
|
|
35
40
|
try {
|
|
36
41
|
const raw = await readFile(file, 'utf8');
|
|
37
42
|
const data = JSON.parse(raw);
|
|
@@ -51,6 +56,11 @@ export async function readSavedConfig() {
|
|
|
51
56
|
contextWindow: typeof data.contextWindow === 'number' && data.contextWindow > 0
|
|
52
57
|
? data.contextWindow
|
|
53
58
|
: undefined,
|
|
59
|
+
compactRatio: typeof data.compactRatio === 'number' &&
|
|
60
|
+
data.compactRatio > 0 &&
|
|
61
|
+
data.compactRatio <= 1
|
|
62
|
+
? data.compactRatio
|
|
63
|
+
: undefined,
|
|
54
64
|
tavilyApiKey: typeof data.tavilyApiKey === 'string' && data.tavilyApiKey.length > 0
|
|
55
65
|
? data.tavilyApiKey
|
|
56
66
|
: undefined,
|
|
@@ -61,6 +71,30 @@ export async function readSavedConfig() {
|
|
|
61
71
|
return null;
|
|
62
72
|
}
|
|
63
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* 读取已保存的 provider 配置(**分层**,按优先级返回第一个有效的):
|
|
76
|
+
* 1. `MINIMAL_AGENT_CONFIG_FILE` 显式覆盖 —— **只读这一个,不 fallthrough**
|
|
77
|
+
* (保持既有语义 + 测试隔离)
|
|
78
|
+
* 2. exe 同级 `config.json` —— **绿色版 / U 盘的卖家预置配置**(compile 后
|
|
79
|
+
* `execSiblingDir()` = 真实 exe 目录;dev/npm 下是 node/bun 二进制目录、无此文件 → 跳过)
|
|
80
|
+
* 3. `~/.minimal-agent/config.json` —— 首次配置向导写出的(原行为)
|
|
81
|
+
*
|
|
82
|
+
* 这层让卖家把配好的 config.json 放进绿色目录即完成预配置,客户端零配置开箱即用
|
|
83
|
+
* (loadProviderLayered 拿到 provider → 不进向导、`-p` 不报 config_error)。
|
|
84
|
+
*
|
|
85
|
+
* 加性保证:dev / npm 模式无 override 时,exe 同级无 config.json → 只命中 home → 行为不变。
|
|
86
|
+
*/
|
|
87
|
+
export async function readSavedConfig() {
|
|
88
|
+
const override = process.env.MINIMAL_AGENT_CONFIG_FILE;
|
|
89
|
+
if (override)
|
|
90
|
+
return parseConfigFile(override);
|
|
91
|
+
for (const file of [join(execSiblingDir(), 'config.json'), homeConfigPath()]) {
|
|
92
|
+
const cfg = await parseConfigFile(file);
|
|
93
|
+
if (cfg)
|
|
94
|
+
return cfg;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
64
98
|
/**
|
|
65
99
|
* 写入向导收集到的配置。
|
|
66
100
|
* unix 上 chmod 600(仅本人可读写),Windows 上 chmod 是 no-op,靠 NTFS ACL。
|
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
|
// 真正要压缩了
|