minimal-agent 0.6.2 → 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/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/plugins/transcript.js +3 -1
- package/src/tools/bash/bash.js +34 -4
- package/src/ui/hooks/useTokenUsage.js +3 -2
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/context/recentDirs.ts —— 最近工作目录注册表(Web 侧栏用)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 做的事:
|
|
6
|
+
* 维护 ~/.minimal-agent/recent-dirs.json —— 一个"用过哪些工作目录"的列表。
|
|
7
|
+
* 每个工作目录 = 一个会话(项目"一 cwd 一 session"约束),Web 前端侧栏据此
|
|
8
|
+
* 列出会话;每条记录顺带存该目录的会话文件路径,让前端不必复刻哈希命名规则
|
|
9
|
+
* 即可定位历史。
|
|
10
|
+
*
|
|
11
|
+
* 谁写:stream-json 模式每轮开始时 recordRecentDir(cwd, 首条用户消息)。
|
|
12
|
+
* 谁读:webchat 服务端直接 JSON.parse 本文件(不 import 后端业务模块)。
|
|
13
|
+
*
|
|
14
|
+
* 抉择:读失败返回 [];写失败静默 —— 注册表只是 UI 便利,绝不该影响对话本身。
|
|
15
|
+
* ============================================================
|
|
16
|
+
*/
|
|
17
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { sessionFileFor } from './sessionPath.js';
|
|
21
|
+
/** 注册表上限:只留最近 N 个,防文件无限增长。 */
|
|
22
|
+
const MAX_ENTRIES = 50;
|
|
23
|
+
function registryPath() {
|
|
24
|
+
return join(homedir(), '.minimal-agent', 'recent-dirs.json');
|
|
25
|
+
}
|
|
26
|
+
/** 读注册表,按最近使用倒序;任何异常返回 []。 */
|
|
27
|
+
export async function listRecentDirs() {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(registryPath(), 'utf8');
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
if (!Array.isArray(data))
|
|
32
|
+
return [];
|
|
33
|
+
return data
|
|
34
|
+
.filter((e) => e && typeof e.path === 'string' && typeof e.sessionFile === 'string')
|
|
35
|
+
.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 记录/更新一个工作目录。upsert by path:已存在则更新 lastUsedAt(title 给了才覆盖,
|
|
43
|
+
* 否则保留旧 title),不存在则插入。写失败静默。
|
|
44
|
+
*/
|
|
45
|
+
export async function recordRecentDir(cwd, title) {
|
|
46
|
+
try {
|
|
47
|
+
const abs = resolve(cwd);
|
|
48
|
+
const list = await listRecentDirs();
|
|
49
|
+
const prev = list.find((e) => e.path === abs);
|
|
50
|
+
const rest = list.filter((e) => e.path !== abs);
|
|
51
|
+
const entry = {
|
|
52
|
+
path: abs,
|
|
53
|
+
name: basename(abs) || abs,
|
|
54
|
+
lastUsedAt: Date.now(),
|
|
55
|
+
title: title ? title.slice(0, 80) : prev?.title,
|
|
56
|
+
sessionFile: sessionFileFor(abs),
|
|
57
|
+
};
|
|
58
|
+
const next = [entry, ...rest].slice(0, MAX_ENTRIES);
|
|
59
|
+
const file = registryPath();
|
|
60
|
+
await mkdir(dirname(file), { recursive: true });
|
|
61
|
+
await writeFile(file, JSON.stringify(next, null, 2), 'utf8');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// 静默:注册失败不影响对话
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -39,6 +39,29 @@ export function countTextTokens(text) {
|
|
|
39
39
|
}
|
|
40
40
|
return Math.ceil(asciiChars / 4) + nonAsciiChars;
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* 估算「工具 schema」本身占的 token。每轮请求都会把 ALL_TOOLS 的
|
|
44
|
+
* name + description + parameters JSON 发给 LLM,但 countMessagesTokens 只数历史消息、
|
|
45
|
+
* 漏了这部分 → 系统性低估。自动压缩判定阈值时必须把它加上才准。
|
|
46
|
+
*
|
|
47
|
+
* 每个工具的 token 组成:
|
|
48
|
+
* - 8 token 固定开销(type/function 包裹等结构体)
|
|
49
|
+
* - name 字段的文本 token
|
|
50
|
+
* - description 字段的文本 token(支持字符串或函数两种形式)
|
|
51
|
+
* - parameters JSON 序列化后的文本 token
|
|
52
|
+
*/
|
|
53
|
+
export function estimateToolsTokens(tools) {
|
|
54
|
+
let total = 0;
|
|
55
|
+
for (const t of tools) {
|
|
56
|
+
total += 8; // 每个工具的结构固定开销(type/function 包裹)
|
|
57
|
+
total += countTextTokens(t.name);
|
|
58
|
+
// description 可以是静态字符串,也可以是注入运行时信息的函数
|
|
59
|
+
const desc = typeof t.description === 'function' ? t.description() : t.description;
|
|
60
|
+
total += countTextTokens(desc);
|
|
61
|
+
total += countTextTokens(JSON.stringify(t.parameters));
|
|
62
|
+
}
|
|
63
|
+
return total;
|
|
64
|
+
}
|
|
42
65
|
/** 估算整段历史的 token 数(包含 role / 工具调用结构的开销) */
|
|
43
66
|
export function countMessagesTokens(messages) {
|
|
44
67
|
let total = 0;
|
package/src/llm/client.js
CHANGED
|
@@ -59,11 +59,16 @@ export async function* chat(args) {
|
|
|
59
59
|
return rest;
|
|
60
60
|
});
|
|
61
61
|
// 3. 构造请求体
|
|
62
|
+
// 逃生开关:极个别 provider 不认 stream_options 会 400,可用 MINIMAL_AGENT_DISABLE_USAGE=1 关掉。
|
|
63
|
+
const includeUsage = process.env.MINIMAL_AGENT_DISABLE_USAGE !== '1';
|
|
62
64
|
const body = {
|
|
63
65
|
model: provider.model,
|
|
64
66
|
messages: cleanedMessages,
|
|
65
67
|
tools: openaiTools.length > 0 ? openaiTools : undefined,
|
|
66
68
|
stream: true,
|
|
69
|
+
// stream_options.include_usage:让 provider 在流末附带真实 prompt_tokens,
|
|
70
|
+
// 供上层自动压缩判断用;provider 不支持时 usagePromptTokens 保持 undefined,上层 fallback 本地估算。
|
|
71
|
+
...(includeUsage ? { stream_options: { include_usage: true } } : {}),
|
|
67
72
|
// tool_choice: 'auto' 是默认值,可不写;某些 provider 必须显式
|
|
68
73
|
tool_choice: openaiTools.length > 0 ? 'auto' : undefined,
|
|
69
74
|
// ADR-05: structured output;缺省 undefined 不出现在 body(JSON.stringify 自动剥离)
|
|
@@ -95,6 +100,8 @@ export async function* chat(args) {
|
|
|
95
100
|
let buffer = '';
|
|
96
101
|
/** 累积的 stop_reason,最后 yield done 时用 */
|
|
97
102
|
let stopReason = 'unknown';
|
|
103
|
+
/** 真实 prompt_tokens(从 usage chunk 读取);provider 不返回时保持 undefined */
|
|
104
|
+
let usagePromptTokens;
|
|
98
105
|
try {
|
|
99
106
|
while (true) {
|
|
100
107
|
// 每次 read 前检查 abort:用户按 ESC/Ctrl+C 时 signal.aborted 为 true。
|
|
@@ -121,8 +128,8 @@ export async function* chat(args) {
|
|
|
121
128
|
continue;
|
|
122
129
|
const dataStr = line.slice(5).trim(); // 去掉 "data:" 前缀
|
|
123
130
|
if (dataStr === '[DONE]') {
|
|
124
|
-
//
|
|
125
|
-
yield { type: 'done', stopReason };
|
|
131
|
+
// 流结束标记;带出真实 prompt_tokens(provider 不返回时为 undefined,上层 fallback 本地估算)
|
|
132
|
+
yield { type: 'done', stopReason, promptTokens: usagePromptTokens };
|
|
126
133
|
return;
|
|
127
134
|
}
|
|
128
135
|
// 解析一个 chunk JSON
|
|
@@ -134,6 +141,12 @@ export async function* chat(args) {
|
|
|
134
141
|
// 罕见:某些 provider 会发空行或心跳,忽略
|
|
135
142
|
continue;
|
|
136
143
|
}
|
|
144
|
+
// usage chunk:include_usage 开启时,provider 通常在流末单独发一帧,
|
|
145
|
+
// 此帧 choices 为空(没有 delta),必须在 `if (!delta) continue` 之前读取,
|
|
146
|
+
// 否则会被 continue 跳过,永远拿不到真实 prompt_tokens。
|
|
147
|
+
if (chunk.usage && typeof chunk.usage.prompt_tokens === 'number') {
|
|
148
|
+
usagePromptTokens = chunk.usage.prompt_tokens;
|
|
149
|
+
}
|
|
137
150
|
const delta = chunk.choices?.[0]?.delta;
|
|
138
151
|
const finish = chunk.choices?.[0]?.finish_reason;
|
|
139
152
|
if (finish) {
|
|
@@ -189,8 +202,8 @@ export async function* chat(args) {
|
|
|
189
202
|
}
|
|
190
203
|
}
|
|
191
204
|
}
|
|
192
|
-
// 如果流没显式发 [DONE],也补一个 done
|
|
193
|
-
yield { type: 'done', stopReason };
|
|
205
|
+
// 如果流没显式发 [DONE],也补一个 done;同样带出 promptTokens
|
|
206
|
+
yield { type: 'done', stopReason, promptTokens: usagePromptTokens };
|
|
194
207
|
}
|
|
195
208
|
finally {
|
|
196
209
|
reader.releaseLock?.();
|
package/src/loop.js
CHANGED
|
@@ -30,8 +30,16 @@ import crypto from 'node:crypto';
|
|
|
30
30
|
import { autoCompactIfNeeded } from './context/compact.js';
|
|
31
31
|
import { microCompact, incrementTurn, expireOldEntries } from './context/microCompactLite.js';
|
|
32
32
|
import { isPromptTooLongError, reactiveCompactIfApplicable, } from './context/reactiveCompact.js';
|
|
33
|
+
import { snipCompactIfNeeded } from './context/snipCompact.js';
|
|
34
|
+
import { countMessagesTokens, estimateToolsTokens } from './context/tokenCounter.js';
|
|
33
35
|
import { chat as defaultChat } from './llm/client.js';
|
|
34
|
-
import { ALL_TOOLS, executeTool as defaultExecuteTool } from './tools/index.js';
|
|
36
|
+
import { ALL_TOOLS, executeTool as defaultExecuteTool, getToolByName } from './tools/index.js';
|
|
37
|
+
/**
|
|
38
|
+
* 工具 schema 的估算 token 数(ALL_TOOLS 固定,模块加载时算一次即可)。
|
|
39
|
+
* 每轮 LLM 请求都会带上整套工具 schema,但 countMessagesTokens 只数历史消息、
|
|
40
|
+
* 漏了这部分 → 系统性低估。autoCompact 判定阈值时加上它才准。
|
|
41
|
+
*/
|
|
42
|
+
const TOOLS_SCHEMA_TOKENS = estimateToolsTokens(ALL_TOOLS);
|
|
35
43
|
/**
|
|
36
44
|
* 执行一次"用户输入 → 模型回答完成"的完整流程。
|
|
37
45
|
*
|
|
@@ -54,9 +62,13 @@ export async function* runQuery(userInput, options) {
|
|
|
54
62
|
// 立即通知 UI:用户消息已入栈,触发 bump 让 <Static> 马上把它 commit 进 scrollback,
|
|
55
63
|
// 不必等整轮 assistant 落定(保证用户输入第一时间置顶,符合 T-A-O-R 展示顺序)。
|
|
56
64
|
yield { type: 'user_message_committed' };
|
|
57
|
-
// 反应式压缩自救:本 runQuery
|
|
58
|
-
//
|
|
59
|
-
let
|
|
65
|
+
// 反应式压缩自救:本 runQuery 内最多触发 MAX_REACTIVE_PER_QUERY 次(防本轮死循环);
|
|
66
|
+
// 跨 runQuery 的连续失败由 reactiveCompact.ts 的 circuit breaker(consecutiveFailures)兜底。
|
|
67
|
+
let reactiveCount = 0;
|
|
68
|
+
const MAX_REACTIVE_PER_QUERY = 3;
|
|
69
|
+
// 上一轮 LLM 返回的真实 prompt token 数(done.promptTokens;provider 开 include_usage 才有)。
|
|
70
|
+
// 作为 autoCompact 的权威判据,校正本地估算的系统性低估;压缩后清空(历史已变,旧值失真)。
|
|
71
|
+
let lastPromptTokens;
|
|
60
72
|
let turn = 0;
|
|
61
73
|
while (turn < maxTurns) {
|
|
62
74
|
turn++;
|
|
@@ -74,27 +86,45 @@ export async function* runQuery(userInput, options) {
|
|
|
74
86
|
yield { type: 'error', error: '已被用户中断', code: 'aborted' };
|
|
75
87
|
return;
|
|
76
88
|
}
|
|
77
|
-
// 2.
|
|
89
|
+
// 2. 自动压缩(判据 = max(上一轮真实 usage, 历史估算 + 工具 schema),
|
|
90
|
+
// 阈值 provider 无关、按 contextWindow × compactRatio 比例触发)
|
|
78
91
|
try {
|
|
79
|
-
const compact = await autoCompactIfNeeded(history, provider
|
|
92
|
+
const compact = await autoCompactIfNeeded(history, provider, {
|
|
93
|
+
actualPromptTokens: lastPromptTokens,
|
|
94
|
+
toolsTokens: TOOLS_SCHEMA_TOKENS,
|
|
95
|
+
});
|
|
80
96
|
if (compact.compacted) {
|
|
81
97
|
yield { type: 'compact_start' };
|
|
82
98
|
yield { type: 'stage_change', stage: 'compacting' };
|
|
83
99
|
// in-place 替换 history(保持调用方持有的引用有效)
|
|
84
100
|
history.length = 0;
|
|
85
101
|
history.push(...compact.messages);
|
|
102
|
+
// 历史已压缩变小,旧的真实 usage 不再代表当前请求 → 清空,下一轮先用估算判据
|
|
103
|
+
lastPromptTokens = undefined;
|
|
86
104
|
yield { type: 'compact_done', before: compact.before, after: compact.after };
|
|
87
105
|
// 压缩完会回到 LLM 调用,stage 退回 thinking
|
|
88
106
|
yield { type: 'stage_change', stage: 'thinking' };
|
|
89
107
|
}
|
|
90
108
|
}
|
|
91
109
|
catch (e) {
|
|
92
|
-
//
|
|
93
|
-
// code='compact_failed'
|
|
94
|
-
|
|
110
|
+
// 压缩失败不要让整个对话挂掉:先 snip 砍最老消息释放空间再继续,
|
|
111
|
+
// 避免下一步 LLM 直接撞 prompt-too-long(code='compact_failed' 非致命,不 return)。
|
|
112
|
+
try {
|
|
113
|
+
const before = countMessagesTokens(history);
|
|
114
|
+
const snipped = snipCompactIfNeeded(history, { force: true, snipPercent: 0.3 });
|
|
115
|
+
if (snipped.messagesRemoved > 0) {
|
|
116
|
+
history.length = 0;
|
|
117
|
+
history.push(...snipped.messages);
|
|
118
|
+
lastPromptTokens = undefined;
|
|
119
|
+
yield { type: 'compact_done', before, after: countMessagesTokens(history) };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* snip 也失败 → 真的只能硬上 */
|
|
124
|
+
}
|
|
95
125
|
yield {
|
|
96
126
|
type: 'error',
|
|
97
|
-
error:
|
|
127
|
+
error: `自动压缩失败(已尝试 snip 兜底后继续):${e.message}`,
|
|
98
128
|
code: 'compact_failed',
|
|
99
129
|
};
|
|
100
130
|
}
|
|
@@ -111,6 +141,12 @@ export async function* runQuery(userInput, options) {
|
|
|
111
141
|
const reasoningDetails = [];
|
|
112
142
|
/** 首个 text_delta 时切到 streaming,再来不重复 yield */
|
|
113
143
|
let stageStreamingYielded = false;
|
|
144
|
+
/**
|
|
145
|
+
* R2:本轮 LLM 流的终止原因(来自 client 的 done.stopReason)。
|
|
146
|
+
* 'length' = 被 provider 因 max_tokens 截断 —— 收尾时据此给 warning,
|
|
147
|
+
* 避免「半截答案」被静默当成正常 end_turn。
|
|
148
|
+
*/
|
|
149
|
+
let lastFinishReason = 'unknown';
|
|
114
150
|
// 进入 LLM 流前:等首 token / reasoning,本质是 thinking
|
|
115
151
|
yield { type: 'stage_change', stage: 'thinking' };
|
|
116
152
|
try {
|
|
@@ -145,7 +181,13 @@ export async function* runQuery(userInput, options) {
|
|
|
145
181
|
reasoningDetails.push(...ev.items);
|
|
146
182
|
}
|
|
147
183
|
}
|
|
148
|
-
|
|
184
|
+
else if (ev.type === 'done') {
|
|
185
|
+
// R2:记下流的终止原因。'length'(max_tokens 截断)会在本轮收尾时触发 warning。
|
|
186
|
+
lastFinishReason = ev.stopReason;
|
|
187
|
+
// 真实 prompt token(provider 开 include_usage 才有)→ 喂给下一轮 autoCompact 做权威判据
|
|
188
|
+
if (typeof ev.promptTokens === 'number')
|
|
189
|
+
lastPromptTokens = ev.promptTokens;
|
|
190
|
+
}
|
|
149
191
|
}
|
|
150
192
|
}
|
|
151
193
|
catch (e) {
|
|
@@ -163,14 +205,17 @@ export async function* runQuery(userInput, options) {
|
|
|
163
205
|
// prompt_too_long 反应式自救:压缩历史 → 同 turn 重发 LLM
|
|
164
206
|
// LLM 看到压缩后的 9 段摘要 + 近期 verbatim 消息,能接着干活,
|
|
165
207
|
// 不会丢失中途的工具调用上下文。
|
|
166
|
-
if (isPromptTooLongError(e) &&
|
|
167
|
-
|
|
208
|
+
if (isPromptTooLongError(e) && reactiveCount < MAX_REACTIVE_PER_QUERY) {
|
|
209
|
+
reactiveCount++;
|
|
168
210
|
yield { type: 'compact_start' };
|
|
169
211
|
yield { type: 'stage_change', stage: 'compacting' };
|
|
170
212
|
const result = await reactiveCompactIfApplicable(history, provider, e, sessionState?.reactive);
|
|
171
|
-
|
|
213
|
+
// 仅当确实压下去了(after < before)才重试;否则(没压动 / 未恢复)退回错误路径,杜绝死循环。
|
|
214
|
+
const shrank = (result.after ?? 0) < (result.before ?? 0);
|
|
215
|
+
if (result.recovered && shrank) {
|
|
172
216
|
history.length = 0;
|
|
173
217
|
history.push(...result.messages);
|
|
218
|
+
lastPromptTokens = undefined; // 历史已变小,旧真实 usage 失真
|
|
174
219
|
yield {
|
|
175
220
|
type: 'compact_done',
|
|
176
221
|
before: result.before ?? 0,
|
|
@@ -181,7 +226,7 @@ export async function* runQuery(userInput, options) {
|
|
|
181
226
|
turn--; // 不消耗 turn 配额,本轮重新走
|
|
182
227
|
continue;
|
|
183
228
|
}
|
|
184
|
-
// reactive
|
|
229
|
+
// reactive 没压动或未恢复 → 退回正常错误路径
|
|
185
230
|
}
|
|
186
231
|
// code='llm_error':终结性错误,stop_reason='error'
|
|
187
232
|
yield {
|
|
@@ -191,6 +236,15 @@ export async function* runQuery(userInput, options) {
|
|
|
191
236
|
};
|
|
192
237
|
return;
|
|
193
238
|
}
|
|
239
|
+
// S2 防御:OpenAI 协议保证 tool_call 的 index 连续;万一某 provider 跳号产生稀疏数组,
|
|
240
|
+
// 这里压实掉空洞,避免后续遍历 / 回填遇到 undefined(assistantMsg.tool_calls 与执行都用它)。
|
|
241
|
+
{
|
|
242
|
+
const dense = toolCallsByIndex.filter((tc) => tc != null);
|
|
243
|
+
if (dense.length !== toolCallsByIndex.length) {
|
|
244
|
+
toolCallsByIndex.length = 0;
|
|
245
|
+
toolCallsByIndex.push(...dense);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
194
248
|
const assistantMsg = {
|
|
195
249
|
role: 'assistant',
|
|
196
250
|
content: assistantText.length > 0 ? assistantText : null,
|
|
@@ -205,100 +259,112 @@ export async function* runQuery(userInput, options) {
|
|
|
205
259
|
yield { type: 'assistant_message', message: assistantMsg };
|
|
206
260
|
// 4. 没有工具调用 → 整轮交互结束
|
|
207
261
|
if (toolCallsByIndex.length === 0) {
|
|
262
|
+
// R2:最终回答被 provider 因 max_tokens 截断(finish_reason='length')时,
|
|
263
|
+
// 不能静默当正常 end_turn 收尾 —— 否则 -p 宿主会拿到半截答案却以为成功。
|
|
264
|
+
// 走通用 warning(text/json 都落 stderr,不污染 json 那一行 stdout 契约)。
|
|
265
|
+
if (lastFinishReason === 'length') {
|
|
266
|
+
yield {
|
|
267
|
+
type: 'warning',
|
|
268
|
+
message: '⚠️ 模型回答可能被截断(finish_reason=length,触顶 max_tokens),本轮回答或不完整。' +
|
|
269
|
+
'可调大模型的 max_tokens 上限,或让模型分段输出。',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
208
272
|
yield { type: 'turn_done' };
|
|
209
273
|
return;
|
|
210
274
|
}
|
|
211
|
-
// 5.
|
|
275
|
+
// 5. 执行工具调用
|
|
212
276
|
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
277
|
+
// R1:按 isConcurrencySafe 决定并发策略 —— 工具早就声明了这个字段,此前却没有
|
|
278
|
+
// 任何执行路径消费它(全程无条件并行),这里让它真正生效:
|
|
279
|
+
// - 本轮工具「全部并发安全」(只读类 Read/Grep/Glob/WebSearch/WebFetch)且不止一个
|
|
280
|
+
// → 并行:用事件队列把多个 worker 的 tool_start/tool_end 按真实完成顺序交错 yield。
|
|
281
|
+
// - 只要有一个写类/未知工具(Edit/Write/MultiEdit/Bash/WebBrowser)→ 整轮串行:
|
|
282
|
+
// 彻底杜绝两个写操作并行落到同一文件导致的 lost-update / fileState TOCTOU。
|
|
283
|
+
// 两种路径下,history 里的 tool 消息都严格按 toolCallsByIndex 索引顺序回填
|
|
284
|
+
// (OpenAI 协议要求 tool 消息与上一条 assistant 的 tool_calls 一一对应)。
|
|
218
285
|
yield { type: 'stage_change', stage: 'tool_executing' };
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const enqueue = (ev) => {
|
|
222
|
-
queue.push(ev);
|
|
223
|
-
signalNew?.();
|
|
224
|
-
signalNew = null;
|
|
225
|
-
};
|
|
226
|
-
const workers = toolCallsByIndex.map(async (tc) => {
|
|
227
|
-
enqueue({
|
|
228
|
-
type: 'tool_start',
|
|
229
|
-
toolCallId: tc.id,
|
|
230
|
-
toolName: tc.function.name,
|
|
231
|
-
argsPreview: previewArgs(tc.function.arguments),
|
|
232
|
-
argsFriendly: friendlyToolDescription(tc.function.name, tc.function.arguments),
|
|
233
|
-
});
|
|
286
|
+
// 执行单个 tool call → WorkerOutcome(内部吞掉 abort/异常,永不 reject)。
|
|
287
|
+
const runOne = async (tc) => {
|
|
234
288
|
try {
|
|
235
289
|
const result = await executeToolFn(tc.function.name, tc.function.arguments, signal);
|
|
236
|
-
enqueue({
|
|
237
|
-
type: 'tool_end',
|
|
238
|
-
toolCallId: tc.id,
|
|
239
|
-
toolName: tc.function.name,
|
|
240
|
-
ok: result.ok,
|
|
241
|
-
content: result.ok ? result.content : `Error: ${result.error}`,
|
|
242
|
-
});
|
|
243
290
|
return { tc, result };
|
|
244
291
|
}
|
|
245
292
|
catch (e) {
|
|
246
293
|
if (e.name === 'AbortError') {
|
|
247
|
-
enqueue({
|
|
248
|
-
type: 'tool_end',
|
|
249
|
-
toolCallId: tc.id,
|
|
250
|
-
toolName: tc.function.name,
|
|
251
|
-
ok: false,
|
|
252
|
-
content: '(已中断)',
|
|
253
|
-
});
|
|
254
294
|
return { tc, result: { ok: false, error: 'aborted' } };
|
|
255
295
|
}
|
|
256
|
-
|
|
257
|
-
enqueue({
|
|
258
|
-
type: 'tool_end',
|
|
259
|
-
toolCallId: tc.id,
|
|
260
|
-
toolName: tc.function.name,
|
|
261
|
-
ok: false,
|
|
262
|
-
content: msg,
|
|
263
|
-
});
|
|
264
|
-
return { tc, result: { ok: false, error: msg } };
|
|
296
|
+
return { tc, result: { ok: false, error: e.message } };
|
|
265
297
|
}
|
|
298
|
+
};
|
|
299
|
+
const startEvent = (tc) => ({
|
|
300
|
+
type: 'tool_start',
|
|
301
|
+
toolCallId: tc.id,
|
|
302
|
+
toolName: tc.function.name,
|
|
303
|
+
argsPreview: previewArgs(tc.function.arguments),
|
|
304
|
+
argsFriendly: friendlyToolDescription(tc.function.name, tc.function.arguments),
|
|
305
|
+
});
|
|
306
|
+
const endEvent = (o) => ({
|
|
307
|
+
type: 'tool_end',
|
|
308
|
+
toolCallId: o.tc.id,
|
|
309
|
+
toolName: o.tc.function.name,
|
|
310
|
+
ok: o.result.ok,
|
|
311
|
+
content: o.result.ok
|
|
312
|
+
? o.result.content
|
|
313
|
+
: o.result.error === 'aborted'
|
|
314
|
+
? '(已中断)'
|
|
315
|
+
: `Error: ${o.result.error}`,
|
|
266
316
|
});
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
finished = true;
|
|
271
|
-
signalNew?.();
|
|
272
|
-
signalNew = null;
|
|
317
|
+
const allConcurrencySafe = toolCallsByIndex.every((tc) => {
|
|
318
|
+
const tool = getToolByName(tc.function.name);
|
|
319
|
+
return tool?.isConcurrencySafe === true;
|
|
273
320
|
});
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
321
|
+
let settled;
|
|
322
|
+
if (allConcurrencySafe && toolCallsByIndex.length > 1) {
|
|
323
|
+
// —— 并行:事件队列把各 worker 的 start/end 按真实完成顺序交错 yield 给 UI。
|
|
324
|
+
const queue = [];
|
|
325
|
+
let signalNew = null;
|
|
326
|
+
const enqueue = (ev) => {
|
|
327
|
+
queue.push(ev);
|
|
328
|
+
signalNew?.();
|
|
329
|
+
signalNew = null;
|
|
330
|
+
};
|
|
331
|
+
const workers = toolCallsByIndex.map(async (tc) => {
|
|
332
|
+
enqueue(startEvent(tc));
|
|
333
|
+
const o = await runOne(tc);
|
|
334
|
+
enqueue(endEvent(o));
|
|
335
|
+
return o;
|
|
281
336
|
});
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
timestamp: Date.now(),
|
|
297
|
-
id: crypto.randomUUID(),
|
|
337
|
+
const allDone = Promise.all(workers);
|
|
338
|
+
let finished = false;
|
|
339
|
+
void allDone.then(() => {
|
|
340
|
+
finished = true;
|
|
341
|
+
signalNew?.();
|
|
342
|
+
signalNew = null;
|
|
343
|
+
});
|
|
344
|
+
while (!finished || queue.length > 0) {
|
|
345
|
+
while (queue.length > 0)
|
|
346
|
+
yield queue.shift();
|
|
347
|
+
if (finished)
|
|
348
|
+
break;
|
|
349
|
+
await new Promise((r) => {
|
|
350
|
+
signalNew = r;
|
|
298
351
|
});
|
|
299
|
-
continue;
|
|
300
352
|
}
|
|
301
|
-
|
|
353
|
+
settled = await allDone;
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
// —— 串行:逐个执行(含写类工具,或只有一个工具)。事件直接顺序 yield,无需队列。
|
|
357
|
+
settled = [];
|
|
358
|
+
for (const tc of toolCallsByIndex) {
|
|
359
|
+
yield startEvent(tc);
|
|
360
|
+
const o = await runOne(tc);
|
|
361
|
+
yield endEvent(o);
|
|
362
|
+
settled.push(o);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// 严格按 toolCallsByIndex 顺序把 tool 消息回填 history(settled 已保持该顺序)。
|
|
366
|
+
let anyAborted = false;
|
|
367
|
+
for (const { tc, result } of settled) {
|
|
302
368
|
if (!result.ok && result.error === 'aborted')
|
|
303
369
|
anyAborted = true;
|
|
304
370
|
const rawContent = result.ok ? result.content : `Error: ${result.error}`;
|
|
@@ -315,7 +381,7 @@ export async function* runQuery(userInput, options) {
|
|
|
315
381
|
});
|
|
316
382
|
}
|
|
317
383
|
if (anyAborted || signal?.aborted) {
|
|
318
|
-
//
|
|
384
|
+
// 工具被中断时,外层 yield interrupted 并 return
|
|
319
385
|
yield { type: 'interrupted' };
|
|
320
386
|
return;
|
|
321
387
|
}
|
|
@@ -375,6 +441,10 @@ function friendlyToolDescription(toolName, rawArgsJson) {
|
|
|
375
441
|
switch (toolName) {
|
|
376
442
|
case 'Read': return `Reading ${truncate(args.file_path, 60)}`;
|
|
377
443
|
case 'Edit': return `Editing ${truncate(args.file_path, 60)}`;
|
|
444
|
+
case 'MultiEdit': {
|
|
445
|
+
const n = Array.isArray(args.edits) ? args.edits.length : 0;
|
|
446
|
+
return `Editing ${truncate(args.file_path, 50)}${n ? ` (${n} 处)` : ''}`;
|
|
447
|
+
}
|
|
378
448
|
case 'Write': return `Writing ${truncate(args.file_path, 60)}`;
|
|
379
449
|
case 'Bash': return `Running \`${truncate(args.command, 40)}\``;
|
|
380
450
|
case 'Grep': {
|
package/src/main.js
CHANGED
|
@@ -20,12 +20,13 @@ const pkg = (() => {
|
|
|
20
20
|
import { extractCwdArg } from './bootstrap/cwdArg.js';
|
|
21
21
|
import { initWorkingDir, getWorkingDir } from './bootstrap/workingDir.js';
|
|
22
22
|
import { applyToolKeysToEnv, loadProviderLayered } from './config.js';
|
|
23
|
-
import { loadContext } from './context/persistContext.js';
|
|
23
|
+
import { clearContext, loadContext } from './context/persistContext.js';
|
|
24
24
|
import { migrateLegacyContext } from './context/sessionPath.js';
|
|
25
25
|
import { buildFullSystemPrompt } from './prompts/system.js';
|
|
26
26
|
import { ALL_TOOLS } from './tools/index.js';
|
|
27
27
|
import { Root } from './ui/Root.js';
|
|
28
28
|
import { runPrintMode } from './cli/print.js';
|
|
29
|
+
import { runStreamJsonMode } from './cli/streamJson.js';
|
|
29
30
|
import { extractFlagValue } from './cli/args.js';
|
|
30
31
|
// re-export:对外保持「main.tsx 提供 extractFlagValue」的语义;实现住在 cli/args.ts,
|
|
31
32
|
// 这样单测可零副作用 import(main.tsx 顶层 main() 自调用会启动整个 app,不可被测试 import)。
|
|
@@ -59,12 +60,23 @@ async function main() {
|
|
|
59
60
|
console.log(`minimal-agent v${pkg.version}`);
|
|
60
61
|
return;
|
|
61
62
|
}
|
|
63
|
+
// --clear:清空当前工作目录的会话(归档 + 清空)后退出。供 webchat 的 /new 调用。
|
|
64
|
+
// 无需 provider;上面 -d/--cwd 已 chdir + initWorkingDir,clearContext() 作用于该目录。
|
|
65
|
+
if (args.includes('--clear')) {
|
|
66
|
+
await clearContext();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
62
69
|
const isPrintMode = args.includes('-p') || args.includes('--print');
|
|
63
70
|
if (isPrintMode) {
|
|
64
71
|
// 先把输出格式解析出来——provider 缺失分支也要按 json 契约给信号。
|
|
65
72
|
// 只接受 'text' | 'json',其它值(含拼错)一律回落 'text'(向后兼容)。
|
|
66
73
|
const rawFormat = extractFlagValue(args, ['--output-format']);
|
|
67
|
-
|
|
74
|
+
// text(默认,逐字节向后兼容)/ json(一行结局契约)/ stream-json(逐行 LoopEvent,webchat broker 用)
|
|
75
|
+
const outputFormat = rawFormat === 'json'
|
|
76
|
+
? 'json'
|
|
77
|
+
: rawFormat === 'stream-json'
|
|
78
|
+
? 'stream-json'
|
|
79
|
+
: 'text';
|
|
68
80
|
// --max-turns:parseInt,非法(NaN)→ undefined(走 loop 默认 50)
|
|
69
81
|
const rawMaxTurns = extractFlagValue(args, ['--max-turns']);
|
|
70
82
|
const parsedMaxTurns = rawMaxTurns !== undefined ? Number.parseInt(rawMaxTurns, 10) : Number.NaN;
|
|
@@ -85,6 +97,10 @@ async function main() {
|
|
|
85
97
|
error: 'provider config not found',
|
|
86
98
|
}));
|
|
87
99
|
}
|
|
100
|
+
else if (outputFormat === 'stream-json') {
|
|
101
|
+
// stream-json:吐一行 error 事件(webchat 前端识别为致命错误)
|
|
102
|
+
console.log(JSON.stringify({ type: 'error', error: 'provider config not found', code: 'llm_error' }));
|
|
103
|
+
}
|
|
88
104
|
process.stderr.write(`\n未找到 provider 配置。\n\n` +
|
|
89
105
|
`请二选一:\n` +
|
|
90
106
|
` 1. 设置环境变量 MINIMAL_AGENT_BASE_URL / MINIMAL_AGENT_API_KEY / MINIMAL_AGENT_MODEL\n` +
|
|
@@ -93,6 +109,14 @@ async function main() {
|
|
|
93
109
|
}
|
|
94
110
|
const initialHistory = await buildInitialHistory();
|
|
95
111
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
112
|
+
// stream-json 走独立流式模式(webchat broker / 网关用);text/json 仍走原 runPrintMode(契约不变)。
|
|
113
|
+
if (outputFormat === 'stream-json') {
|
|
114
|
+
await runStreamJsonMode(provider, args, initialHistory, {
|
|
115
|
+
maxTurns,
|
|
116
|
+
compact: args.includes('--compact'),
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
96
120
|
await runPrintMode(provider, args, initialHistory, { verbose, outputFormat, maxTurns });
|
|
97
121
|
return;
|
|
98
122
|
}
|
|
@@ -131,9 +155,12 @@ minimal-agent - 轻量级 AI 编程助手
|
|
|
131
155
|
-v, --verbose 显示详细输出(工具调用、压缩信息)
|
|
132
156
|
-d, --cwd <dir> 指定工作目录(不存在自动创建);启动时 chdir 到这里,
|
|
133
157
|
上下文文件、工具相对路径、.env 加载都以此为基准
|
|
134
|
-
--output-format <fmt> text(默认)= 纯文本答案;json =
|
|
135
|
-
|
|
158
|
+
--output-format <fmt> text(默认)= 纯文本答案;json = 一行结局契约
|
|
159
|
+
(ok / result / stop_reason / num_turns / error);
|
|
160
|
+
stream-json = 逐行 LoopEvent(webchat / 网关 broker 用)
|
|
136
161
|
--max-turns <n> 最大工具循环轮数(防失控;缺省 50)
|
|
162
|
+
--clear 清空当前工作目录的会话(归档 + 清空)后退出
|
|
163
|
+
--compact 与 -p --output-format stream-json 连用:压缩当前会话
|
|
137
164
|
-h, --help 显示帮助信息
|
|
138
165
|
|
|
139
166
|
会话记忆:
|
|
@@ -26,7 +26,9 @@ import { randomUUID } from 'node:crypto';
|
|
|
26
26
|
// 模块级状态:当前会话的 handle(lazy 初始化)
|
|
27
27
|
let current = null;
|
|
28
28
|
function transcriptDir() {
|
|
29
|
-
|
|
29
|
+
// 优先使用环境变量(测试可覆盖;Docker/K8s 也常靠 ENV 定位 home),
|
|
30
|
+
// fallback 到 os.homedir()(生产环境缺 ENV 时用系统默认值)。
|
|
31
|
+
return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), '.minimal-agent', 'transcripts');
|
|
30
32
|
}
|
|
31
33
|
function generateSessionId() {
|
|
32
34
|
return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|