minimal-agent 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -486
- package/package.json +3 -1
- package/src/cli/args.js +33 -0
- package/src/cli/print.js +145 -17
- package/src/config/configFile.js +36 -6
- package/src/loop.js +14 -3
- package/src/main.js +41 -2
- package/src/plugins/pluginLoader.js +41 -1
- package/src/tools/grep/rgPath.js +10 -0
- 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.2",
|
|
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": {
|
package/src/cli/args.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/cli/args.ts —— CLI 参数解析纯函数
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 零依赖、零副作用的命令行参数提取工具,供 main.tsx 的 -p 分支使用。
|
|
6
|
+
*
|
|
7
|
+
* 为什么单独成文件(而非塞进 main.tsx):
|
|
8
|
+
* main.tsx 顶层有 `main().catch(...)` 自调用,一旦被 import 就会启动整个
|
|
9
|
+
* 应用。把纯函数抽出来,单测可零副作用地 import 断言。main.tsx re-export
|
|
10
|
+
* 同名符号,对外仍是「main.tsx 提供 extractFlagValue」的语义。
|
|
11
|
+
*
|
|
12
|
+
* 不引 commander:手撸即可,逻辑足够简单且教学清晰。
|
|
13
|
+
* ============================================================
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* 从 args 里提取「带值标志」的值:找到 names 中任一标志,取其后**紧跟的下一个元素**当 value。
|
|
17
|
+
*
|
|
18
|
+
* - 命中多次:取**第一次**出现的值
|
|
19
|
+
* - 标志在末尾(后面没有元素):返回 undefined(args[i+1] 天然是 undefined)
|
|
20
|
+
* - 没命中任何 name:返回 undefined
|
|
21
|
+
*
|
|
22
|
+
* 例:extractFlagValue(['-p','--output-format','json','x'], ['--output-format']) === 'json'
|
|
23
|
+
*
|
|
24
|
+
* 用于 -p 模式提取 --output-format / --max-turns 的值。
|
|
25
|
+
*/
|
|
26
|
+
export function extractFlagValue(args, names) {
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
if (names.includes(args[i])) {
|
|
29
|
+
return args[i + 1]; // 末尾标志时 args[i+1] 为 undefined,符合语义
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
package/src/cli/print.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
* ============================================================
|
|
3
3
|
* src/cli/print.ts —— CLI 非交互模式(-p / --print)
|
|
4
4
|
* ------------------------------------------------------------
|
|
5
|
-
* 不启动 Ink TUI
|
|
6
|
-
* 适合脚本集成、CI/CD
|
|
5
|
+
* 不启动 Ink TUI,直接执行单次问答,最后落盘 history。
|
|
6
|
+
* 适合脚本集成、CI/CD、宿主程序(hermes/openclaw)spawn 调用。
|
|
7
|
+
*
|
|
8
|
+
* 两种输出格式(--output-format,默认 text):
|
|
9
|
+
* text —— 把最终回答原样打到 stdout(向后兼容,逐字节不变)
|
|
10
|
+
* json —— stdout 只输出**一行** JSON 结局契约,其它诊断全走 stderr
|
|
7
11
|
*
|
|
8
12
|
* v2 改进(对齐 kakadeai CLI 工程实践):
|
|
9
13
|
* 1. SIGINT 先保存 context 再退出(防丢失)
|
|
@@ -15,9 +19,10 @@
|
|
|
15
19
|
* minimal-agent -p "提示"
|
|
16
20
|
* echo "提示" | minimal-agent -p
|
|
17
21
|
* minimal-agent -p --verbose "提示"
|
|
22
|
+
* minimal-agent -p --output-format json "提示" # 结构化结局
|
|
18
23
|
* ============================================================
|
|
19
24
|
*/
|
|
20
|
-
import { saveContext } from '../context/persistContext.js';
|
|
25
|
+
import { getContextPath, saveContext } from '../context/persistContext.js';
|
|
21
26
|
import { runWithPlugins } from '../plugins/pluginRunner.js';
|
|
22
27
|
/** stderr 显示工具结果时的截断阈值;超过即末尾加 "..." */
|
|
23
28
|
const TOOL_OUTPUT_PREVIEW_MAX = 200;
|
|
@@ -56,7 +61,13 @@ export function extractPromptArgs(args) {
|
|
|
56
61
|
'--version',
|
|
57
62
|
]);
|
|
58
63
|
// 带值标志:本身 + 后面紧跟的值都要跳过
|
|
59
|
-
|
|
64
|
+
// (--output-format / --max-turns 必须在此,否则它们的值会被当成 prompt 文本污染)
|
|
65
|
+
const FLAG_WITH_VALUE = new Set([
|
|
66
|
+
'-d',
|
|
67
|
+
'--cwd',
|
|
68
|
+
'--output-format',
|
|
69
|
+
'--max-turns',
|
|
70
|
+
]);
|
|
60
71
|
const result = [];
|
|
61
72
|
for (let i = 0; i < args.length; i++) {
|
|
62
73
|
const a = args[i];
|
|
@@ -71,12 +82,62 @@ export function extractPromptArgs(args) {
|
|
|
71
82
|
return result;
|
|
72
83
|
}
|
|
73
84
|
/**
|
|
74
|
-
*
|
|
85
|
+
* 输出 `-p --output-format json` 的结局契约(**一行** JSON 到 stdout)。
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ 诚实声明:`ok=true` 只代表「Agent 正常跑完(stop_reason==='end_turn')——
|
|
88
|
+
* 未报错、未被中断、未撞最大轮数」,**不代表任务目标真的达成**。工具失败会被
|
|
89
|
+
* 吞回 LLM 让它自行重试,LLM 完全可能在没产出有效结果的情况下正常收尾、给出
|
|
90
|
+
* end_turn。调用方(宿主程序)若要判断「任务是否真做成」,必须额外校验 `result`
|
|
91
|
+
* 文本内容 / 产物文件,不能只看 ok。
|
|
92
|
+
*
|
|
93
|
+
* 字段语义:
|
|
94
|
+
* ok = (stop_reason === 'end_turn')
|
|
95
|
+
* result = 累积的 assistant 文本
|
|
96
|
+
* is_error = !ok
|
|
97
|
+
* stop_reason = end_turn | max_turns | error | interrupted | config_error
|
|
98
|
+
* num_turns = assistant_message 事件计数
|
|
99
|
+
* error = 失败文案(成功时 null)
|
|
100
|
+
* session_file(可选)= 当前对话落盘路径;拿得到才填,拿不到省略该键
|
|
101
|
+
*
|
|
102
|
+
* 导出供单测断言。
|
|
103
|
+
*/
|
|
104
|
+
export function emitJsonResult(result, sessionFile) {
|
|
105
|
+
const stopReason = result.stopReason ?? 'error';
|
|
106
|
+
const ok = stopReason === 'end_turn';
|
|
107
|
+
console.log(JSON.stringify({
|
|
108
|
+
ok,
|
|
109
|
+
result: result.buffer,
|
|
110
|
+
is_error: !ok,
|
|
111
|
+
stop_reason: stopReason,
|
|
112
|
+
num_turns: result.numTurns,
|
|
113
|
+
error: result.error,
|
|
114
|
+
...(sessionFile ? { session_file: sessionFile } : {}),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 尝试拿当前会话落盘路径(给 json 契约的 session_file 字段)。
|
|
119
|
+
* 取不到(异常等)返回 undefined,emitJsonResult 据此省略该键(契约允许)。
|
|
120
|
+
*/
|
|
121
|
+
function trySessionFile() {
|
|
122
|
+
try {
|
|
123
|
+
return getContextPath();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* CLI 非交互模式:读取 prompt,执行 runQuery,最后落盘 history。
|
|
131
|
+
*
|
|
132
|
+
* - text 模式:流式 / 收尾把答案打到 stdout(逐字节兼容旧行为)
|
|
133
|
+
* - json 模式:stdout 只在退出时输出一行 JSON 结局契约(emitJsonResult)
|
|
75
134
|
*/
|
|
76
135
|
export async function runPrintMode(provider, args, initialHistory, options) {
|
|
77
136
|
// EPIPE 保护:管道断开时不崩溃(如 `bun -p "..." | head -1`)
|
|
78
137
|
process.stdout.on('error', handleEPIPE(process.stdout));
|
|
79
138
|
process.stderr.on('error', handleEPIPE(process.stderr));
|
|
139
|
+
const outputFormat = options.outputFormat ?? 'text';
|
|
140
|
+
const isJson = outputFormat === 'json';
|
|
80
141
|
const promptArgs = extractPromptArgs(args);
|
|
81
142
|
let prompt;
|
|
82
143
|
if (promptArgs.length > 0) {
|
|
@@ -95,53 +156,97 @@ export async function runPrintMode(provider, args, initialHistory, options) {
|
|
|
95
156
|
}
|
|
96
157
|
const abortController = new AbortController();
|
|
97
158
|
let interrupted = false;
|
|
159
|
+
const history = initialHistory;
|
|
160
|
+
// text 模式只关心 buffer;json 模式还要 numTurns / stopReason / error。
|
|
161
|
+
// 用同一个对象承载,handleEvent 按 outputFormat 决定写哪些字段。
|
|
162
|
+
const result = {
|
|
163
|
+
buffer: '',
|
|
164
|
+
numTurns: 0,
|
|
165
|
+
stopReason: undefined,
|
|
166
|
+
error: null,
|
|
167
|
+
};
|
|
98
168
|
process.on('SIGINT', () => {
|
|
99
169
|
if (interrupted)
|
|
100
170
|
process.exit(130);
|
|
101
171
|
interrupted = true;
|
|
102
172
|
abortController.abort();
|
|
103
173
|
console.error('\n已中断');
|
|
104
|
-
|
|
174
|
+
// json 模式:SIGINT 也要给出结构化结局(stop_reason='interrupted')
|
|
175
|
+
if (isJson) {
|
|
176
|
+
result.stopReason = 'interrupted';
|
|
177
|
+
void saveContext(history).finally(() => {
|
|
178
|
+
emitJsonResult(result, trySessionFile());
|
|
179
|
+
process.exit(130);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
void saveContext(history).finally(() => process.exit(130));
|
|
184
|
+
}
|
|
105
185
|
});
|
|
106
|
-
const history = initialHistory;
|
|
107
|
-
const output = { buffer: '' };
|
|
108
186
|
try {
|
|
109
187
|
for await (const event of runWithPlugins(prompt, {
|
|
110
188
|
provider,
|
|
111
189
|
history,
|
|
112
190
|
signal: abortController.signal,
|
|
191
|
+
maxTurns: options.maxTurns,
|
|
113
192
|
})) {
|
|
114
193
|
// event 是 LoopEvent | PluginEvent;handleEvent 只认 LoopEvent,未识别的插件私有事件走 default 静默忽略
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
194
|
+
const handled = handleEvent(event, result, options.verbose, outputFormat);
|
|
195
|
+
if (handled.exitCode !== undefined) {
|
|
117
196
|
await saveContext(history);
|
|
118
|
-
|
|
197
|
+
if (isJson)
|
|
198
|
+
emitJsonResult(result, trySessionFile());
|
|
199
|
+
process.exit(handled.exitCode);
|
|
119
200
|
}
|
|
120
201
|
}
|
|
121
202
|
}
|
|
122
203
|
catch (e) {
|
|
123
204
|
if (e.name === 'AbortError') {
|
|
124
205
|
await saveContext(history);
|
|
206
|
+
if (isJson) {
|
|
207
|
+
result.stopReason = 'interrupted';
|
|
208
|
+
emitJsonResult(result, trySessionFile());
|
|
209
|
+
}
|
|
125
210
|
process.exit(130);
|
|
126
211
|
}
|
|
127
212
|
console.error(`\n未捕获异常: ${e.message}`);
|
|
128
213
|
await saveContext(history);
|
|
214
|
+
if (isJson) {
|
|
215
|
+
// 未分类异常按 error 收尾(stopReason 仍为空 → emitJsonResult 兜底 'error')
|
|
216
|
+
result.error = result.error ?? e.message;
|
|
217
|
+
emitJsonResult(result, trySessionFile());
|
|
218
|
+
}
|
|
129
219
|
process.exit(1);
|
|
130
220
|
}
|
|
131
221
|
await saveContext(history);
|
|
222
|
+
// json 模式:for-await 正常跑完(没有触发任何 exitCode 退出点)也要 emit 一行结局。
|
|
223
|
+
if (isJson)
|
|
224
|
+
emitJsonResult(result, trySessionFile());
|
|
132
225
|
}
|
|
133
226
|
/**
|
|
134
227
|
* 处理 runQuery yield 出来的 LoopEvent。
|
|
135
228
|
* 纯事件分发函数,返回 { exitCode? } 由调用方决定是否退出。
|
|
229
|
+
*
|
|
230
|
+
* @param result 累积态(buffer 始终累积;json 模式还会写 numTurns/stopReason/error)
|
|
231
|
+
* @param verbose 是否把工具/压缩诊断打到 stderr
|
|
232
|
+
* @param outputFormat 'text'(默认)逐字节兼容旧行为;'json' 时 stdout 静默(只在退出时由 emitJsonResult 输出)
|
|
233
|
+
*
|
|
234
|
+
* ⚠️ 向后兼容:旧调用方 `handleEvent(ev, output, verbose)` 三参不传 outputFormat,
|
|
235
|
+
* 默认 'text',行为与改前逐字节一致。
|
|
136
236
|
*/
|
|
137
|
-
export function handleEvent(event,
|
|
237
|
+
export function handleEvent(event, result, verbose, outputFormat = 'text') {
|
|
238
|
+
const isJson = outputFormat === 'json';
|
|
138
239
|
switch (event.type) {
|
|
139
240
|
case 'text':
|
|
140
|
-
|
|
241
|
+
// text 模式:verbose 才流式写 stdout(与旧行为逐字节一致)。
|
|
242
|
+
// json 模式:绝不往 stdout 写任何东西(stdout 只允许最后那一行 JSON),仅累积 buffer。
|
|
243
|
+
if (verbose && !isJson)
|
|
141
244
|
process.stdout.write(event.delta);
|
|
142
|
-
|
|
245
|
+
result.buffer += event.delta;
|
|
143
246
|
return {};
|
|
144
247
|
case 'assistant_message':
|
|
248
|
+
// 一轮 assistant 消息完整组装 → 回合计数 +1(json 契约的 num_turns)
|
|
249
|
+
result.numTurns++;
|
|
145
250
|
return {};
|
|
146
251
|
case 'tool_start':
|
|
147
252
|
if (verbose) {
|
|
@@ -168,16 +273,39 @@ export function handleEvent(event, output, verbose) {
|
|
|
168
273
|
process.stderr.write(`${event.message}\n`);
|
|
169
274
|
return {};
|
|
170
275
|
case 'turn_done':
|
|
171
|
-
|
|
172
|
-
|
|
276
|
+
// text 模式:非 verbose 时这里一次性把累积答案打到 stdout(旧行为,逐字节不变)。
|
|
277
|
+
// json 模式:只记 stopReason='end_turn',stdout 保持静默。
|
|
278
|
+
result.stopReason = 'end_turn';
|
|
279
|
+
if (!isJson && !verbose)
|
|
280
|
+
console.log(result.buffer);
|
|
173
281
|
return {};
|
|
174
282
|
case 'error':
|
|
283
|
+
// compact_failed 是「非致命」信号:loop 压缩失败后会继续硬上(不 return),
|
|
284
|
+
// 这里**不退出**,只 stderr 警告 —— 对齐 loop 语义。
|
|
285
|
+
// (这同时修了一个旧瑕疵:之前 -p 收到该 error 会被误当致命退出。)
|
|
286
|
+
if (event.code === 'compact_failed') {
|
|
287
|
+
process.stderr.write(`${event.error}\n`);
|
|
288
|
+
return {};
|
|
289
|
+
}
|
|
290
|
+
// 其余 error 都是终结性的。
|
|
291
|
+
if (isJson) {
|
|
292
|
+
result.stopReason = event.code === 'max_turns' ? 'max_turns' : 'error';
|
|
293
|
+
result.error = event.error;
|
|
294
|
+
// json 模式不在这里打 stdout/stderr;由外层 emitJsonResult 统一输出
|
|
295
|
+
return { exitCode: 1 };
|
|
296
|
+
}
|
|
297
|
+
// text 模式:保持旧行为(红字 stderr + exitCode 1)
|
|
175
298
|
console.error(`\n错误: ${event.error}`);
|
|
176
299
|
return { exitCode: 1 };
|
|
300
|
+
case 'interrupted':
|
|
301
|
+
// 用户中断:json 模式记 stop_reason;外层退出点(catch / for-await 收尾 / SIGINT)emit。
|
|
302
|
+
// text 模式无需特殊处理(与旧行为一致:静默忽略,靠 SIGINT handler 退出)。
|
|
303
|
+
if (isJson)
|
|
304
|
+
result.stopReason = 'interrupted';
|
|
305
|
+
return {};
|
|
177
306
|
// 已知但 -p 模式无需呈现的框架事件:显式忽略,好让 default 只剩"真正未知"的插件私有事件。
|
|
178
307
|
case 'user_message_committed':
|
|
179
308
|
case 'tool_messages_committed':
|
|
180
|
-
case 'interrupted':
|
|
181
309
|
case 'plugin_progress':
|
|
182
310
|
case 'reasoning':
|
|
183
311
|
case 'stage_change':
|
package/src/config/configFile.js
CHANGED
|
@@ -20,18 +20,24 @@
|
|
|
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
30
|
return (process.env.MINIMAL_AGENT_CONFIG_FILE ??
|
|
26
31
|
join(homedir(), '.minimal-agent', 'config.json'));
|
|
27
32
|
}
|
|
33
|
+
/** home 默认配置路径(读分层的最后一档)。 */
|
|
34
|
+
function homeConfigPath() {
|
|
35
|
+
return join(homedir(), '.minimal-agent', 'config.json');
|
|
36
|
+
}
|
|
28
37
|
/**
|
|
29
|
-
*
|
|
30
|
-
* - 文件不存在 / JSON 损坏 / 必填字段缺失 → 返回 null
|
|
31
|
-
* - 成功 → 返回 SavedConfig
|
|
38
|
+
* 解析单个 config.json:不存在 / JSON 损坏 / 必填字段缺失 → null;成功 → SavedConfig。
|
|
32
39
|
*/
|
|
33
|
-
|
|
34
|
-
const file = getConfigFilePath();
|
|
40
|
+
async function parseConfigFile(file) {
|
|
35
41
|
try {
|
|
36
42
|
const raw = await readFile(file, 'utf8');
|
|
37
43
|
const data = JSON.parse(raw);
|
|
@@ -61,6 +67,30 @@ export async function readSavedConfig() {
|
|
|
61
67
|
return null;
|
|
62
68
|
}
|
|
63
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* 读取已保存的 provider 配置(**分层**,按优先级返回第一个有效的):
|
|
72
|
+
* 1. `MINIMAL_AGENT_CONFIG_FILE` 显式覆盖 —— **只读这一个,不 fallthrough**
|
|
73
|
+
* (保持既有语义 + 测试隔离)
|
|
74
|
+
* 2. exe 同级 `config.json` —— **绿色版 / U 盘的卖家预置配置**(compile 后
|
|
75
|
+
* `execSiblingDir()` = 真实 exe 目录;dev/npm 下是 node/bun 二进制目录、无此文件 → 跳过)
|
|
76
|
+
* 3. `~/.minimal-agent/config.json` —— 首次配置向导写出的(原行为)
|
|
77
|
+
*
|
|
78
|
+
* 这层让卖家把配好的 config.json 放进绿色目录即完成预配置,客户端零配置开箱即用
|
|
79
|
+
* (loadProviderLayered 拿到 provider → 不进向导、`-p` 不报 config_error)。
|
|
80
|
+
*
|
|
81
|
+
* 加性保证:dev / npm 模式无 override 时,exe 同级无 config.json → 只命中 home → 行为不变。
|
|
82
|
+
*/
|
|
83
|
+
export async function readSavedConfig() {
|
|
84
|
+
const override = process.env.MINIMAL_AGENT_CONFIG_FILE;
|
|
85
|
+
if (override)
|
|
86
|
+
return parseConfigFile(override);
|
|
87
|
+
for (const file of [join(execSiblingDir(), 'config.json'), homeConfigPath()]) {
|
|
88
|
+
const cfg = await parseConfigFile(file);
|
|
89
|
+
if (cfg)
|
|
90
|
+
return cfg;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
64
94
|
/**
|
|
65
95
|
* 写入向导收集到的配置。
|
|
66
96
|
* unix 上 chmod 600(仅本人可读写),Windows 上 chmod 是 no-op,靠 NTFS ACL。
|
package/src/loop.js
CHANGED
|
@@ -70,7 +70,8 @@ export async function* runQuery(userInput, options) {
|
|
|
70
70
|
timestamp: Date.now(),
|
|
71
71
|
id: crypto.randomUUID(),
|
|
72
72
|
});
|
|
73
|
-
|
|
73
|
+
// code='aborted':让 `-p --output-format json` 把终结原因映射成 stop_reason='interrupted'
|
|
74
|
+
yield { type: 'error', error: '已被用户中断', code: 'aborted' };
|
|
74
75
|
return;
|
|
75
76
|
}
|
|
76
77
|
// 2. 自动压缩
|
|
@@ -88,10 +89,13 @@ export async function* runQuery(userInput, options) {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
catch (e) {
|
|
91
|
-
//
|
|
92
|
+
// 压缩失败不要让整个对话挂掉。
|
|
93
|
+
// code='compact_failed':标记为「非致命」——注意此处 yield error 后**不 return**,
|
|
94
|
+
// loop 继续往下跑(不压缩硬上)。`-p --output-format json` 据此只 stderr 警告、不退出。
|
|
92
95
|
yield {
|
|
93
96
|
type: 'error',
|
|
94
97
|
error: `自动压缩失败(继续不压缩):${e.message}`,
|
|
98
|
+
code: 'compact_failed',
|
|
95
99
|
};
|
|
96
100
|
}
|
|
97
101
|
// 3. 调 LLM 并组装 assistant 消息
|
|
@@ -179,7 +183,12 @@ export async function* runQuery(userInput, options) {
|
|
|
179
183
|
}
|
|
180
184
|
// reactive 也失败 → 退回正常错误路径
|
|
181
185
|
}
|
|
182
|
-
|
|
186
|
+
// code='llm_error':终结性错误,stop_reason='error'
|
|
187
|
+
yield {
|
|
188
|
+
type: 'error',
|
|
189
|
+
error: `LLM 调用失败:${e.message}`,
|
|
190
|
+
code: 'llm_error',
|
|
191
|
+
};
|
|
183
192
|
return;
|
|
184
193
|
}
|
|
185
194
|
const assistantMsg = {
|
|
@@ -312,9 +321,11 @@ export async function* runQuery(userInput, options) {
|
|
|
312
321
|
}
|
|
313
322
|
// 6. 继续 while 让模型看到 tool_result 后继续推理
|
|
314
323
|
}
|
|
324
|
+
// code='max_turns':撞最大轮数上限而终结,stop_reason='max_turns'
|
|
315
325
|
yield {
|
|
316
326
|
type: 'error',
|
|
317
327
|
error: `达到最大轮数 ${maxTurns},提前结束(防止失控)。如果合理可以提高 maxTurns。`,
|
|
328
|
+
code: 'max_turns',
|
|
318
329
|
};
|
|
319
330
|
}
|
|
320
331
|
// ---------------- 辅助函数 ----------------
|
package/src/main.js
CHANGED
|
@@ -5,7 +5,18 @@ import { existsSync, mkdirSync } from 'node:fs';
|
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
|
-
const pkg =
|
|
8
|
+
const pkg = (() => {
|
|
9
|
+
try {
|
|
10
|
+
return require('../package.json');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return {
|
|
14
|
+
version: typeof __GREEN_BUILD_VERSION__ === 'string'
|
|
15
|
+
? __GREEN_BUILD_VERSION__
|
|
16
|
+
: '0.0.0',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
9
20
|
import { extractCwdArg } from './bootstrap/cwdArg.js';
|
|
10
21
|
import { initWorkingDir, getWorkingDir } from './bootstrap/workingDir.js';
|
|
11
22
|
import { applyToolKeysToEnv, loadProviderLayered } from './config.js';
|
|
@@ -15,6 +26,10 @@ import { buildFullSystemPrompt } from './prompts/system.js';
|
|
|
15
26
|
import { ALL_TOOLS } from './tools/index.js';
|
|
16
27
|
import { Root } from './ui/Root.js';
|
|
17
28
|
import { runPrintMode } from './cli/print.js';
|
|
29
|
+
import { extractFlagValue } from './cli/args.js';
|
|
30
|
+
// re-export:对外保持「main.tsx 提供 extractFlagValue」的语义;实现住在 cli/args.ts,
|
|
31
|
+
// 这样单测可零副作用 import(main.tsx 顶层 main() 自调用会启动整个 app,不可被测试 import)。
|
|
32
|
+
export { extractFlagValue };
|
|
18
33
|
async function main() {
|
|
19
34
|
// ★ -d/--cwd:在锁定工作目录之前先建目录 + chdir,让后续逻辑全在 -d 下运行
|
|
20
35
|
// 不存在则自动 mkdir -p;后续 initWorkingDir() 自然拿到新 cwd
|
|
@@ -46,10 +61,30 @@ async function main() {
|
|
|
46
61
|
}
|
|
47
62
|
const isPrintMode = args.includes('-p') || args.includes('--print');
|
|
48
63
|
if (isPrintMode) {
|
|
64
|
+
// 先把输出格式解析出来——provider 缺失分支也要按 json 契约给信号。
|
|
65
|
+
// 只接受 'text' | 'json',其它值(含拼错)一律回落 'text'(向后兼容)。
|
|
66
|
+
const rawFormat = extractFlagValue(args, ['--output-format']);
|
|
67
|
+
const outputFormat = rawFormat === 'json' ? 'json' : 'text';
|
|
68
|
+
// --max-turns:parseInt,非法(NaN)→ undefined(走 loop 默认 50)
|
|
69
|
+
const rawMaxTurns = extractFlagValue(args, ['--max-turns']);
|
|
70
|
+
const parsedMaxTurns = rawMaxTurns !== undefined ? Number.parseInt(rawMaxTurns, 10) : Number.NaN;
|
|
71
|
+
const maxTurns = Number.isNaN(parsedMaxTurns) ? undefined : parsedMaxTurns;
|
|
49
72
|
// CLI 非交互模式:先 env,再 ~/.minimal-agent/config.json fallback;都没就退出
|
|
50
73
|
// (宿主 spawn 子进程时通常没 export env,靠 TUI 向导写出的 config.json 持久化)
|
|
51
74
|
const provider = await loadProviderLayered();
|
|
52
75
|
if (!provider) {
|
|
76
|
+
// json 模式:先给宿主一行结构化信号(stop_reason='config_error'),再退出。
|
|
77
|
+
// 人类可读的中文提示仍走 stderr(json 契约只占 stdout 那一行)。
|
|
78
|
+
if (outputFormat === 'json') {
|
|
79
|
+
console.log(JSON.stringify({
|
|
80
|
+
ok: false,
|
|
81
|
+
result: '',
|
|
82
|
+
is_error: true,
|
|
83
|
+
stop_reason: 'config_error',
|
|
84
|
+
num_turns: 0,
|
|
85
|
+
error: 'provider config not found',
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
53
88
|
process.stderr.write(`\n未找到 provider 配置。\n\n` +
|
|
54
89
|
`请二选一:\n` +
|
|
55
90
|
` 1. 设置环境变量 MINIMAL_AGENT_BASE_URL / MINIMAL_AGENT_API_KEY / MINIMAL_AGENT_MODEL\n` +
|
|
@@ -58,7 +93,7 @@ async function main() {
|
|
|
58
93
|
}
|
|
59
94
|
const initialHistory = await buildInitialHistory();
|
|
60
95
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
61
|
-
await runPrintMode(provider, args, initialHistory, { verbose });
|
|
96
|
+
await runPrintMode(provider, args, initialHistory, { verbose, outputFormat, maxTurns });
|
|
62
97
|
return;
|
|
63
98
|
}
|
|
64
99
|
// TUI 交互模式:env 不全则 fallback 到 ~/.minimal-agent/config.json;
|
|
@@ -96,6 +131,9 @@ minimal-agent - 轻量级 AI 编程助手
|
|
|
96
131
|
-v, --verbose 显示详细输出(工具调用、压缩信息)
|
|
97
132
|
-d, --cwd <dir> 指定工作目录(不存在自动创建);启动时 chdir 到这里,
|
|
98
133
|
上下文文件、工具相对路径、.env 加载都以此为基准
|
|
134
|
+
--output-format <fmt> text(默认)= 纯文本答案;json = stdout 输出一行结构化
|
|
135
|
+
结局契约(ok / result / stop_reason / num_turns / error)
|
|
136
|
+
--max-turns <n> 最大工具循环轮数(防失控;缺省 50)
|
|
99
137
|
-h, --help 显示帮助信息
|
|
100
138
|
|
|
101
139
|
会话记忆:
|
|
@@ -108,6 +146,7 @@ minimal-agent - 轻量级 AI 编程助手
|
|
|
108
146
|
echo "解释代码" | minimal-agent -p
|
|
109
147
|
minimal-agent -p --verbose "运行测试并报告结果"
|
|
110
148
|
minimal-agent -p "处理资料" -d /tmp/job-123 # 工作目录隔离
|
|
149
|
+
minimal-agent -p --output-format json "做点事" # 宿主程序 spawn:一行 JSON 结局
|
|
111
150
|
`);
|
|
112
151
|
}
|
|
113
152
|
main().catch((e) => {
|
|
@@ -24,9 +24,28 @@
|
|
|
24
24
|
* ============================================================
|
|
25
25
|
*/
|
|
26
26
|
import { existsSync, statSync } from 'node:fs';
|
|
27
|
-
import { join } from 'node:path';
|
|
27
|
+
import { basename, join } from 'node:path';
|
|
28
28
|
import { pathToFileURL } from 'node:url';
|
|
29
29
|
const loaderCache = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* 内置插件静态注册表(仅绿色版 compile 入口 entry.compiled.tsx 会填充)。
|
|
32
|
+
*
|
|
33
|
+
* 背景:`bun --compile` 后,rich 插件的 plugin.ts 顺链 import 的框架 SDK
|
|
34
|
+
* (`../../../src/plugin-sdk.js`)已被焊进 exe、磁盘上不存在 → 动态 import
|
|
35
|
+
* 外部 plugin.ts 必抛错。绿色版改为在 compile 入口**静态 import** 内置插件
|
|
36
|
+
* (`src/plugins/builtinRegistry.ts`),让 bun 把执行器整树打进 exe,再经
|
|
37
|
+
* setBuiltinPluginApi 注册到这里;loadPluginApi 在磁盘加载缺失/失败时回退到此表。
|
|
38
|
+
*
|
|
39
|
+
* dev / npm 模式:entry.compiled 永不被 import → 此表恒为空 → 回退分支不触发
|
|
40
|
+
* → loadPluginApi 行为与改动前逐字节一致。
|
|
41
|
+
*
|
|
42
|
+
* key = 插件目录名(`plugins/<id>/` 的 `<id>`,等于 `basename(pluginRoot)`)。
|
|
43
|
+
*/
|
|
44
|
+
const builtinApis = new Map();
|
|
45
|
+
/** 由绿色版 compile 入口调用,注册内置插件执行器。dev/npm 不会调用。 */
|
|
46
|
+
export function setBuiltinPluginApi(key, api) {
|
|
47
|
+
builtinApis.set(key, api);
|
|
48
|
+
}
|
|
30
49
|
function isPluginApi(value) {
|
|
31
50
|
if (!value || typeof value !== 'object')
|
|
32
51
|
return false;
|
|
@@ -62,8 +81,24 @@ export async function loadPluginApi(pluginRoot) {
|
|
|
62
81
|
// stat 失败静默,不影响加载
|
|
63
82
|
}
|
|
64
83
|
}
|
|
84
|
+
// 绿色版回退:磁盘加载缺失/失败时,按目录名查 exe 内静态注册表。
|
|
85
|
+
// dev/npm 注册表恒空 → 永远返回 null → 不改变下方任何分支的最终结果。
|
|
86
|
+
const builtinKey = basename(pluginRoot);
|
|
87
|
+
const builtinFallback = () => {
|
|
88
|
+
const api = builtinApis.get(builtinKey);
|
|
89
|
+
if (api) {
|
|
90
|
+
loaderCache.set(pluginRoot, api);
|
|
91
|
+
return api;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
65
95
|
const pluginEntry = jsExists ? jsPath : tsExists ? tsPath : undefined;
|
|
66
96
|
if (!pluginEntry) {
|
|
97
|
+
// 磁盘无 plugin.js/.ts:绿色目录只铺声明式部分(plugin.json + commands/),
|
|
98
|
+
// 执行器在 exe 内注册表 → 这里命中。dev/npm 注册表空 → 维持原 null。
|
|
99
|
+
const fb = builtinFallback();
|
|
100
|
+
if (fb)
|
|
101
|
+
return fb;
|
|
67
102
|
loaderCache.set(pluginRoot, null);
|
|
68
103
|
return null;
|
|
69
104
|
}
|
|
@@ -79,6 +114,11 @@ export async function loadPluginApi(pluginRoot) {
|
|
|
79
114
|
return candidate;
|
|
80
115
|
}
|
|
81
116
|
catch (err) {
|
|
117
|
+
// 绿色版:磁盘铺了 plugin.js 但它顺链 import 的框架 SDK 在 exe 内、磁盘没有
|
|
118
|
+
// → import 抛错 → 回退到 exe 内注册表。dev/npm 注册表空 → 维持原 warn + null。
|
|
119
|
+
const fb = builtinFallback();
|
|
120
|
+
if (fb)
|
|
121
|
+
return fb;
|
|
82
122
|
console.warn(`[minimal-agent] failed to load plugin entry at ${pluginRoot}: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
123
|
loaderCache.set(pluginRoot, null);
|
|
84
124
|
return null;
|
package/src/tools/grep/rgPath.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { spawn } from 'node:child_process';
|
|
27
27
|
import { chmodSync, existsSync } from 'node:fs';
|
|
28
28
|
import { resolve } from 'node:path';
|
|
29
|
+
import { execSiblingDir } from '../../utils/greenRoot.js';
|
|
29
30
|
import { findPackageRoot } from '../../utils/packageRoot.js';
|
|
30
31
|
let cached;
|
|
31
32
|
/**
|
|
@@ -53,6 +54,15 @@ async function detect() {
|
|
|
53
54
|
ensureExecutable(vendored);
|
|
54
55
|
return vendored;
|
|
55
56
|
}
|
|
57
|
+
// ②.5 绿色版(bun --compile 单文件):exe 同级 vendor/ripgrep/<arch>-<platform>/rg
|
|
58
|
+
// compile 后 import.meta.url 是虚拟路径,findPackageRoot 失效(② 命不中),
|
|
59
|
+
// 绿色目录把 vendor/ 摆在可执行文件旁边,从这里捞回来。dev/npm 下 exe 同级
|
|
60
|
+
// 没有 vendor/ → existsSync 过滤掉 → 不影响 ②/③/④ 原有优先级。
|
|
61
|
+
const sibling = resolve(execSiblingDir(), 'vendor', 'ripgrep', subdir(), exeName());
|
|
62
|
+
if (existsSync(sibling)) {
|
|
63
|
+
ensureExecutable(sibling);
|
|
64
|
+
return sibling;
|
|
65
|
+
}
|
|
56
66
|
// ③ PATH 上的 rg —— 直接试 spawn rg --version
|
|
57
67
|
if (await trySpawn('rg'))
|
|
58
68
|
return 'rg';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/utils/greenRoot.ts —— 绿色版(bun --compile 单文件)资源探测锚点
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 问题:`bun build --compile` 把运行时 + 全部 JS 焊进单个可执行文件,
|
|
6
|
+
* 此时 `import.meta.url` 指向嵌入式虚拟路径(`/$bunfs/...`),磁盘上
|
|
7
|
+
* 既没有 package.json 也没有源码树 —— `findPackageRoot` 会抛错,靠它
|
|
8
|
+
* 定位的 `vendor/ripgrep/`、`skills/`、`plugins/`、`workflows/` 全失效。
|
|
9
|
+
*
|
|
10
|
+
* 方案:绿色版把这些外部资产与可执行文件**放在同一目录**,运行时用
|
|
11
|
+
* `dirname(process.execPath)` 找回来(exe 同级)。
|
|
12
|
+
*
|
|
13
|
+
* 为什么 dev / npm 模式零影响:
|
|
14
|
+
* - dev(`bun run src/main.tsx`):execPath = bun 二进制,其同级目录
|
|
15
|
+
* (如 `~/.bun/bin/`)没有 vendor/skills/plugins。
|
|
16
|
+
* - npm 全局安装(`node dist/main.js`):execPath = node 二进制,其
|
|
17
|
+
* 同级目录同样没有这些资源目录。
|
|
18
|
+
* - 故调用方一律对返回值做 `existsSync` 过滤:dev/npm 下命不中 → 被
|
|
19
|
+
* 丢弃 → 行为与现状逐字节一致。只有真·绿色版(资产摆在 exe 旁边)
|
|
20
|
+
* 才会命中。因此这里**无条件返回** execPath 目录即可,无需判别
|
|
21
|
+
* "是否 compiled"。
|
|
22
|
+
* ============================================================
|
|
23
|
+
*/
|
|
24
|
+
import { dirname } from 'node:path';
|
|
25
|
+
/**
|
|
26
|
+
* 返回可执行文件所在目录(绿色版资产的同级锚点)。
|
|
27
|
+
*
|
|
28
|
+
* 调用方必须用 existsSync 过滤返回路径下的目标子目录 —— dev/npm 模式
|
|
29
|
+
* execPath 指向 node/bun 二进制,其同级没有资源目录,过滤后无副作用。
|
|
30
|
+
*/
|
|
31
|
+
export function execSiblingDir() {
|
|
32
|
+
return dirname(process.execPath);
|
|
33
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { existsSync } from 'node:fs';
|
|
22
22
|
import { join, resolve } from 'node:path';
|
|
23
23
|
import { getWorkingDir } from '../bootstrap/workingDir.js';
|
|
24
|
+
import { execSiblingDir } from './greenRoot.js';
|
|
24
25
|
import { findPackageRoot } from './packageRoot.js';
|
|
25
26
|
/**
|
|
26
27
|
* 返回资源目录的搜索顺序数组(cwd 优先 + packageRoot fallback)。
|
|
@@ -35,6 +36,13 @@ export function getResourceSearchPaths(name, metaUrl) {
|
|
|
35
36
|
if (existsSync(cwdPath)) {
|
|
36
37
|
paths.push(cwdPath);
|
|
37
38
|
}
|
|
39
|
+
// 绿色版(bun --compile 单文件):exe 同级 <name>/ —— compile 后 findPackageRoot
|
|
40
|
+
// 失效,资源目录摆在可执行文件旁边从这里命中。dev/npm 下 exe 同级(node/bun
|
|
41
|
+
// 二进制所在目录)没有资源目录 → existsSync 过滤 → 数组与现状一致。
|
|
42
|
+
const sibPath = resolve(join(execSiblingDir(), name));
|
|
43
|
+
if (!paths.includes(sibPath) && existsSync(sibPath)) {
|
|
44
|
+
paths.push(sibPath);
|
|
45
|
+
}
|
|
38
46
|
let pkgPath;
|
|
39
47
|
try {
|
|
40
48
|
pkgPath = resolve(join(findPackageRoot(metaUrl), name));
|
|
@@ -42,7 +50,7 @@ export function getResourceSearchPaths(name, metaUrl) {
|
|
|
42
50
|
catch {
|
|
43
51
|
return paths;
|
|
44
52
|
}
|
|
45
|
-
if (pkgPath
|
|
53
|
+
if (!paths.includes(pkgPath) && existsSync(pkgPath)) {
|
|
46
54
|
paths.push(pkgPath);
|
|
47
55
|
}
|
|
48
56
|
return paths;
|