minimal-agent 0.5.6 → 0.6.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-agent",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
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>",
@@ -13,23 +13,34 @@
13
13
  * or := and ( '||' and )*
14
14
  * and := not ( '&&' not )*
15
15
  * not := '!' not | comparison
16
- * comparison := primary ( ('==' | '!=' | '>=' | '<=' | '>' | '<') primary )?
17
- * primary := number | string | array | call | path | '(' expr ')'
16
+ * comparison := additive ( ('==' | '!=' | '>=' | '<=' | '>' | '<') additive )?
17
+ * additive := primary ( '+' primary )* // number 相加 / 否则 stringify 后拼接
18
+ * primary := number | string | array | call | path | interp | '(' expr ')'
18
19
  * array := '[' expr (',' expr)* ']'
19
20
  * call := IDENT '(' expr (',' expr)* ')' // 白名单
20
21
  * path := IDENT ('.' IDENT)* // 变量 + 点路径
22
+ * interp := '${' EXPR '}' // 取 EXPR 的"值"(任意类型,不字符串化)
23
+ * string := "…" | '…' // 内嵌 ${EXPR} 走 template-literal 插值
21
24
  *
22
25
  * 白名单函数:fileExists / length / lower / upper
23
26
  *
24
- * 插值:${EXPR} —— EXPR 求值后 toString 替换;对象/数组用 JSON.stringify。
25
- * interpolateDeep 递归处理对象/数组/字符串。
27
+ * P2 根治:表达式上下文(when/condition/over/assert/vote.input)直接 evalExpr,
28
+ * 不再"先 interpolate 再 eval"(旧双求值的 footgun 源头)。四种写法都 OK:
29
+ * 裸 ${x} == 'y' → ${x} 取值后比较(修掉旧 footgun:旧实现把 ${x} 替成裸字面量被当变量名)
30
+ * "a/${x}/b" → 字符串字面量内插值拼路径(向后兼容旧 fileExists("…${x}…") 写法)
31
+ * "a/" + x + "/b" → + 拼接(新增)
32
+ * x == 'y' → 裸变量名(向后兼容)
33
+ * 安全性也更好:变量的字符串内容不再被当作表达式二次执行(旧实现若插值结果落在引号外会被 eval)。
34
+ *
35
+ * 插值函数 interpolate(${EXPR}) 仍用于**模板上下文**(prompt / args / file_path)——
36
+ * 把 EXPR 求值后 toString 替换;对象/数组用 JSON.stringify。interpolateDeep 递归处理对象/数组/字符串。
26
37
  * ============================================================
27
38
  */
28
39
  import { existsSync } from 'node:fs';
29
40
  import { join, isAbsolute } from 'node:path';
30
41
  import { getWorkingDir } from '../../../src/plugin-sdk.js';
31
42
  const PUNCT2 = new Set(['==', '!=', '>=', '<=', '&&', '||']);
32
- const PUNCT1 = new Set(['(', ')', '[', ']', ',', '.', '!', '>', '<']);
43
+ const PUNCT1 = new Set(['(', ')', '[', ']', ',', '.', '!', '>', '<', '+']);
33
44
  function tokenize(src) {
34
45
  const out = [];
35
46
  let i = 0;
@@ -39,6 +50,25 @@ function tokenize(src) {
39
50
  i++;
40
51
  continue;
41
52
  }
53
+ // 独立 ${...} 取值 token。注意:字符串字面量内部的 ${} 不会到这里——
54
+ // 会被下面的字符串分支整体读进 str token,由 parsePrimary 在 eval 时插值。
55
+ if (c === '$' && src[i + 1] === '{') {
56
+ let depth = 1;
57
+ let j = i + 2;
58
+ while (j < src.length && depth > 0) {
59
+ if (src[j] === '{')
60
+ depth++;
61
+ else if (src[j] === '}')
62
+ depth--;
63
+ if (depth > 0)
64
+ j++;
65
+ }
66
+ if (depth !== 0)
67
+ throw new Error(`未闭合的 \${ 在表达式 "${src}"`);
68
+ out.push({ kind: 'interp', value: src.slice(i + 2, j) });
69
+ i = j + 1;
70
+ continue;
71
+ }
42
72
  // 字符串
43
73
  if (c === '"' || c === "'") {
44
74
  const quote = c;
@@ -161,11 +191,11 @@ function parseNot(c, vars) {
161
191
  return parseComparison(c, vars);
162
192
  }
163
193
  function parseComparison(c, vars) {
164
- const left = parsePrimary(c, vars);
194
+ const left = parseAdditive(c, vars);
165
195
  const t = peek(c);
166
196
  if (t && t.kind === 'punct' && ['==', '!=', '>=', '<=', '>', '<'].includes(t.value)) {
167
197
  eat(c);
168
- const right = parsePrimary(c, vars);
198
+ const right = parseAdditive(c, vars);
169
199
  switch (t.value) {
170
200
  case '==':
171
201
  // eslint-disable-next-line eqeqeq
@@ -185,6 +215,23 @@ function parseComparison(c, vars) {
185
215
  }
186
216
  return left;
187
217
  }
218
+ // 加法/拼接层:number + number → 相加;否则两边 stringify 后字符串拼接。
219
+ // 表达式语言原本无拼接符,导致拼路径只能"先插值再 eval"(footgun 源头);
220
+ // 有了 + 之后可写 fileExists("videos/" + inputs.book + "/x") 这种纯表达式。
221
+ function parseAdditive(c, vars) {
222
+ let left = parsePrimary(c, vars);
223
+ while (isPunct(c, '+')) {
224
+ eat(c);
225
+ const right = parsePrimary(c, vars);
226
+ left = addValues(left, right);
227
+ }
228
+ return left;
229
+ }
230
+ function addValues(l, r) {
231
+ if (typeof l === 'number' && typeof r === 'number')
232
+ return l + r;
233
+ return stringify(l) + stringify(r);
234
+ }
188
235
  function parsePrimary(c, vars) {
189
236
  const t = peek(c);
190
237
  if (!t)
@@ -218,7 +265,15 @@ function parsePrimary(c, vars) {
218
265
  }
219
266
  if (t.kind === 'str') {
220
267
  eat(c);
221
- return t.value;
268
+ // 字符串字面量支持内嵌 ${...} 插值(template-literal 语义):
269
+ // fileExists("videos/${inputs.book}/x") 不必预插值也能工作。无 ${} 时原样返回。
270
+ return interpolate(t.value, vars);
271
+ }
272
+ if (t.kind === 'interp') {
273
+ eat(c);
274
+ // 独立 ${EXPR}:求值为 EXPR 的"值"(任意类型,不字符串化)。
275
+ // 修掉"裸 ${x} == 'y' 被当成变量名 x"的 footgun。
276
+ return evalExpr(t.value, vars);
222
277
  }
223
278
  if (t.kind === 'ident') {
224
279
  eat(c);
@@ -165,14 +165,14 @@ export async function* runWorkflowFromCommand(rawArgs, opts) {
165
165
  if (def.inputs && positional.length > def.inputs.length) {
166
166
  const dropped = positional.slice(def.inputs.length);
167
167
  yield {
168
- type: 'workflow_warning',
168
+ type: 'warning',
169
169
  message: `⚠ 忽略了 ${dropped.length} 个多余位置参数: ${dropped.map((d) => JSON.stringify(d)).join(' ')}(workflow "${name}" 只声明了 ${def.inputs.length} 个 input)`,
170
170
  };
171
171
  }
172
172
  // warn:位置参数被 --input 覆盖
173
173
  if (overriddenByKV.length > 0) {
174
174
  yield {
175
- type: 'workflow_warning',
175
+ type: 'warning',
176
176
  message: `⚠ --input 优先级高于位置参数,以下位置参数被覆盖:${overriddenByKV.join('; ')}`,
177
177
  };
178
178
  }
@@ -256,6 +256,11 @@ function validateSteps(steps, file, pathPrefix) {
256
256
  if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
257
257
  throw new WorkflowLoadError(`${p}.output_schema 不能用在 type=${s.type} 控制流节点上`, file);
258
258
  }
259
+ // P3:output_schema 在 skill 节点上是静默 no-op(execSkillStep 无视它)。
260
+ // 与其悄悄丢弃,不如 loader 直接拒绝——仅 llm 节点支持 structured output。
261
+ if (s.skill !== undefined) {
262
+ throw new WorkflowLoadError(`${p}.output_schema 暂不支持 skill 节点(仅 llm 节点生效)`, file);
263
+ }
259
264
  }
260
265
  // context_files:[{ path, hint? }];不能用在控制流节点上
261
266
  if (s.context_files !== undefined) {
@@ -277,6 +282,11 @@ function validateSteps(steps, file, pathPrefix) {
277
282
  if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
278
283
  throw new WorkflowLoadError(`${p}.context_files 不能用在 type=${s.type} 控制流节点上`, file);
279
284
  }
285
+ // P3:context_files 同样是 skill 节点的静默 no-op(execSkillStep 无视它),
286
+ // 仅 llm 节点经 miniReAct 生效——loader 直接拒绝,避免悄悄丢弃。
287
+ if (s.skill !== undefined) {
288
+ throw new WorkflowLoadError(`${p}.context_files 暂不支持 skill 节点(仅 llm 节点生效)`, file);
289
+ }
280
290
  }
281
291
  // allowed_tools:string[];不能用在控制流节点上
282
292
  if (s.allowed_tools !== undefined) {
@@ -287,6 +297,10 @@ function validateSteps(steps, file, pathPrefix) {
287
297
  if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
288
298
  throw new WorkflowLoadError(`${p}.allowed_tools 不能用在 type=${s.type} 控制流节点上`, file);
289
299
  }
300
+ // P3:allowed_tools 与 context_files 配对,skill 节点上同样无效——一并拒绝。
301
+ if (s.skill !== undefined) {
302
+ throw new WorkflowLoadError(`${p}.allowed_tools 暂不支持 skill 节点(仅 llm 节点生效)`, file);
303
+ }
290
304
  }
291
305
  }
292
306
  }
@@ -28,6 +28,13 @@ import { execToolStep } from './stepExecutors/tool.js';
28
28
  import { VarStack } from './types.js';
29
29
  import { runVoter } from './voteHelpers.js';
30
30
  import { WorkflowState } from './workflowState.js';
31
+ /**
32
+ * 插件 ID:把私有 workflow_* 事件镜像成框架通用 plugin_progress 时挂这个 id。
33
+ * LiveArea 状态行会渲染成 "[plugin:workflow-runner] i/total <message>"
34
+ * (src/ui/LiveArea.tsx:123),让 TUI 里跑 workflow 不再"无声"。
35
+ * (-p 非交互模式另由 src/cli/print.ts 把通用 warning 事件落 stderr。)
36
+ */
37
+ const WF_PLUGIN_ID = 'workflow-runner';
31
38
  function stepKind(step) {
32
39
  if (step.type)
33
40
  return step.type;
@@ -65,9 +72,9 @@ async function* dispatchActionStep(step, vars, ctx) {
65
72
  if (step.type === 'assert')
66
73
  return execAssertStep(step, vars);
67
74
  if (step.tool)
68
- return await execToolStep(step, vars, ctx);
75
+ return yield* execToolStep(step, vars, ctx);
69
76
  if (step.llm)
70
- return await execLlmStep(step, vars, ctx);
77
+ return yield* execLlmStep(step, vars, ctx);
71
78
  if (step.skill)
72
79
  return yield* execSkillStep(step, vars, ctx);
73
80
  throw new Error(`step ${step.id}: 无可执行字段(tool/llm/skill/assert)`);
@@ -89,7 +96,7 @@ async function* runSteps(steps, vars, ctx, state) {
89
96
  if (step.when) {
90
97
  let pass = false;
91
98
  try {
92
- pass = Boolean(evalExpr(interpolate(step.when, vars), vars));
99
+ pass = Boolean(evalExpr(step.when, vars));
93
100
  }
94
101
  catch (e) {
95
102
  const errMsg = `when 表达式错误: ${e.message}`;
@@ -123,11 +130,21 @@ async function* runSteps(steps, vars, ctx, state) {
123
130
  index: i,
124
131
  total: steps.length,
125
132
  };
133
+ // P1 TUI 可见:把私有 workflow_step_start 镜像成框架通用 plugin_progress,
134
+ // LiveArea 状态行渲染成 "[plugin:workflow-runner] i/total step <id> (<kind>)"。
135
+ // (嵌套 block 的 i/total 相对当前 block,足够给"跑到哪一步"的实时反馈。)
136
+ yield {
137
+ type: 'plugin_progress',
138
+ pluginId: WF_PLUGIN_ID,
139
+ current: i + 1,
140
+ max: steps.length,
141
+ message: `step ${step.id} (${kind})`,
142
+ };
126
143
  // ---------- 控制流:branch ----------
127
144
  if (step.type === 'branch') {
128
145
  let cond = false;
129
146
  try {
130
- cond = Boolean(evalExpr(interpolate(step.condition ?? 'false', vars), vars));
147
+ cond = Boolean(evalExpr(step.condition ?? 'false', vars));
131
148
  }
132
149
  catch (e) {
133
150
  const errMsg = `branch 条件错误: ${e.message}`;
@@ -156,7 +173,7 @@ async function* runSteps(steps, vars, ctx, state) {
156
173
  if (step.type === 'loop') {
157
174
  let arr;
158
175
  try {
159
- arr = evalExpr(interpolate(step.over ?? '[]', vars), vars);
176
+ arr = evalExpr(step.over ?? '[]', vars);
160
177
  }
161
178
  catch (e) {
162
179
  const errMsg = `loop over 表达式错误: ${e.message}`;
@@ -208,6 +225,18 @@ async function* runSteps(steps, vars, ctx, state) {
208
225
  ? interpolate(step.prompt, vars)
209
226
  : 'pause(非交互模式直接跳过)';
210
227
  await state.appendProgress(`- ${step.id} pause: ${msg}`);
228
+ // P3:pause 在当前实现下**从不**真正等待人工(runner 是纯 AsyncGenerator,
229
+ // 没有把用户输入回灌进来的通道)。把"静默放行"变"响亮放行"——发通用 warning
230
+ // (-p 模式由 print.ts 落 stderr,权威通道)并镜像 plugin_progress(TUI 状态行可见),
231
+ // 避免审批闸门被悄悄跳过。真·HITL pause 属 P4,本轮不做。
232
+ const warnMsg = `⚠ pause "${step.id}" 在非交互模式被自动跳过,未真正等待人工确认——若这是审批闸门请勿依赖它。提示:${msg}`;
233
+ yield { type: 'warning', message: warnMsg };
234
+ yield {
235
+ type: 'plugin_progress',
236
+ pluginId: WF_PLUGIN_ID,
237
+ current: 0,
238
+ message: warnMsg,
239
+ };
211
240
  yield {
212
241
  type: 'workflow_step_skipped',
213
242
  id: step.id,
@@ -296,7 +325,7 @@ async function* runSteps(steps, vars, ctx, state) {
296
325
  const threshold = step.threshold ?? Math.ceil(voters_count / 2);
297
326
  let inputArr;
298
327
  try {
299
- inputArr = evalExpr(interpolate(step.input ?? '[]', vars), vars);
328
+ inputArr = evalExpr(step.input ?? '[]', vars);
300
329
  }
301
330
  catch (e) {
302
331
  const errMsg = `vote.input 求值失败: ${e.message}`;
@@ -408,10 +437,27 @@ export async function* runWorkflow(def, inputs, ctx) {
408
437
  name: def.name,
409
438
  totalSteps: def.steps.length,
410
439
  };
440
+ // P1 TUI 可见:开工时给 LiveArea 状态行一条 plugin_progress(0/total)。
441
+ yield {
442
+ type: 'plugin_progress',
443
+ pluginId: WF_PLUGIN_ID,
444
+ current: 0,
445
+ max: def.steps.length,
446
+ message: `▶ workflow ${def.name}`,
447
+ };
411
448
  try {
412
449
  const halted = yield* runSteps(def.steps, vars, ctx, state);
413
450
  if (halted)
414
451
  return;
452
+ // 完成态也给一条 plugin_progress(放在 workflow_done 之前,保证
453
+ // workflow_done 仍是事件流最后一条——e2e 测试依赖 events[last]===workflow_done)。
454
+ yield {
455
+ type: 'plugin_progress',
456
+ pluginId: WF_PLUGIN_ID,
457
+ current: def.steps.length,
458
+ max: def.steps.length,
459
+ message: `✓ workflow ${def.name} 完成`,
460
+ };
415
461
  yield { type: 'workflow_done', name: def.name, vars: vars.snapshot() };
416
462
  }
417
463
  finally {
@@ -12,8 +12,8 @@ export function execAssertStep(step, vars) {
12
12
  }
13
13
  let ok;
14
14
  try {
15
- const expanded = interpolate(step.condition, vars);
16
- ok = Boolean(evalExpr(expanded, vars));
15
+ // P2:表达式上下文直接 evalExpr(${} 由引擎原生处理),不再"先插值再 eval"。
16
+ ok = Boolean(evalExpr(step.condition, vars));
17
17
  }
18
18
  catch (e) {
19
19
  throw new Error(`assert 表达式求值失败: ${e.message}`);
@@ -5,7 +5,9 @@
5
5
  * 单轮 LLM 生成。关键设计:
6
6
  * - tools: [] —— 不开任何工具(工具调用应该写成 tool: step)
7
7
  * - 自带独立 system + user 两条消息,不污染主 history
8
- * - 流式累积文本(reasoning 字段在这里忽略;只关心最终文本)
8
+ * - AsyncGenerator:非 schema 模式把 text_delta 流式 yield 给 UI(答案是主角,
9
+ * 消除 TUI"无声"),最终仍 return StepResult;schema 模式静默累积 JSON 不回显。
10
+ * reasoning 字段在这里忽略;只关心最终文本
9
11
  *
10
12
  * capture:
11
13
  * - `{ text: var_name }` 绑生成文本(无 output_schema 时)
@@ -18,7 +20,7 @@
18
20
  * - system prompt 切换为严格 JSON 模式,禁止 markdown 代码块包裹
19
21
  * ============================================================
20
22
  */
21
- import { ALL_TOOLS, chat } from '../../../../src/plugin-sdk.js';
23
+ import { ALL_TOOLS, chat, } from '../../../../src/plugin-sdk.js';
22
24
  import { interpolate } from '../expressions.js';
23
25
  import { runMiniReAct } from '../miniReAct.js';
24
26
  const LLM_STEP_SYSTEM_PLAIN = '你正在被一个 workflow 调用。只输出本步骤要求的内容本身,不要寒暄、不要列要求复述、不要工具调用语法。';
@@ -37,7 +39,7 @@ const LLM_STEP_SYSTEM_WITH_TOOLS = `你正在被一个 workflow 调用。你被
37
39
  4. 完成后**不要**再调用工具,直接输出最终回答
38
40
 
39
41
  如果 workflow 要求结构化输出,请在最终回答里严格遵循 JSON Schema(无 markdown 代码块包裹)。`;
40
- export async function execLlmStep(step, vars, ctx) {
42
+ export async function* execLlmStep(step, vars, ctx) {
41
43
  if (typeof step.llm !== 'string')
42
44
  throw new Error(`step ${step.id}: 缺少 llm 字段`);
43
45
  const prompt = interpolate(step.llm, vars);
@@ -88,8 +90,13 @@ export async function execLlmStep(step, vars, ctx) {
88
90
  responseFormat,
89
91
  signal: ctx.signal,
90
92
  })) {
91
- if (ev.type === 'text_delta')
93
+ if (ev.type === 'text_delta') {
92
94
  text += ev.delta;
95
+ // 非 schema 模式:把答案流式 yield 给 UI(答案是主角,消除"无声")。
96
+ // schema 模式输出是原始 JSON,不当"答案文本"回显,避免屏幕闪 JSON。
97
+ if (!useSchema)
98
+ yield { type: 'text', delta: ev.delta };
99
+ }
93
100
  if (ev.type === 'done' && ev.stopReason === 'aborted') {
94
101
  throw new Error('用户中断');
95
102
  }
@@ -1,4 +1,6 @@
1
- // TODO ADR-05: output_schema 暂未在 skill 节点生效,仅 llm 节点支持;workflow 用 vote 时通过 runVoter 直接调 execLlmStep
1
+ // NOTE: output_schema 不支持 skill 节点——loader 现已直接拒绝 skill+output_schema 组合
2
+ // (见 loader.ts validateSteps,P3 修复:静默 no-op 改为加载期报错)。
3
+ // 仅 llm 节点支持 provider-native structured output;vote 通过 runVoter 调 execLlmStep(llm 虚拟步骤)。
2
4
  /**
3
5
  * ============================================================
4
6
  * plugins/workflow-runner/src/stepExecutors/skill.ts —— skill step 执行
@@ -5,17 +5,23 @@
5
5
  * 把 step.args 递归 ${var} 插值后 JSON.stringify,转发到 executeTool。
6
6
  * 失败(ok=false)抛 Error,让 runner 走 onError 处理。
7
7
  *
8
+ * v2(P1 TUI 可见):本执行器改为 AsyncGenerator,在调 executeTool 前后
9
+ * yield 通用 tool_start / tool_end LoopEvent —— 这样 workflow 的 tool 步骤
10
+ * 在 TUI 里像普通 ReAct 工具调用一样显示(runningTools spinner + 人话描述),
11
+ * 不再"无声"。**不** yield tool_messages_committed:workflow 步骤不进主 history,
12
+ * 其 live 态由 useChat 的 submit finally(resetTurnState) 在事件流末尾统一清理;
13
+ * tool_end 自身已把该 callId 从 runningTools 里摘除,无需额外 commit 语义。
14
+ *
8
15
  * capture 字段(支持点路径):
9
16
  * - `{ content: var_name }` 绑 ToolResult.content(工具回执文本)
10
17
  * - `{ result: var_name }` 同义,给"无结构化字段"工具用
11
18
  * - `{ args.content: var_name }` 绑插值后传给工具的入参(如真正写入的内容)
12
19
  * - `{ args.<任意字段>: var_name }` 绑入参的具名字段
13
- * P1 可扩展按工具特定字段(如 Read 的 fileSize)。
14
20
  * ============================================================
15
21
  */
16
- import { executeTool, getToolByName } from '../../../../src/plugin-sdk.js';
22
+ import { executeTool, getToolByName, } from '../../../../src/plugin-sdk.js';
17
23
  import { interpolateDeep } from '../expressions.js';
18
- export async function execToolStep(step, vars, ctx) {
24
+ export async function* execToolStep(step, vars, ctx) {
19
25
  if (!step.tool)
20
26
  throw new Error(`step ${step.id}: 缺少 tool 字段`);
21
27
  const tool = getToolByName(step.tool);
@@ -23,7 +29,23 @@ export async function execToolStep(step, vars, ctx) {
23
29
  throw new Error(`step ${step.id}: 未知 tool "${step.tool}"`);
24
30
  const args = interpolateDeep(step.args ?? {}, vars);
25
31
  const argsJson = JSON.stringify(args);
32
+ const toolCallId = `wf_${step.id}`;
33
+ const argsPreview = argsJson.length > 80 ? `${argsJson.slice(0, 80)}…` : argsJson;
34
+ yield {
35
+ type: 'tool_start',
36
+ toolName: tool.name,
37
+ toolCallId,
38
+ argsPreview,
39
+ argsFriendly: friendlyToolDesc(tool.name, args),
40
+ };
26
41
  const result = await executeTool(tool.name, argsJson, ctx.signal);
42
+ yield {
43
+ type: 'tool_end',
44
+ toolName: tool.name,
45
+ toolCallId,
46
+ ok: result.ok,
47
+ content: result.ok ? result.content : result.error,
48
+ };
27
49
  if (!result.ok) {
28
50
  throw new Error(result.error);
29
51
  }
@@ -33,3 +55,16 @@ export async function execToolStep(step, vars, ctx) {
33
55
  preview: content.length > 200 ? `${content.slice(0, 200)}...` : content,
34
56
  };
35
57
  }
58
+ /**
59
+ * 给 tool_start 生成人话描述(LiveArea spinner 行用)。
60
+ * 取常见入参键(file_path/command/query/...)拼成 "Write greetings/a/hello.txt"
61
+ * 这样的短句;都没命中就回退到工具名本身。
62
+ */
63
+ function friendlyToolDesc(toolName, args) {
64
+ const key = ['file_path', 'command', 'query', 'pattern', 'path', 'url'].find((k) => typeof args[k] === 'string');
65
+ if (!key)
66
+ return toolName;
67
+ const val = String(args[key]);
68
+ const short = val.length > 60 ? `${val.slice(0, 60)}…` : val;
69
+ return `${toolName} ${short}`;
70
+ }
@@ -41,7 +41,13 @@ export async function runVoter(rules, item, vars, ctx) {
41
41
  output_schema: VERDICT_SCHEMA,
42
42
  };
43
43
  try {
44
- const result = await execLlmStep(virtualStep, vars, ctx);
44
+ // execLlmStep 现为 AsyncGenerator:手动 drain return 值。voter 必须静默——
45
+ // N 个 voter 在 Promise.all 里并行,若把它们的 text 事件向上转发会在 UI 互相打架。
46
+ const gen = execLlmStep(virtualStep, vars, ctx);
47
+ let res = await gen.next();
48
+ while (!res.done)
49
+ res = await gen.next();
50
+ const result = res.value;
45
51
  const verdict = extractVerdict(result.raw);
46
52
  if (verdict)
47
53
  return verdict;
package/src/cli/print.js CHANGED
@@ -23,6 +23,15 @@ import { runWithPlugins } from '../plugins/pluginRunner.js';
23
23
  const TOOL_OUTPUT_PREVIEW_MAX = 200;
24
24
  /** stdin 读取超时(ms),防止非 TTY + 无管道时永久挂起 */
25
25
  const STDIN_TIMEOUT_MS = 3000;
26
+ /** 已告警过的未知事件类型(去重,避免高频插件私有事件刷屏 stderr)。 */
27
+ const warnedUnknownEventTypes = new Set();
28
+ function warnUnknownEventType(type) {
29
+ const t = typeof type === 'string' ? type : String(type);
30
+ if (warnedUnknownEventTypes.has(t))
31
+ return;
32
+ warnedUnknownEventTypes.add(t);
33
+ process.stderr.write(`[minimal-agent] 忽略未识别事件类型 "${t}"(插件私有事件;若是 yield 时拼错的 LoopEvent type,请检查插件)\n`);
34
+ }
26
35
  function handleEPIPE(stream) {
27
36
  return (err) => {
28
37
  if (err.code === 'EPIPE')
@@ -102,7 +111,7 @@ export async function runPrintMode(provider, args, initialHistory, options) {
102
111
  history,
103
112
  signal: abortController.signal,
104
113
  })) {
105
- // event 是 LoopEvent | PluginEvent;handleEvent 识别 LoopEvent + workflow_warning,其它走 default 静默忽略
114
+ // event 是 LoopEvent | PluginEvent;handleEvent 只认 LoopEvent,未识别的插件私有事件走 default 静默忽略
106
115
  const result = handleEvent(event, output, options.verbose);
107
116
  if (result.exitCode !== undefined) {
108
117
  await saveContext(history);
@@ -153,8 +162,8 @@ export function handleEvent(event, output, verbose) {
153
162
  process.stderr.write(`📝 压缩完成: ${event.before} → ${event.after} tokens\n`);
154
163
  }
155
164
  return {};
156
- case 'workflow_warning':
157
- // workflow 引擎警告(如多余位置参数、--input 覆盖位置参数等)始终走 stderr,
165
+ case 'warning':
166
+ // 通用非致命警告(如 workflow 多余参数、pause 被自动跳过等)始终走 stderr,
158
167
  // 不受 verbose 控制 —— 这是用户必须看到的信号,避免静默丢弃语义被忽略。
159
168
  process.stderr.write(`${event.message}\n`);
160
169
  return {};
@@ -165,7 +174,19 @@ export function handleEvent(event, output, verbose) {
165
174
  case 'error':
166
175
  console.error(`\n错误: ${event.error}`);
167
176
  return { exitCode: 1 };
177
+ // 已知但 -p 模式无需呈现的框架事件:显式忽略,好让 default 只剩"真正未知"的插件私有事件。
178
+ case 'user_message_committed':
179
+ case 'tool_messages_committed':
180
+ case 'interrupted':
181
+ case 'plugin_progress':
182
+ case 'reasoning':
183
+ case 'stage_change':
184
+ return {};
168
185
  default:
186
+ // 落到这里 = 插件私有事件(PluginEvent,type 任意字符串)。正常静默忽略;
187
+ // verbose 下打一行 stderr 帮插件作者发现 yield 了拼错的 type(如 tool_strat)。
188
+ if (verbose)
189
+ warnUnknownEventType(event.type);
169
190
  return {};
170
191
  }
171
192
  }
@@ -11,6 +11,15 @@ function stripQuotes(s) {
11
11
  }
12
12
  return trimmed;
13
13
  }
14
+ /**
15
+ * 跨平台取路径最后一段。Windows 上 join() 产出反斜杠路径,单纯 split('/')
16
+ * 不切分会让整条绝对路径变成"目录名"。同时吃 \\ 与 /,与
17
+ * hookEngine.pluginNameFromRoot 的归一逻辑保持一致。
18
+ */
19
+ export function baseName(p) {
20
+ const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
21
+ return parts[parts.length - 1] || p;
22
+ }
14
23
  function parseMarkdownFrontmatter(content) {
15
24
  const match = content.match(/^---\n([\s\S]*?)\n---/);
16
25
  if (!match)
@@ -28,7 +37,7 @@ function parseMarkdownFrontmatter(content) {
28
37
  return frontmatter;
29
38
  }
30
39
  async function loadPlugin(pluginDirPath) {
31
- const dirName = pluginDirPath.split('/').pop() ?? pluginDirPath;
40
+ const dirName = baseName(pluginDirPath);
32
41
  const manifestPath = join(pluginDirPath, '.claude-plugin', 'plugin.json');
33
42
  let manifestName = dirName;
34
43
  let manifestVersion;
@@ -101,6 +110,9 @@ export async function discoverPlugins() {
101
110
  discoveryDone = true;
102
111
  // 双源扫描:cwd 优先 + packageRoot fallback;按数组顺序处理,已有 manifestName 跳过
103
112
  const searchPaths = getResourceSearchPaths('plugins', import.meta.url);
113
+ // 命令名 → 首个声明它的插件名。resolveCommand 按插入顺序返回第一个命中,
114
+ // 所以两个不同插件声明同名命令时,先注册者生效、后者被影 —— 不再静默,warn 一次。
115
+ const commandOwners = new Map();
104
116
  for (const root of searchPaths) {
105
117
  try {
106
118
  const entries = await readdir(root, { withFileTypes: true });
@@ -115,6 +127,16 @@ export async function discoverPlugins() {
115
127
  if (pluginCache.has(plugin.name))
116
128
  continue; // cwd 已注册的 manifestName,packageRoot 同名跳过
117
129
  pluginCache.set(plugin.name, plugin);
130
+ for (const cmd of plugin.commands) {
131
+ const owner = commandOwners.get(cmd.name);
132
+ if (owner && owner !== plugin.name) {
133
+ console.warn(`[minimal-agent] 命令名冲突:/${cmd.name} 同时由插件 "${owner}" 与 "${plugin.name}" 声明;` +
134
+ `先注册的 "${owner}" 生效,"${plugin.name}" 的同名命令被忽略。`);
135
+ }
136
+ else if (!owner) {
137
+ commandOwners.set(cmd.name, plugin.name);
138
+ }
139
+ }
118
140
  }
119
141
  }
120
142
  catch {
@@ -147,9 +169,11 @@ export function buildCommandInput(resolved) {
147
169
  const { cmd, arguments: args } = resolved;
148
170
  let input = cmd.promptBody;
149
171
  input = input.replaceAll('${CLAUDE_PLUGIN_ROOT}', cmd.pluginRoot);
172
+ // 模板是否自己引用了参数占位符。引用了就别再补尾注,否则参数出现两遍。
173
+ const hadArgsPlaceholder = input.includes('$ARGUMENTS') || input.includes('${ARGUMENTS}');
150
174
  input = input.replaceAll('$ARGUMENTS', args);
151
175
  input = input.replaceAll('${ARGUMENTS}', args);
152
- if (args.trim()) {
176
+ if (!hadArgsPlaceholder && args.trim()) {
153
177
  input += `\n\n用户参数: ${args.trim()}`;
154
178
  }
155
179
  return input;
@@ -567,6 +567,11 @@ export function handleEvent(ev, setters) {
567
567
  setters.setError(e.error);
568
568
  setters.bump();
569
569
  break;
570
+ case 'warning':
571
+ // 通用非致命警告。TUI 不把它当红色 error 渲染;workflow 这类插件已另发
572
+ // plugin_progress 把同一文案镜像到状态行(LiveArea)。这里显式 no-op 让它
573
+ // 成为"已识别事件",不触发 default 分支对未知事件的 dev 告警(见 default)。
574
+ break;
570
575
  case 'plugin_progress':
571
576
  setters.setPluginProgress({
572
577
  pluginId: e.pluginId,
@@ -588,5 +593,10 @@ export function handleEvent(ev, setters) {
588
593
  // 工具集合的清空时机是各自 tool_end
589
594
  setters.setProgressStage(e.stage);
590
595
  break;
596
+ default:
597
+ // 落到这里 = 插件私有事件(PluginEvent,type 任意字符串),TUI 不识别即忽略。
598
+ // 这里**故意不**写 console.warn —— Ink 渲染期间写 stdout/stderr 会撕裂终端画面。
599
+ // 要排查插件 yield 了拼错的事件 type,请用 `minimal-agent -p --verbose`(print.ts 落 stderr)。
600
+ break;
591
601
  }
592
602
  }
@@ -73,7 +73,7 @@
73
73
  "prompt": { "type": "string" },
74
74
  "output_schema": {
75
75
  "type": "object",
76
- "description": "⚠ ADR-05 新增:LLM 输出 JSON Schema 强约束,走 provider-native structured output。仅 llm/skill 节点生效;详见 docs/05-advanced-workflow-integration.md。"
76
+ "description": "⚠ ADR-05 新增:LLM 输出 JSON Schema 强约束,走 provider-native structured output。仅 llm 节点生效(skill 节点 loader 拒绝);详见 docs/workflow-design.md。"
77
77
  },
78
78
  "fork": {
79
79
  "type": "array",
@@ -105,7 +105,7 @@
105
105
  "hint": { "type": "string" }
106
106
  }
107
107
  },
108
- "description": "⚠ ADR-05 新增:llm/skill 节点声明可用的上下文文件清单(path + hint),runner 拼进 prompt 并开放 Read 子循环,不 inline 内容。详见 docs/05-advanced-workflow-integration.md。"
108
+ "description": "⚠ ADR-05 新增:仅 llm 节点声明可用的上下文文件清单(path + hint),runner 拼进 prompt 并开放 Read 子循环,不 inline 内容(skill 节点 loader 拒绝)。详见 docs/workflow-design.md。"
109
109
  },
110
110
  "allowed_tools": {
111
111
  "type": "array",