minimal-agent 0.5.5 → 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 +1 -1
- package/plugins/workflow-runner/src/expressions.js +63 -8
- package/plugins/workflow-runner/src/index.js +2 -2
- package/plugins/workflow-runner/src/loader.js +14 -0
- package/plugins/workflow-runner/src/runner.js +52 -6
- package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -2
- package/plugins/workflow-runner/src/stepExecutors/llm.js +11 -4
- package/plugins/workflow-runner/src/stepExecutors/skill.js +3 -1
- package/plugins/workflow-runner/src/stepExecutors/tool.js +38 -3
- package/plugins/workflow-runner/src/voteHelpers.js +7 -1
- package/src/cli/print.js +24 -3
- package/src/context/persistContext.js +17 -1
- package/src/llm/client.js +16 -2
- package/src/loop.js +16 -29
- package/src/plugins/commandRouter.js +26 -2
- package/src/prompts/system.js +16 -16
- package/src/tools/read/read.js +5 -1
- package/src/tools/shared/fileState.js +23 -0
- package/src/ui/App.js +155 -11
- package/src/ui/InputBox.js +31 -57
- package/src/ui/LiveArea.js +132 -0
- package/src/ui/MessageList.js +214 -21
- package/src/ui/ProgressPanel.js +4 -48
- package/src/ui/StatusLine.js +21 -2
- package/src/ui/hooks/useChat.js +417 -105
- package/src/ui/hooks/useTokenUsage.js +0 -1
- package/src/ui/liveViewport.js +176 -0
- package/workflows/schema.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.
|
|
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 :=
|
|
17
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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
|
|
75
|
+
return yield* execToolStep(step, vars, ctx);
|
|
69
76
|
if (step.llm)
|
|
70
|
-
return
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
16
|
-
ok = Boolean(evalExpr(
|
|
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
|
-
* -
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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 '
|
|
157
|
-
// workflow
|
|
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
|
}
|
|
@@ -53,15 +53,31 @@ export async function loadContext(file) {
|
|
|
53
53
|
return null;
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* 把 messages 数组过滤掉 _ephemeral=true 的 system 消息。
|
|
58
|
+
*
|
|
59
|
+
* v2.x 新增:UI 用 ephemeral system 消息把 slash 命令输出落进 scrollback
|
|
60
|
+
* (替代 process.stdout.write 旁路 Ink)。这种消息只供 UI 展示,不该污染
|
|
61
|
+
* 持久化历史;这里在 saveContext 之前做一次过滤。
|
|
62
|
+
*
|
|
63
|
+
* 导出供测试断言。
|
|
64
|
+
*/
|
|
65
|
+
export function filterEphemeralMessages(messages) {
|
|
66
|
+
return messages.filter((m) => !(m.role === 'system' && m._ephemeral === true));
|
|
67
|
+
}
|
|
56
68
|
/**
|
|
57
69
|
* 保存当前 messages 为"上下文文件",覆盖之前内容。
|
|
58
70
|
* 写失败不抛——丢一次落盘比把进程崩掉划算。
|
|
71
|
+
*
|
|
72
|
+
* v2.x 新增:写盘前过滤掉 _ephemeral=true 的 system 消息(UI 临时提示,
|
|
73
|
+
* 不应进入持久化历史)。
|
|
59
74
|
*/
|
|
60
75
|
export async function saveContext(messages, file) {
|
|
61
76
|
const target = file ?? getContextPath();
|
|
62
77
|
try {
|
|
63
78
|
await mkdir(dirname(target), { recursive: true });
|
|
64
|
-
const
|
|
79
|
+
const persistable = filterEphemeralMessages(messages);
|
|
80
|
+
const data = { updatedAt: Date.now(), messages: persistable };
|
|
65
81
|
await writeFile(target, JSON.stringify(data), 'utf8');
|
|
66
82
|
}
|
|
67
83
|
catch {
|
package/src/llm/client.js
CHANGED
|
@@ -44,10 +44,24 @@ export async function* chat(args) {
|
|
|
44
44
|
parameters: t.parameters,
|
|
45
45
|
},
|
|
46
46
|
}));
|
|
47
|
-
// 2.
|
|
47
|
+
// 2. 过滤并清洁 messages 数组,剥离 timestamp, id, _ephemeral 等本地渲染元数据
|
|
48
|
+
const cleanedMessages = messages
|
|
49
|
+
.filter((m) => {
|
|
50
|
+
// 过滤掉 _ephemeral=true 的临时 UI 提示消息,防止其污染 LLM 核心上下文
|
|
51
|
+
if (m.role === 'system' && m._ephemeral === true) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
})
|
|
56
|
+
.map((m) => {
|
|
57
|
+
// 结构解构以去除本地专属渲染元数据
|
|
58
|
+
const { id, timestamp, ...rest } = m;
|
|
59
|
+
return rest;
|
|
60
|
+
});
|
|
61
|
+
// 3. 构造请求体
|
|
48
62
|
const body = {
|
|
49
63
|
model: provider.model,
|
|
50
|
-
messages,
|
|
64
|
+
messages: cleanedMessages,
|
|
51
65
|
tools: openaiTools.length > 0 ? openaiTools : undefined,
|
|
52
66
|
stream: true,
|
|
53
67
|
// tool_choice: 'auto' 是默认值,可不写;某些 provider 必须显式
|