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.
@@ -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
- // 配合 reactiveCompact.ts 的 attemptedsession 级)双层防爆。
59
- let reactiveAttempted = false;
65
+ // 反应式压缩自救:本 runQuery 内最多触发 MAX_REACTIVE_PER_QUERY 次(防本轮死循环);
66
+ // runQuery 的连续失败由 reactiveCompact.ts 的 circuit breakerconsecutiveFailures)兜底。
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':标记为「非致命」——注意此处 yield error 后**不 return**,
94
- // loop 继续往下跑(不压缩硬上)。`-p --output-format json` 据此只 stderr 警告、不退出。
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: `自动压缩失败(继续不压缩):${e.message}`,
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
- // ev.type === 'done' 时无需处理:循环自然结束
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) && !reactiveAttempted) {
167
- reactiveAttempted = true;
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
- if (result.recovered) {
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
- // 设计:用 Promise.allSettled 启动 N 个 worker,配合一个简易事件队列
214
- // (queue + signalNew)把多个 worker 的 tool_start / tool_end 事件按
215
- // 真实完成顺序交错 yield 给 UI;但 history 里的 tool 消息严格按
216
- // toolCallsByIndex 索引顺序 push(OpenAI 协议要求 tool 消息和上一条
217
- // assistant 的 tool_calls 一一对应)。
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
- const queue = [];
220
- let signalNew = null;
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
- const msg = e.message;
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 allDone = Promise.allSettled(workers);
268
- let finished = false;
269
- allDone.then(() => {
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
- while (!finished || queue.length > 0) {
275
- while (queue.length > 0)
276
- yield queue.shift();
277
- if (finished)
278
- break;
279
- await new Promise((r) => {
280
- signalNew = r;
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
- // 严格按 toolCallsByIndex 顺序 push tool 消息进 history(OpenAI 协议要求)
284
- const settled = await allDone;
285
- let anyAborted = false;
286
- for (let i = 0; i < toolCallsByIndex.length; i++) {
287
- const tc = toolCallsByIndex[i];
288
- const r = settled[i];
289
- if (r.status === 'rejected') {
290
- // 理论上 worker 已经 catch 了所有异常,这里只是防御
291
- const errMsg = r.reason?.message ?? 'unknown';
292
- history.push({
293
- role: 'tool',
294
- tool_call_id: tc.id,
295
- content: `Error: ${errMsg}`,
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
- const { result } = r.value;
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
- // 任一工具抛 AbortError 时,外层 yield interrupted 并 return
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
- const outputFormat = rawFormat === 'json' ? 'json' : 'text';
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 = stdout 输出一行结构化
135
- 结局契约(ok / result / stop_reason / num_turns / error
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
  会话记忆: