minimal-agent 0.4.0 → 0.5.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.4.0",
3
+ "version": "0.5.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>",
@@ -124,6 +124,24 @@ function validate(obj, file) {
124
124
  }
125
125
  }
126
126
  }
127
+ // ADR-05: 控制流 type 集合——这些节点的"动作"由 type 自身定义,
128
+ // 因此不能附带 tool/skill/llm 也不能配 output_schema / context_files / allowed_tools。
129
+ const CONTROL_FLOW_TYPES = new Set([
130
+ 'assert',
131
+ 'branch',
132
+ 'loop',
133
+ 'pause',
134
+ 'parallel',
135
+ 'vote',
136
+ ]);
137
+ const VALID_STEP_TYPES = new Set([
138
+ 'assert',
139
+ 'branch',
140
+ 'loop',
141
+ 'pause',
142
+ 'parallel',
143
+ 'vote',
144
+ ]);
127
145
  function validateSteps(steps, file, pathPrefix) {
128
146
  const seenIds = new Set();
129
147
  for (const [i, item] of steps.entries()) {
@@ -140,6 +158,10 @@ function validateSteps(steps, file, pathPrefix) {
140
158
  throw new WorkflowLoadError(`${p}.id "${s.id}" 在当前 steps 列表中重复`, file);
141
159
  }
142
160
  seenIds.add(s.id);
161
+ // type 枚举校验(提前于动作互斥,给更准确的错误信息)
162
+ if (s.type !== undefined && (typeof s.type !== 'string' || !VALID_STEP_TYPES.has(s.type))) {
163
+ throw new WorkflowLoadError(`${p}.type "${String(s.type)}" 非法;合法值: ${[...VALID_STEP_TYPES].join(' / ')}`, file);
164
+ }
143
165
  // 动作字段恰好一个
144
166
  const actionFields = ['tool', 'skill', 'llm', 'type'];
145
167
  const present = actionFields.filter((k) => s[k] !== undefined);
@@ -179,5 +201,92 @@ function validateSteps(steps, file, pathPrefix) {
179
201
  if (Array.isArray(s.else))
180
202
  validateSteps(s.else, file, `${p}.else`);
181
203
  }
204
+ // ---------------- ADR-05 新增校验 ----------------
205
+ // parallel:必须有非空 fork[],递归校验子步骤
206
+ if (s.type === 'parallel') {
207
+ if (!Array.isArray(s.fork) || s.fork.length === 0) {
208
+ throw new WorkflowLoadError(`${p} type=parallel 必须有 fork: [...](至少 1 个子步骤)`, file);
209
+ }
210
+ validateSteps(s.fork, file, `${p}.fork`);
211
+ }
212
+ else if (s.fork !== undefined) {
213
+ throw new WorkflowLoadError(`${p}.fork 只能用在 type=parallel 节点上`, file);
214
+ }
215
+ // vote:voters_count (2~10) / rules / input 必填;threshold 可选但越界要拦
216
+ if (s.type === 'vote') {
217
+ const vc = s.voters_count;
218
+ const rules = s.rules;
219
+ const input = s.input;
220
+ const missing = typeof vc !== 'number' ||
221
+ !Number.isInteger(vc) ||
222
+ vc < 2 ||
223
+ vc > 10 ||
224
+ typeof rules !== 'string' ||
225
+ rules.length === 0 ||
226
+ typeof input !== 'string' ||
227
+ input.length === 0;
228
+ if (missing) {
229
+ throw new WorkflowLoadError(`${p} type=vote 要求 voters_count (2~10), rules, input 三个字段`, file);
230
+ }
231
+ if (s.threshold !== undefined) {
232
+ const t = s.threshold;
233
+ if (typeof t !== 'number' ||
234
+ !Number.isInteger(t) ||
235
+ t < 1 ||
236
+ t > vc) {
237
+ throw new WorkflowLoadError(`${p} threshold=${String(t)} 超出范围;必须是 1 <= threshold <= voters_count(${vc})`, file);
238
+ }
239
+ }
240
+ }
241
+ else {
242
+ // 非 vote 节点不应携带 vote-only 字段
243
+ for (const k of ['voters_count', 'rules', 'threshold']) {
244
+ if (s[k] !== undefined) {
245
+ throw new WorkflowLoadError(`${p}.${k} 只能用在 type=vote 节点上`, file);
246
+ }
247
+ }
248
+ }
249
+ // output_schema:必须是 object;不能用在控制流节点上
250
+ if (s.output_schema !== undefined) {
251
+ if (typeof s.output_schema !== 'object' ||
252
+ s.output_schema === null ||
253
+ Array.isArray(s.output_schema)) {
254
+ throw new WorkflowLoadError(`${p}.output_schema 必须是对象`, file);
255
+ }
256
+ if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
257
+ throw new WorkflowLoadError(`${p}.output_schema 不能用在 type=${s.type} 控制流节点上`, file);
258
+ }
259
+ }
260
+ // context_files:[{ path, hint? }];不能用在控制流节点上
261
+ if (s.context_files !== undefined) {
262
+ if (!Array.isArray(s.context_files)) {
263
+ throw new WorkflowLoadError(`${p}.context_files 必须是数组`, file);
264
+ }
265
+ for (const [j, cf] of s.context_files.entries()) {
266
+ if (!cf || typeof cf !== 'object' || Array.isArray(cf)) {
267
+ throw new WorkflowLoadError(`${p}.context_files[${j}] 必须是 { path: string, hint?: string } 对象`, file);
268
+ }
269
+ const r = cf;
270
+ if (typeof r.path !== 'string' || r.path.length === 0) {
271
+ throw new WorkflowLoadError(`${p}.context_files[${j}].path 必填(非空 string)`, file);
272
+ }
273
+ if (r.hint !== undefined && typeof r.hint !== 'string') {
274
+ throw new WorkflowLoadError(`${p}.context_files[${j}].hint 必须是 string`, file);
275
+ }
276
+ }
277
+ if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
278
+ throw new WorkflowLoadError(`${p}.context_files 不能用在 type=${s.type} 控制流节点上`, file);
279
+ }
280
+ }
281
+ // allowed_tools:string[];不能用在控制流节点上
282
+ if (s.allowed_tools !== undefined) {
283
+ if (!Array.isArray(s.allowed_tools) ||
284
+ !s.allowed_tools.every((x) => typeof x === 'string' && x.length > 0)) {
285
+ throw new WorkflowLoadError(`${p}.allowed_tools 必须是非空 string 数组`, file);
286
+ }
287
+ if (typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type)) {
288
+ throw new WorkflowLoadError(`${p}.allowed_tools 不能用在 type=${s.type} 控制流节点上`, file);
289
+ }
290
+ }
182
291
  }
183
292
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * ============================================================
3
+ * plugins/workflow-runner/src/miniReAct.ts —— 简化版 ReAct 子循环
4
+ * ------------------------------------------------------------
5
+ * ADR-05 阶段 4:当 llm step 声明 context_files 时,execLlmStep 会调本子循环
6
+ * 代替单轮 chat()。本循环只做最纯净的 ReAct:调 LLM → 执行 tool_call → 喂回
7
+ * → 再调 → 直到模型不再调工具或达到 maxTurns。
8
+ *
9
+ * 为什么不复用 src/loop.ts::runQuery?
10
+ * - runQuery 依赖太重:history 持久化、自动压缩、反应式压缩自救、
11
+ * microCompact、reasoning 累积、sessionState 注入、SIGINT 标记消息等
12
+ * - workflow 子步骤是"短小内嵌",不需要这些工程化保障
13
+ * - 独立实现可以精准控制:只用白名单工具、强 schema、重复调用 halt
14
+ *
15
+ * 关键决策:
16
+ * 1. **每轮都传 responseFormat**(如果有 output_schema)。OpenAI 兼容
17
+ * provider 对 tool_calls + json_schema 混用通常允许:tool_calls 走 function
18
+ * 协议,最终 assistant 的 text 是 JSON。这样比"双轮探测"简单可靠。
19
+ * 2. **重复工具调用检测**:同 name 同 args 出现 3 次直接 halt,避免模型卡死。
20
+ * 3. **executeTool 自带 Zod 校验和错误包装**,本循环只需透传 tool_call_id 配对。
21
+ * 4. **maxTurns 默认 10**(vs runQuery 的 50),workflow 子步骤天生该收敛快。
22
+ * ============================================================
23
+ */
24
+ import { chat, executeTool, } from '../../../src/plugin-sdk.js';
25
+ /**
26
+ * 执行一次 ReAct 子循环。返回最终 StepResult(与 execLlmStep 老路径形状一致)。
27
+ *
28
+ * 抛错场景:
29
+ * - 用户中断(signal.aborted)
30
+ * - 重复工具调用 >= 3 次
31
+ * - 用尽 maxTurns 仍未收敛
32
+ * - output_schema 解析失败 / 非对象
33
+ */
34
+ export async function runMiniReAct(args) {
35
+ const { messages, tools, output_schema, provider, signal } = args;
36
+ const maxTurns = args.maxTurns ?? 10;
37
+ // 工作消息列表:拷贝一份,避免污染调用方
38
+ const conv = [...messages];
39
+ let finalText = '';
40
+ /** 上一轮是否仍有 tool_calls(用于检测 "用尽轮数还在调工具" 这一退出态) */
41
+ let lastTurnHadToolCalls = false;
42
+ /** 重复工具调用计数:key = `${name}:${arguments}` */
43
+ const toolCallSignature = new Map();
44
+ const responseFormat = output_schema
45
+ ? {
46
+ type: 'json_schema',
47
+ json_schema: {
48
+ name: 'minireact_output',
49
+ schema: output_schema,
50
+ strict: true,
51
+ },
52
+ }
53
+ : undefined;
54
+ for (let turn = 0; turn < maxTurns; turn++) {
55
+ if (signal?.aborted)
56
+ throw new Error('用户中断');
57
+ // ---- 1. 调 LLM 并累积流式响应 ----
58
+ let textAccum = '';
59
+ const toolCallsAccum = [];
60
+ for await (const ev of chat({
61
+ provider,
62
+ messages: conv,
63
+ tools,
64
+ responseFormat,
65
+ signal,
66
+ })) {
67
+ if (ev.type === 'text_delta') {
68
+ textAccum += ev.delta;
69
+ }
70
+ else if (ev.type === 'tool_call_delta') {
71
+ mergeToolCallDelta(toolCallsAccum, ev);
72
+ }
73
+ else if (ev.type === 'done' && ev.stopReason === 'aborted') {
74
+ throw new Error('用户中断');
75
+ }
76
+ // reasoning_delta / 其它事件忽略
77
+ }
78
+ // ---- 2. 把 assistant 消息推入 conv ----
79
+ conv.push({
80
+ role: 'assistant',
81
+ content: textAccum.length > 0 ? textAccum : null,
82
+ ...(toolCallsAccum.length > 0 ? { tool_calls: toolCallsAccum } : {}),
83
+ });
84
+ // ---- 3. 无 tool_calls → 结束循环 ----
85
+ if (toolCallsAccum.length === 0) {
86
+ finalText = textAccum.trim();
87
+ lastTurnHadToolCalls = false;
88
+ break;
89
+ }
90
+ lastTurnHadToolCalls = true;
91
+ // ---- 4. 执行所有 tool_calls,结果回填为 tool 消息 ----
92
+ for (const tc of toolCallsAccum) {
93
+ // 重复检测
94
+ const sig = `${tc.function.name}:${tc.function.arguments}`;
95
+ const count = (toolCallSignature.get(sig) ?? 0) + 1;
96
+ toolCallSignature.set(sig, count);
97
+ if (count >= 3) {
98
+ throw new Error(`miniReAct: 检测到重复工具调用(${tc.function.name} 同参数已 ${count} 次),可能陷入循环,halt。`);
99
+ }
100
+ if (signal?.aborted)
101
+ throw new Error('用户中断');
102
+ const result = await executeTool(tc.function.name, tc.function.arguments, signal);
103
+ conv.push({
104
+ role: 'tool',
105
+ tool_call_id: tc.id,
106
+ content: result.ok ? result.content : `工具错误: ${result.error}`,
107
+ });
108
+ }
109
+ // 继续下一轮
110
+ }
111
+ // 用尽 maxTurns 且最后一轮仍有 tool_calls
112
+ if (lastTurnHadToolCalls) {
113
+ throw new Error(`miniReAct: 用尽 ${maxTurns} 轮未得到最终回答(仍在调工具)`);
114
+ }
115
+ // ---- 5. 输出处理 ----
116
+ if (output_schema) {
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(finalText);
120
+ }
121
+ catch (e) {
122
+ throw new Error(`miniReAct: output_schema 要求 JSON 但解析失败: ${e.message}\n收到: ${finalText.slice(0, 200)}`);
123
+ }
124
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
125
+ throw new Error(`miniReAct: output_schema 要求对象,得到 ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
126
+ }
127
+ return {
128
+ raw: parsed,
129
+ preview: `[json+tools] ${finalText.slice(0, 180)}${finalText.length > 180 ? '...' : ''}`,
130
+ };
131
+ }
132
+ return {
133
+ raw: { text: finalText, result: finalText },
134
+ preview: finalText.length > 200 ? `${finalText.slice(0, 200)}...` : finalText,
135
+ };
136
+ }
137
+ // ---------------- 辅助 ----------------
138
+ /** 把一条 tool_call_delta 合并到累积数组上(与 src/loop.ts mergeToolCallDelta 同语义) */
139
+ function mergeToolCallDelta(acc, ev) {
140
+ let slot = acc[ev.index];
141
+ if (!slot) {
142
+ slot = {
143
+ id: '',
144
+ type: 'function',
145
+ function: { name: '', arguments: '' },
146
+ };
147
+ acc[ev.index] = slot;
148
+ }
149
+ if (ev.id)
150
+ slot.id = ev.id;
151
+ if (ev.name)
152
+ slot.function.name += ev.name;
153
+ if (ev.argumentsDelta)
154
+ slot.function.arguments += ev.argumentsDelta;
155
+ }
@@ -26,6 +26,7 @@ import { execLlmStep } from './stepExecutors/llm.js';
26
26
  import { execSkillStep } from './stepExecutors/skill.js';
27
27
  import { execToolStep } from './stepExecutors/tool.js';
28
28
  import { VarStack } from './types.js';
29
+ import { runVoter } from './voteHelpers.js';
29
30
  import { WorkflowState } from './workflowState.js';
30
31
  function stepKind(step) {
31
32
  if (step.type)
@@ -220,6 +221,135 @@ async function* runSteps(steps, vars, ctx, state) {
220
221
  };
221
222
  continue;
222
223
  }
224
+ // ---------- 控制流:parallel ----------
225
+ //
226
+ // 每个 fork 独立 VarStack 副本 + 独立事件缓冲;Promise.all 并发执行。
227
+ // 子 fork 的事件先收进 buffer,再按 fork 顺序一次性 yield,保 UI
228
+ // 时间轴可读(并发 yield 会导致 UI 拿到乱序事件)。
229
+ // 任一 fork halt → 整个 parallel halt。
230
+ if (step.type === 'parallel') {
231
+ const forks = step.fork ?? [];
232
+ const results = await Promise.all(forks.map(async (forkStep) => {
233
+ const forkVars = vars.fork();
234
+ const events = [];
235
+ let halted = false;
236
+ try {
237
+ halted = await collectStepsToBuffer([forkStep], forkVars, ctx, state, events);
238
+ }
239
+ catch (e) {
240
+ halted = true;
241
+ events.push({
242
+ type: 'error',
243
+ error: `fork ${forkStep.id} 抛异常: ${e.message}`,
244
+ });
245
+ }
246
+ return {
247
+ id: forkStep.id,
248
+ events,
249
+ vars: forkVars.snapshot(),
250
+ halted,
251
+ };
252
+ }));
253
+ // 顺序 yield 所有 fork 事件
254
+ for (const r of results) {
255
+ for (const ev of r.events)
256
+ yield ev;
257
+ }
258
+ if (results.some((r) => r.halted)) {
259
+ await state.appendProgress(`✗ ${step.id}: parallel: 至少一个 fork 失败`);
260
+ yield {
261
+ type: 'workflow_step_end',
262
+ id: step.id,
263
+ ok: false,
264
+ error: 'parallel: 至少一个 fork 失败',
265
+ };
266
+ yield {
267
+ type: 'error',
268
+ error: `Workflow halted at step "${step.id}": parallel: 至少一个 fork 失败`,
269
+ };
270
+ return true;
271
+ }
272
+ // 聚合:raw.fork 是 [{ id, vars }, ...],capture 可走 fork.0.vars.xxx 这种点路径
273
+ const aggResult = {
274
+ raw: { fork: results.map((r) => ({ id: r.id, vars: r.vars })) },
275
+ preview: `parallel: ${results.length} forks done`,
276
+ };
277
+ if (step.capture)
278
+ bindCapture(step.capture, aggResult, vars);
279
+ await state.appendProgress(`✓ ${step.id} (parallel ×${results.length})`);
280
+ await state.writeVars(vars.snapshot());
281
+ yield {
282
+ type: 'workflow_step_end',
283
+ id: step.id,
284
+ ok: true,
285
+ output: aggResult.preview,
286
+ };
287
+ continue;
288
+ }
289
+ // ---------- 控制流:vote ----------
290
+ //
291
+ // 对每个 input 数组项跑 N 个 voter(同 prompt + refuted: boolean),
292
+ // refuted 票数 >= threshold 则视为驳回(item 不进 survived)。
293
+ // 心理学定位:voter 倾向于 refuted=true(找漏洞),所以阈值算"驳回票数"。
294
+ if (step.type === 'vote') {
295
+ const voters_count = step.voters_count ?? 3;
296
+ const threshold = step.threshold ?? Math.ceil(voters_count / 2);
297
+ let inputArr;
298
+ try {
299
+ inputArr = evalExpr(interpolate(step.input ?? '[]', vars), vars);
300
+ }
301
+ catch (e) {
302
+ const errMsg = `vote.input 求值失败: ${e.message}`;
303
+ await state.appendProgress(`✗ ${step.id}: ${errMsg}`);
304
+ yield { type: 'workflow_step_end', id: step.id, ok: false, error: errMsg };
305
+ yield {
306
+ type: 'error',
307
+ error: `Workflow halted at step "${step.id}": ${errMsg}`,
308
+ };
309
+ return true;
310
+ }
311
+ if (!Array.isArray(inputArr)) {
312
+ const errMsg = `vote.input 必须求值为数组,得到 ${typeof inputArr}`;
313
+ await state.appendProgress(`✗ ${step.id}: ${errMsg}`);
314
+ yield { type: 'workflow_step_end', id: step.id, ok: false, error: errMsg };
315
+ yield {
316
+ type: 'error',
317
+ error: `Workflow halted at step "${step.id}": ${errMsg}`,
318
+ };
319
+ return true;
320
+ }
321
+ const survived = [];
322
+ for (const item of inputArr) {
323
+ if (ctx.signal?.aborted) {
324
+ yield { type: 'interrupted' };
325
+ return true;
326
+ }
327
+ const verdicts = await Promise.all(Array.from({ length: voters_count }, () => runVoter(step.rules ?? '', item, vars, ctx)));
328
+ const refutedCount = verdicts.filter((v) => v.refuted === true).length;
329
+ if (refutedCount < threshold) {
330
+ survived.push(item);
331
+ }
332
+ }
333
+ const voteResult = {
334
+ raw: {
335
+ survived,
336
+ total: inputArr.length,
337
+ kept: survived.length,
338
+ },
339
+ preview: `vote: ${survived.length}/${inputArr.length} survived (threshold=${threshold}/${voters_count})`,
340
+ };
341
+ if (step.capture)
342
+ bindCapture(step.capture, voteResult, vars);
343
+ await state.appendProgress(`✓ ${step.id} (vote ${survived.length}/${inputArr.length})`);
344
+ await state.writeVars(vars.snapshot());
345
+ yield {
346
+ type: 'workflow_step_end',
347
+ id: step.id,
348
+ ok: true,
349
+ output: voteResult.preview,
350
+ };
351
+ continue;
352
+ }
223
353
  // ---------- 普通动作 step:tool / llm / skill / assert ----------
224
354
  try {
225
355
  const result = yield* dispatchActionStep(step, vars, ctx);
@@ -288,3 +418,17 @@ export async function* runWorkflow(def, inputs, ctx) {
288
418
  await state.cleanup();
289
419
  }
290
420
  }
421
+ /**
422
+ * parallel fork 用:把 runSteps 的 yield 流收集到 buffer 数组,
423
+ * 让外层 Promise.all 拿到完整事件后再顺序 yield。
424
+ *
425
+ * 注:for-await 无法读取 generator 的 return value(JS 限制),
426
+ * 所以"是否 halted"靠"buffer 里有 error event"近似判断 —— 这符合
427
+ * runSteps 的语义(halt 一定会 yield error / interrupted)。
428
+ */
429
+ async function collectStepsToBuffer(steps, vars, ctx, state, buffer) {
430
+ for await (const ev of runSteps(steps, vars, ctx, state)) {
431
+ buffer.push(ev);
432
+ }
433
+ return buffer.some((ev) => ev.type === 'error' || ev.type === 'interrupted');
434
+ }
@@ -8,26 +8,84 @@
8
8
  * - 流式累积文本(reasoning 字段在这里忽略;只关心最终文本)
9
9
  *
10
10
  * capture:
11
- * - `{ text: var_name }` 绑生成文本
11
+ * - `{ text: var_name }` 绑生成文本(无 output_schema 时)
12
12
  * - `{ result: var_name }` 同义
13
+ * - `{ <field>: var_name }` 当 output_schema 命中时按 raw 上的字段名取
14
+ *
15
+ * ADR-05: 当 step.output_schema 存在 → 走 provider-native structured output:
16
+ * - 通过 chat() 的 responseFormat 参数下发 JSON Schema
17
+ * - 收到的文本必须解析为 JSON 对象,整对象作为 raw 暴露给 capture
18
+ * - system prompt 切换为严格 JSON 模式,禁止 markdown 代码块包裹
13
19
  * ============================================================
14
20
  */
15
- import { chat } from '../../../../src/plugin-sdk.js';
21
+ import { ALL_TOOLS, chat } from '../../../../src/plugin-sdk.js';
16
22
  import { interpolate } from '../expressions.js';
17
- const LLM_STEP_SYSTEM = '你正在被一个 workflow 调用。只输出本步骤要求的内容本身,不要寒暄、不要列要求复述、不要工具调用语法。';
23
+ import { runMiniReAct } from '../miniReAct.js';
24
+ const LLM_STEP_SYSTEM_PLAIN = '你正在被一个 workflow 调用。只输出本步骤要求的内容本身,不要寒暄、不要列要求复述、不要工具调用语法。';
25
+ const LLM_STEP_SYSTEM_SCHEMA = `你正在被一个 workflow 调用。
26
+ 要求:
27
+ 1. 严格按指定 JSON Schema 输出有效 JSON,**不要 markdown 代码块包裹**(不要 \`\`\`json 也不要 \`\`\`)
28
+ 2. 不要寒暄、不要解释、不要额外字段
29
+ 3. 字段缺失或类型不符会导致 workflow 失败
30
+ 4. 字符串内特殊字符必须正确转义(双引号 \\", 反斜杠 \\\\, 换行 \\n)`;
31
+ const LLM_STEP_SYSTEM_WITH_TOOLS = `你正在被一个 workflow 调用。你被授予了有限的工具(通常是 Read)以查询提供的上下文文件。
32
+
33
+ 工作流程:
34
+ 1. 先用 Read 工具读取你需要的文件(按需读取,**不要全部读完再思考**——大文件分段读、按 hint 选读)
35
+ 2. 基于读到的内容生成最终回答
36
+ 3. 不要寒暄、不要复述要求、不要解释你的推理过程
37
+ 4. 完成后**不要**再调用工具,直接输出最终回答
38
+
39
+ 如果 workflow 要求结构化输出,请在最终回答里严格遵循 JSON Schema(无 markdown 代码块包裹)。`;
18
40
  export async function execLlmStep(step, vars, ctx) {
19
41
  if (typeof step.llm !== 'string')
20
42
  throw new Error(`step ${step.id}: 缺少 llm 字段`);
21
43
  const prompt = interpolate(step.llm, vars);
44
+ // ===== ADR-05 阶段 4: context_files → miniReAct 子循环 =====
45
+ if (step.context_files && step.context_files.length > 0) {
46
+ const fileList = step.context_files
47
+ .map((f) => `- ${interpolate(f.path, vars)}${f.hint ? ` — ${f.hint}` : ''}`)
48
+ .join('\n');
49
+ const augmented = `${prompt}\n\n**以下文件可用,请用 Read 工具按需读取**:\n${fileList}`;
50
+ const allowedNames = step.allowed_tools ?? ['Read'];
51
+ const allowedTools = ALL_TOOLS.filter((t) => allowedNames.includes(t.name));
52
+ if (allowedTools.length === 0) {
53
+ throw new Error(`step ${step.id}: context_files 启用但 allowed_tools=${JSON.stringify(allowedNames)} 没有匹配的可用工具`);
54
+ }
55
+ return await runMiniReAct({
56
+ messages: [
57
+ { role: 'system', content: LLM_STEP_SYSTEM_WITH_TOOLS },
58
+ { role: 'user', content: augmented },
59
+ ],
60
+ tools: allowedTools,
61
+ output_schema: step.output_schema,
62
+ provider: ctx.provider,
63
+ signal: ctx.signal,
64
+ maxTurns: step.maxTurns ?? 10,
65
+ });
66
+ }
67
+ // ===== END NEW =====
68
+ const useSchema = step.output_schema !== undefined;
22
69
  const messages = [
23
- { role: 'system', content: LLM_STEP_SYSTEM },
70
+ { role: 'system', content: useSchema ? LLM_STEP_SYSTEM_SCHEMA : LLM_STEP_SYSTEM_PLAIN },
24
71
  { role: 'user', content: prompt },
25
72
  ];
73
+ const responseFormat = useSchema
74
+ ? {
75
+ type: 'json_schema',
76
+ json_schema: {
77
+ name: `${step.id}_output`,
78
+ schema: step.output_schema,
79
+ strict: true,
80
+ },
81
+ }
82
+ : undefined;
26
83
  let text = '';
27
84
  for await (const ev of chat({
28
85
  provider: ctx.provider,
29
86
  messages,
30
87
  tools: [],
88
+ responseFormat,
31
89
  signal: ctx.signal,
32
90
  })) {
33
91
  if (ev.type === 'text_delta')
@@ -37,6 +95,24 @@ export async function execLlmStep(step, vars, ctx) {
37
95
  }
38
96
  }
39
97
  const trimmed = text.trim();
98
+ if (useSchema) {
99
+ // strict structured output:解析 JSON 后整个对象作为 raw
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(trimmed);
103
+ }
104
+ catch (e) {
105
+ throw new Error(`step ${step.id}: output_schema 要求 JSON 输出但解析失败: ${e.message}\n收到: ${trimmed.slice(0, 200)}`);
106
+ }
107
+ if (parsed === null || typeof parsed !== 'object') {
108
+ throw new Error(`step ${step.id}: output_schema 要求对象输出,得到 ${typeof parsed}: ${trimmed.slice(0, 100)}`);
109
+ }
110
+ return {
111
+ raw: parsed,
112
+ preview: `[json] ${trimmed.slice(0, 180)}${trimmed.length > 180 ? '...' : ''}`,
113
+ };
114
+ }
115
+ // 老路径:裸文本
40
116
  return {
41
117
  raw: { text: trimmed, result: trimmed },
42
118
  preview: trimmed.length > 200 ? `${trimmed.slice(0, 200)}...` : trimmed,
@@ -1,3 +1,4 @@
1
+ // TODO ADR-05: output_schema 暂未在 skill 节点生效,仅 llm 节点支持;workflow 用 vote 时通过 runVoter 直接调 execLlmStep
1
2
  /**
2
3
  * ============================================================
3
4
  * plugins/workflow-runner/src/stepExecutors/skill.ts —— skill step 执行
@@ -52,6 +52,12 @@ export class VarStack {
52
52
  if (this.frames.length > 1)
53
53
  this.frames.pop();
54
54
  }
55
+ /** ADR-05: parallel 用——给子 fork 一份深拷贝栈;子 fork 的 set/push/pop 不污染父栈。 */
56
+ fork() {
57
+ const child = new VarStack();
58
+ child.frames = this.frames.map((f) => ({ ...f }));
59
+ return child;
60
+ }
55
61
  /** 合并所有帧为一个普通对象(外层覆盖内层),用于 vars 持久化与事件 */
56
62
  snapshot() {
57
63
  return Object.assign({}, ...this.frames);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ============================================================
3
+ * voteHelpers.ts —— vote 节点的 voter 调用辅助
4
+ * ------------------------------------------------------------
5
+ * 对抗式审查:N 个 voter 跑同一 prompt + refuted: boolean 反向投票。
6
+ * 策略上倾向 refuted=true(找漏洞),所以阈值算的是"驳回票数"。
7
+ * 详见 ADR-05。
8
+ * ============================================================
9
+ */
10
+ import { execLlmStep } from './stepExecutors/llm.js';
11
+ /** voter 输出 schema(内置,不接受用户自定义) */
12
+ export const VERDICT_SCHEMA = {
13
+ type: 'object',
14
+ additionalProperties: false,
15
+ required: ['refuted', 'reasoning'],
16
+ properties: {
17
+ refuted: { type: 'boolean', description: '是否驳回此 finding' },
18
+ reasoning: { type: 'string', description: '核心论证(≤200 字)' },
19
+ },
20
+ };
21
+ export function buildVoterPrompt(rules, item) {
22
+ const itemStr = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
23
+ return `你是一个对抗式审查者,你的核心任务是积极找出下面这个 finding 的逻辑漏洞、证据不足、或论证它是伪命题。
24
+
25
+ **被审查的 finding**:
26
+ ${itemStr}
27
+
28
+ **审查规则**:
29
+ ${rules}
30
+
31
+ **重要心理学定位**:你应当倾向于 refuted=true。只在你**找不到任何合理的反驳角度**且这个 finding 论证扎实时,才输出 refuted=false。"老好人式赞同"是错误的。
32
+
33
+ 输出 JSON,refuted=true 表示你成功找到反驳理由,false 表示你认为它确实成立。reasoning 字段写明你的核心论证(≤200 字)。`;
34
+ }
35
+ /** 单个 voter 调用。包装 execLlmStep,强制 VERDICT_SCHEMA + 对抗式 prompt。
36
+ * 解析失败时默认 refuted=false(保守保留 item,避免 LLM 失误导致正确 finding 被误删)。 */
37
+ export async function runVoter(rules, item, vars, ctx) {
38
+ const virtualStep = {
39
+ id: 'vote_voter', // 仅 ID,不影响事件流
40
+ llm: buildVoterPrompt(rules, item),
41
+ output_schema: VERDICT_SCHEMA,
42
+ };
43
+ try {
44
+ const result = await execLlmStep(virtualStep, vars, ctx);
45
+ const verdict = extractVerdict(result.raw);
46
+ if (verdict)
47
+ return verdict;
48
+ return { refuted: false, reasoning: '(voter 输出格式异常,保守保留)' };
49
+ }
50
+ catch (e) {
51
+ return {
52
+ refuted: false,
53
+ reasoning: `(voter 调用失败: ${e.message},保守保留)`,
54
+ };
55
+ }
56
+ }
57
+ /**
58
+ * 从 execLlmStep 的 raw 提取 { refuted, reasoning }:
59
+ * 1. raw 直接含字段(B 改完 llm.ts 串通 output_schema 后的形态)
60
+ * 2. raw.text / raw.result 是 JSON 字符串,parse 后含字段(B 未改完时的 fallback)
61
+ * 3. raw.text / raw.result 含 ```json fenced block,剥壳后含字段
62
+ * 4. 其余 → null
63
+ */
64
+ function extractVerdict(raw) {
65
+ if (typeof raw.refuted === 'boolean' &&
66
+ typeof raw.reasoning === 'string') {
67
+ return { refuted: raw.refuted, reasoning: raw.reasoning };
68
+ }
69
+ for (const k of ['text', 'result']) {
70
+ const v = raw[k];
71
+ if (typeof v !== 'string' || v.length === 0)
72
+ continue;
73
+ const parsed = tryParseJsonLike(v);
74
+ if (parsed &&
75
+ typeof parsed === 'object' &&
76
+ typeof parsed.refuted === 'boolean' &&
77
+ typeof parsed.reasoning === 'string') {
78
+ return {
79
+ refuted: parsed.refuted,
80
+ reasoning: parsed.reasoning,
81
+ };
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+ function tryParseJsonLike(s) {
87
+ // 1. 直接 JSON
88
+ try {
89
+ return JSON.parse(s);
90
+ }
91
+ catch {
92
+ // 2. 剥 ```json ... ``` 围栏
93
+ const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/);
94
+ if (fence) {
95
+ try {
96
+ return JSON.parse(fence[1].trim());
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ }
package/src/llm/client.js CHANGED
@@ -34,7 +34,7 @@
34
34
  * ```
35
35
  */
36
36
  export async function* chat(args) {
37
- const { provider, messages, tools, signal } = args;
37
+ const { provider, messages, tools, signal, responseFormat } = args;
38
38
  // 1. 把 Tool 数组转成 OpenAI 协议的 tools 字段
39
39
  const openaiTools = tools.map((t) => ({
40
40
  type: 'function',
@@ -52,6 +52,8 @@ export async function* chat(args) {
52
52
  stream: true,
53
53
  // tool_choice: 'auto' 是默认值,可不写;某些 provider 必须显式
54
54
  tool_choice: openaiTools.length > 0 ? 'auto' : undefined,
55
+ // ADR-05: structured output;缺省 undefined 不出现在 body(JSON.stringify 自动剥离)
56
+ response_format: responseFormat,
55
57
  };
56
58
  // 3. 发请求(fetch 在 Bun/Node 20+ 内置)
57
59
  const url = `${provider.baseURL.replace(/\/$/, '')}/chat/completions`;
@@ -19,7 +19,7 @@ import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
19
19
  import { toToolParameters } from '../../utils/zodToJson.js';
20
20
  import { resolveToolPath, findActualString, preserveQuoteStyle, detectLineEndingsForString, applyLineEnding, countOccurrences, splitReplaceAll, } from '../shared/fileUtils.js';
21
21
  import { filePathField } from '../shared/schemas.js';
22
- import { assertFresh, recordRead } from '../shared/fileState.js';
22
+ import { assertFresh, recordRead, wasTruncated } from '../shared/fileState.js';
23
23
  /** 编辑文件大小上限:1GB。防止模型试图 Edit 超大文件撑爆内存。 */
24
24
  const MAX_EDIT_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
25
25
  // ---------------- 1. Zod 输入 schema ----------------
@@ -122,19 +122,22 @@ async function call(input) {
122
122
  }
123
123
  // splitCount = 出现次数
124
124
  const occurrences = countOccurrences(original, searchTarget);
125
+ const truncHint = wasTruncated(filePath)
126
+ ? `\n\n⚠️ 上次 Read 该文件时被截断了(只看到部分内容)。当前 old_string 可能在截断范围外。请用更小的 limit + offset 精确读取目标行号附近后再 Edit。`
127
+ : '';
125
128
  if (occurrences === 0) {
126
129
  // 模糊匹配提示:找找有没有"接近"的内容
127
130
  const hint = findFuzzyMatchHint(original, searchTarget);
128
131
  const extraMsg = hint ? `\n\n💡 提示:${hint}` : '';
129
132
  return {
130
133
  ok: false,
131
- error: `在 ${filePath} 中找不到 old_string。请先用 Read 工具核对内容(注意空格/缩进/换行)。${extraMsg}`,
134
+ error: `在 ${filePath} 中找不到 old_string。请先用 Read 工具核对内容(注意空格/缩进/换行)。${extraMsg}${truncHint}`,
132
135
  };
133
136
  }
134
137
  if (occurrences > 1 && !replaceAll) {
135
138
  return {
136
139
  ok: false,
137
- error: `old_string 在文件中出现了 ${occurrences} 次,不唯一。\n请扩大 old_string 包含更多上下文,或显式传 replace_all=true。`,
140
+ error: `old_string 在文件中出现了 ${occurrences} 次,不唯一。\n请扩大 old_string 包含更多上下文,或显式传 replace_all=true。${truncHint}`,
138
141
  };
139
142
  }
140
143
  const replaced = replaceAll
@@ -20,7 +20,7 @@ import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
20
20
  import { toToolParameters } from '../../utils/zodToJson.js';
21
21
  import { resolveToolPath, detectFileLineEndings, applyLineEnding, findActualString, preserveQuoteStyle, countOccurrences, splitReplaceAll, } from '../shared/fileUtils.js';
22
22
  import { filePathField } from '../shared/schemas.js';
23
- import { assertFresh, recordRead } from '../shared/fileState.js';
23
+ import { assertFresh, recordRead, wasTruncated } from '../shared/fileState.js';
24
24
  // ---------------- 1. Zod 输入 schema ----------------
25
25
  const editItemSchema = z.object({
26
26
  old_string: z.string().min(1).describe('要替换的原文本(不允许为空 —— 创建新文件请用 Edit 工具)'),
@@ -93,6 +93,9 @@ async function call(input) {
93
93
  }
94
94
  // 4f. 在内存中顺序应用所有 edit(任一失败立即返回,磁盘不动)
95
95
  let currentContent = originalContent;
96
+ const truncHint = wasTruncated(filePath)
97
+ ? `\n\n⚠️ 上次 Read 该文件时被截断了(只看到部分内容)。当前 old_string 可能在截断范围外。请用更小的 limit + offset 精确读取目标行号附近后再 MultiEdit。`
98
+ : '';
96
99
  for (let i = 0; i < edits.length; i++) {
97
100
  const edit = edits[i];
98
101
  const replaceAll = edit.replace_all ?? false;
@@ -103,7 +106,7 @@ async function call(input) {
103
106
  if (actualOld === null) {
104
107
  return {
105
108
  ok: false,
106
- error: `edits[${i}] 未匹配到 old_string(在已应用前序 ${i} 处修改后的内容中找不到)。请先用 Read 工具核对当前内容(注意空格/缩进/换行),并检查 edit 顺序。`,
109
+ error: `edits[${i}] 未匹配到 old_string(在已应用前序 ${i} 处修改后的内容中找不到)。请先用 Read 工具核对当前内容(注意空格/缩进/换行),并检查 edit 顺序。${truncHint}`,
107
110
  };
108
111
  }
109
112
  if (actualOld !== edit.old_string) {
@@ -114,13 +117,13 @@ async function call(input) {
114
117
  if (occurrences === 0) {
115
118
  return {
116
119
  ok: false,
117
- error: `edits[${i}] 未匹配到 old_string(已应用前序 ${i} 处修改后内容中出现 0 次)。`,
120
+ error: `edits[${i}] 未匹配到 old_string(已应用前序 ${i} 处修改后内容中出现 0 次)。${truncHint}`,
118
121
  };
119
122
  }
120
123
  if (occurrences > 1 && !replaceAll) {
121
124
  return {
122
125
  ok: false,
123
- error: `edits[${i}].old_string 在当前内容中出现 ${occurrences} 次,不唯一。请扩大 old_string 包含更多上下文,或显式传 replace_all=true。`,
126
+ error: `edits[${i}].old_string 在当前内容中出现 ${occurrences} 次,不唯一。请扩大 old_string 包含更多上下文,或显式传 replace_all=true。${truncHint}`,
124
127
  };
125
128
  }
126
129
  currentContent = replaceAll
@@ -127,10 +127,13 @@ async function call(input) {
127
127
  }
128
128
  // 4f. 大小兜底 + 自动分段提示
129
129
  let content = numbered;
130
+ let truncated = false;
130
131
  if (content.length > DEFAULT_MAX_RESULT_SIZE_CHARS) {
131
132
  content =
132
133
  content.slice(0, DEFAULT_MAX_RESULT_SIZE_CHARS) +
133
- `\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)`;
134
+ `\n\n... (输出超过 ${DEFAULT_MAX_RESULT_SIZE_CHARS} 字符,已截断)` +
135
+ `\n\n⚠️ **重要:本次返回的是截断内容**。直接基于这部分内容写 Edit/Write 会失败 —— old_string 可能在截断范围外,Write 会丢失截断范围外的内容。必须做以下之一:(1) 用更小的 limit + 精确的 offset 缩小读取范围;(2) 已知改动位置时直接 offset 到目标行号附近;(3) 整文件覆盖请用多次 Edit 而非 Write。`;
136
+ truncated = true;
134
137
  }
135
138
  const startIdx = Math.max(0, offset - 1);
136
139
  const endIdx = Math.min(totalLines, startIdx + limit);
@@ -142,7 +145,7 @@ async function call(input) {
142
145
  if (st.size > 100 * 1024 && offset === 1) {
143
146
  content += `\n\n⚠️ 注意:这是一个大文件(${(st.size / 1024).toFixed(1)} KB)。建议用 offset/limit 分段读取,例如先读关键部分(imports、exports、函数签名)。`;
144
147
  }
145
- recordRead(filePath);
148
+ recordRead(filePath, { truncated });
146
149
  return { ok: true, content };
147
150
  }
148
151
  async function readSmallFile(filePath, offset, limit) {
@@ -16,15 +16,23 @@
16
16
  */
17
17
  import { existsSync, statSync } from 'node:fs';
18
18
  const fileState = new Map();
19
- export function recordRead(absPath) {
19
+ export function recordRead(absPath, opts) {
20
20
  try {
21
21
  const st = statSync(absPath);
22
- fileState.set(absPath, { timestamp: st.mtimeMs, size: st.size });
22
+ fileState.set(absPath, {
23
+ timestamp: st.mtimeMs,
24
+ size: st.size,
25
+ truncated: opts?.truncated ?? false,
26
+ });
23
27
  }
24
28
  catch {
25
29
  // 文件不存在或无权访问 —— 不记录,让 Read 自己处理错误
26
30
  }
27
31
  }
32
+ /** 查询上次 Read 是否被截断。供 Edit/MultiEdit 给出更精准 error hint、Write 做 hard guard 用。 */
33
+ export function wasTruncated(absPath) {
34
+ return fileState.get(absPath)?.truncated ?? false;
35
+ }
28
36
  export function assertFresh(absPath) {
29
37
  const entry = fileState.get(absPath);
30
38
  if (!entry) {
@@ -20,7 +20,7 @@ import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
20
20
  import { toToolParameters } from '../../utils/zodToJson.js';
21
21
  import { applyLineEnding, detectFileBomEncoding, detectFileLineEndings, resolveToolPath, } from '../shared/fileUtils.js';
22
22
  import { filePathField } from '../shared/schemas.js';
23
- import { assertFresh, recordRead } from '../shared/fileState.js';
23
+ import { assertFresh, recordRead, wasTruncated } from '../shared/fileState.js';
24
24
  /** 写入内容上限:1GB。防止模型试图写入超大内容撑爆内存/磁盘。 */
25
25
  const MAX_WRITE_SIZE_BYTES = 1024 * 1024 * 1024;
26
26
  // ---------------- 1. Zod 输入 schema ----------------
@@ -50,6 +50,14 @@ async function call(input) {
50
50
  if (!freshness.ok) {
51
51
  return { ok: false, error: freshness.error };
52
52
  }
53
+ // Ranged-read hard guard:上次 Read 被截断时禁止 Write 覆盖。
54
+ // Write 无脑覆盖整文件,部分 Read 后 Write 会真的把截断范围外的内容全删掉。
55
+ if (existsSync(filePath) && wasTruncated(filePath)) {
56
+ return {
57
+ ok: false,
58
+ error: `${filePath} 上次 Read 时被截断了(看到的是部分内容)。Write 会用 content 覆盖整个文件 —— 直接 Write 会丢失截断范围之外的内容!必须先用更小的 limit + 多次 offset Read 到末尾确认看完整后再 Write,或改用 Edit 工具精确替换某段内容(Edit 不需要整文件视野)。`,
59
+ };
60
+ }
53
61
  const contentSize = Buffer.byteLength(input.content, 'utf8');
54
62
  if (contentSize > MAX_WRITE_SIZE_BYTES) {
55
63
  return {
@@ -41,7 +41,10 @@
41
41
  "tool": { "type": "string" },
42
42
  "skill": { "type": "string" },
43
43
  "llm": { "type": "string" },
44
- "type": { "enum": ["assert", "branch", "loop", "pause"] },
44
+ "type": {
45
+ "enum": ["assert", "branch", "loop", "pause", "parallel", "vote"],
46
+ "description": "控制流节点类型。⚠ ADR-05 新增 'parallel' 与 'vote',详见 docs/05-advanced-workflow-integration.md。"
47
+ },
45
48
  "args": { "type": "object" },
46
49
  "input": { "type": "string" },
47
50
  "capture": {
@@ -67,7 +70,48 @@
67
70
  "type": "array",
68
71
  "items": { "$ref": "#/definitions/step" }
69
72
  },
70
- "prompt": { "type": "string" }
73
+ "prompt": { "type": "string" },
74
+ "output_schema": {
75
+ "type": "object",
76
+ "description": "⚠ ADR-05 新增:LLM 输出 JSON Schema 强约束,走 provider-native structured output。仅 llm/skill 节点生效;详见 docs/05-advanced-workflow-integration.md。"
77
+ },
78
+ "fork": {
79
+ "type": "array",
80
+ "items": { "$ref": "#/definitions/step" },
81
+ "description": "⚠ ADR-05 新增:type=parallel 时的 fork 子步骤数组,Promise.all 并发执行。详见 docs/05-advanced-workflow-integration.md。"
82
+ },
83
+ "voters_count": {
84
+ "type": "integer",
85
+ "minimum": 2,
86
+ "maximum": 10,
87
+ "description": "⚠ ADR-05 新增:type=vote 的 voter 数量(2~10)。详见 docs/05-advanced-workflow-integration.md。"
88
+ },
89
+ "rules": {
90
+ "type": "string",
91
+ "description": "⚠ ADR-05 新增:type=vote 给 voter 的审查指令。详见 docs/05-advanced-workflow-integration.md。"
92
+ },
93
+ "threshold": {
94
+ "type": "integer",
95
+ "minimum": 1,
96
+ "description": "⚠ ADR-05 新增:type=vote 的多数门阈值,默认 ceil(voters_count/2)。详见 docs/05-advanced-workflow-integration.md。"
97
+ },
98
+ "context_files": {
99
+ "type": "array",
100
+ "items": {
101
+ "type": "object",
102
+ "required": ["path"],
103
+ "properties": {
104
+ "path": { "type": "string", "minLength": 1 },
105
+ "hint": { "type": "string" }
106
+ }
107
+ },
108
+ "description": "⚠ ADR-05 新增:llm/skill 节点声明可用的上下文文件清单(path + hint),runner 拼进 prompt 并开放 Read 子循环,不 inline 内容。详见 docs/05-advanced-workflow-integration.md。"
109
+ },
110
+ "allowed_tools": {
111
+ "type": "array",
112
+ "items": { "type": "string", "minLength": 1 },
113
+ "description": "⚠ ADR-05 新增:与 context_files 配对的 mini ReAct 工具白名单,缺省 ['Read']。详见 docs/05-advanced-workflow-integration.md。"
114
+ }
71
115
  }
72
116
  }
73
117
  }