minimal-agent 0.12.0 → 0.13.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 +3 -2
- package/plugins/workflow-runner/src/expressions.js +29 -8
- package/plugins/workflow-runner/src/expressions.ts +28 -10
- package/plugins/workflow-runner/src/index.js +45 -17
- package/plugins/workflow-runner/src/index.ts +44 -18
- package/plugins/workflow-runner/src/recovery.js +9 -2
- package/plugins/workflow-runner/src/recovery.ts +9 -2
- package/plugins/workflow-runner/src/runner.js +31 -5
- package/plugins/workflow-runner/src/runner.ts +33 -5
- package/plugins/workflow-runner/src/stepExecutors/assert.js +2 -1
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +3 -1
- package/plugins/workflow-runner/src/stepExecutors/tool.js +29 -10
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +29 -11
- package/plugins/workflow-runner/src/stepExecutors/verify.js +6 -3
- package/plugins/workflow-runner/src/stepExecutors/verify.ts +7 -3
- package/plugins/workflow-runner/src/taskIdentity.js +197 -21
- package/plugins/workflow-runner/src/taskIdentity.ts +201 -20
- package/plugins/workflow-runner/src/types.js +113 -7
- package/plugins/workflow-runner/src/types.ts +111 -7
- package/plugins/workflow-runner/src/workflowState.js +455 -54
- package/plugins/workflow-runner/src/workflowState.ts +508 -63
- package/plugins/workflow-runner/test/cli.e2e.test.ts +20 -0
- package/plugins/workflow-runner/test/e2eNewLayout.test.ts +314 -0
- package/plugins/workflow-runner/test/engineRobustness.test.ts +9 -1
- package/plugins/workflow-runner/test/expressions.test.ts +42 -0
- package/plugins/workflow-runner/test/illustrate.load.test.ts +2 -2
- package/plugins/workflow-runner/test/parseWorkflowArgs.test.ts +19 -0
- package/plugins/workflow-runner/test/ppt.load.test.ts +15 -15
- package/plugins/workflow-runner/test/ralphLoop.e2e.test.ts +3 -2
- package/plugins/workflow-runner/test/research.load.test.ts +328 -0
- package/plugins/workflow-runner/test/runnerPreflight.test.ts +68 -11
- package/plugins/workflow-runner/test/stateLifecycle.test.ts +60 -12
- package/plugins/workflow-runner/test/taskArbitration.test.ts +19 -3
- package/plugins/workflow-runner/test/taskLifecycle.test.ts +61 -7
- package/plugins/workflow-runner/test/webppt.load.test.ts +4 -4
- package/plugins/workflow-runner/test/word.load.test.ts +9 -9
- package/skills/skill-creator/scripts/run_eval.py +3 -2
- package/src/cli/print.js +17 -0
- package/src/cli/print.ts +25 -0
- package/src/config/configService.js +24 -0
- package/src/config/configService.ts +23 -0
- package/src/infra/verification.js +25 -18
- package/src/infra/verification.ts +50 -18
- package/src/plugins/pluginRunner.js +18 -7
- package/src/plugins/pluginRunner.ts +20 -6
- package/src/prompts/policyRegistry.js +28 -1
- package/src/prompts/policyRegistry.ts +28 -1
- package/src/tools/advisor/advisor.js +76 -9
- package/src/tools/advisor/advisor.ts +79 -9
- package/src/tools/bash/bash.js +51 -6
- package/src/tools/bash/bash.ts +56 -7
- package/src/tools/powershell/powershell.js +18 -1
- package/src/tools/powershell/powershell.ts +18 -2
- package/src/tools/read/read.js +9 -4
- package/src/tools/read/read.ts +9 -4
- package/src/tools/shared/permanentDeleteGuard.js +34 -3
- package/src/tools/shared/permanentDeleteGuard.ts +43 -6
- package/src/tools/webbrowser/browserControl.js +5 -1
- package/src/tools/webbrowser/browserControl.ts +7 -1
- package/src/tools/webbrowser/webbrowser.js +8 -4
- package/src/tools/webbrowser/webbrowser.ts +8 -4
- package/src/types.ts +10 -4
- package/workflows/adaptive-research.yaml +5 -5
- package/workflows/book-review-short.yaml +5 -5
- package/workflows/e2e-write-greet.yaml +2 -2
- package/workflows/illustrate.yaml +12 -12
- package/workflows/plan.yaml +18 -18
- package/workflows/ppt.yaml +201 -201
- package/workflows/ralph-loop.yaml +3 -1
- package/workflows/research.md +449 -0
- package/workflows/research.yaml +873 -0
- package/workflows/webppt.yaml +47 -47
- package/workflows/word.yaml +80 -80
- package/workflows/youtube-shorts.yaml +25 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minimal-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Bun-first 的本地 Agent 内核,支持工具、插件、工作流、上下文压缩、长期记忆与 OpenAI 兼容接口;Tauri 桌面宿主和 Node.js npm 发布可复用同一内核",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Bill Wang <leiwang0359@gmail.com>",
|
|
@@ -131,11 +131,12 @@
|
|
|
131
131
|
"prepublishOnly": "bun run clean && bun run build"
|
|
132
132
|
},
|
|
133
133
|
"dependencies": {
|
|
134
|
+
"ccxt": "^4.5.58",
|
|
134
135
|
"diff": "^8.0.2",
|
|
135
136
|
"fast-glob": "^3.3.2",
|
|
136
137
|
"ink": "^5.0.1",
|
|
137
138
|
"marked": "^18.0.6",
|
|
138
|
-
"pdfjs-dist": "
|
|
139
|
+
"pdfjs-dist": "^6.2.108",
|
|
139
140
|
"react": "^18.3.1",
|
|
140
141
|
"turndown": "^7.2.4",
|
|
141
142
|
"vscode-jsonrpc": "^8.2.1",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { existsSync, readFileSync } from 'node:fs';
|
|
41
41
|
import { join, isAbsolute } from 'node:path';
|
|
42
42
|
import { getWorkingDir } from '../../../src/plugin-sdk.js';
|
|
43
|
+
import { DANGEROUS_KEYS, isAbsWithinCwd } from './types.js';
|
|
43
44
|
const PUNCT2 = new Set(['==', '!=', '>=', '<=', '&&', '||']);
|
|
44
45
|
const PUNCT1 = new Set(['(', ')', '[', ']', ',', '.', '!', '>', '<', '+', '?', ':']);
|
|
45
46
|
function tokenize(src) {
|
|
@@ -143,12 +144,25 @@ function isPunct(c, v) {
|
|
|
143
144
|
const t = peek(c);
|
|
144
145
|
return !!t && t.kind === 'punct' && t.value === v;
|
|
145
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* 读磁盘的内置函数(fileExists / readJson)的锚点。
|
|
149
|
+
* 必须和写入侧同源:进了 session worktree 后产物落在 worktree 里,
|
|
150
|
+
* 这里若仍锚原 workspace,done_when 会在空目录里找文件而恒判 false。
|
|
151
|
+
*/
|
|
152
|
+
function cwdOf(vars) {
|
|
153
|
+
return vars.resolveCwd?.() ?? getWorkingDir();
|
|
154
|
+
}
|
|
146
155
|
const BUILTINS = {
|
|
147
|
-
fileExists(args) {
|
|
156
|
+
fileExists(args, cwd) {
|
|
148
157
|
const p = String(args[0] ?? '');
|
|
149
158
|
if (!p)
|
|
150
159
|
return false;
|
|
151
|
-
const abs = isAbsolute(p) ? p : join(
|
|
160
|
+
const abs = isAbsolute(p) ? p : join(cwd, p);
|
|
161
|
+
// 安全守卫:越出 cwd 的路径视为"不存在"(fail-closed,与 readJson 同策略)。
|
|
162
|
+
// 不可信 YAML(模板库/他人分享)可能带 fileExists('/etc/passwd') 做信息刺探,
|
|
163
|
+
// 这里与 cleanupDeclaredArtifacts 保持一致:只允许操作 cwd 下的文件。
|
|
164
|
+
if (!isAbsWithinCwd(cwd, abs))
|
|
165
|
+
return false;
|
|
152
166
|
return existsSync(abs);
|
|
153
167
|
},
|
|
154
168
|
length(args) {
|
|
@@ -247,16 +261,19 @@ const BUILTINS = {
|
|
|
247
261
|
return 'null';
|
|
248
262
|
}
|
|
249
263
|
},
|
|
250
|
-
// 读磁盘 JSON 文件并取值:path
|
|
251
|
-
//
|
|
264
|
+
// 读磁盘 JSON 文件并取值:path 相对当前有效 cwd(绝对路径先经 cwd 安全校验,
|
|
265
|
+
// 越界直接 null,与 fileExists 同 fail-closed 策略);给了 key 则返回顶层
|
|
266
|
+
// obj[key],否则返回整值。缺文件 / 坏 JSON / 键不存在 / 越界 → null
|
|
252
267
|
//(与 match/extract 同「不抛」哲学)。动机:让确定性脚本产出的 manifest
|
|
253
268
|
//(如 source_assets.json)直接驱动工作流 for-each / branch —— 填补「磁盘
|
|
254
269
|
// 结构化数据 → 控制流」的空白(与 fileExists 平级的基础能力)。
|
|
255
|
-
readJson(args) {
|
|
270
|
+
readJson(args, cwd) {
|
|
256
271
|
const p = String(args[0] ?? '');
|
|
257
272
|
if (!p)
|
|
258
273
|
return null;
|
|
259
|
-
const abs = isAbsolute(p) ? p : join(
|
|
274
|
+
const abs = isAbsolute(p) ? p : join(cwd, p);
|
|
275
|
+
if (!isAbsWithinCwd(cwd, abs))
|
|
276
|
+
return null; // 越界 → null(不抛,fail-closed)
|
|
260
277
|
try {
|
|
261
278
|
// 剥前导 BOM:某些工具/重定向写出的 JSON 带 BOM,JSON.parse 会因此抛错。
|
|
262
279
|
let raw = readFileSync(abs, 'utf8');
|
|
@@ -439,7 +456,7 @@ function parsePrimary(c, vars) {
|
|
|
439
456
|
const fn = BUILTINS[t.value];
|
|
440
457
|
if (!fn)
|
|
441
458
|
throw new Error(`未知函数 "${t.value}"。白名单:${Object.keys(BUILTINS).join(', ')}`);
|
|
442
|
-
return fn(argv);
|
|
459
|
+
return fn(argv, cwdOf(vars));
|
|
443
460
|
}
|
|
444
461
|
// 字面常量
|
|
445
462
|
if (t.value === 'true')
|
|
@@ -608,8 +625,12 @@ export function interpolateDeep(value, vars, opts) {
|
|
|
608
625
|
if (value && typeof value === 'object') {
|
|
609
626
|
const out = {};
|
|
610
627
|
for (const [k, v] of Object.entries(value)) {
|
|
611
|
-
// 注意:key 也插值(loop 里 `hook_${idx}` 这种),但只对字符串 key
|
|
628
|
+
// 注意:key 也插值(loop 里 `hook_${idx}` 这种),但只对字符串 key 有意义;
|
|
629
|
+
// 插值后的 key 若命中危险键(__proto__/constructor/prototype)→ 直接跳过不写,
|
|
630
|
+
// 防止经动态 key 注入原型链属性(与 VarStack 防线保持一致)。
|
|
612
631
|
const newKey = interpolate(k, vars, opts);
|
|
632
|
+
if (DANGEROUS_KEYS.has(newKey))
|
|
633
|
+
continue;
|
|
613
634
|
out[newKey] = interpolateDeep(v, vars, opts);
|
|
614
635
|
}
|
|
615
636
|
return out;
|
|
@@ -42,7 +42,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
42
42
|
import { join, isAbsolute } from 'node:path';
|
|
43
43
|
|
|
44
44
|
import { getWorkingDir } from '../../../src/plugin-sdk.js';
|
|
45
|
-
import type
|
|
45
|
+
import { DANGEROUS_KEYS, isAbsWithinCwd, type VarStack } from './types.js';
|
|
46
46
|
|
|
47
47
|
// ---------------- Tokenizer ----------------
|
|
48
48
|
|
|
@@ -160,11 +160,24 @@ function isPunct(c: Cursor, v: string): boolean {
|
|
|
160
160
|
return !!t && t.kind === 'punct' && t.value === v;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
/**
|
|
164
|
+
* 读磁盘的内置函数(fileExists / readJson)的锚点。
|
|
165
|
+
* 必须和写入侧同源:进了 session worktree 后产物落在 worktree 里,
|
|
166
|
+
* 这里若仍锚原 workspace,done_when 会在空目录里找文件而恒判 false。
|
|
167
|
+
*/
|
|
168
|
+
function cwdOf(vars: VarStack): string {
|
|
169
|
+
return vars.resolveCwd?.() ?? getWorkingDir();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const BUILTINS: Record<string, (args: unknown[], cwd: string) => unknown> = {
|
|
173
|
+
fileExists(args, cwd) {
|
|
165
174
|
const p = String(args[0] ?? '');
|
|
166
175
|
if (!p) return false;
|
|
167
|
-
const abs = isAbsolute(p) ? p : join(
|
|
176
|
+
const abs = isAbsolute(p) ? p : join(cwd, p);
|
|
177
|
+
// 安全守卫:越出 cwd 的路径视为"不存在"(fail-closed,与 readJson 同策略)。
|
|
178
|
+
// 不可信 YAML(模板库/他人分享)可能带 fileExists('/etc/passwd') 做信息刺探,
|
|
179
|
+
// 这里与 cleanupDeclaredArtifacts 保持一致:只允许操作 cwd 下的文件。
|
|
180
|
+
if (!isAbsWithinCwd(cwd, abs)) return false;
|
|
168
181
|
return existsSync(abs);
|
|
169
182
|
},
|
|
170
183
|
length(args) {
|
|
@@ -254,15 +267,17 @@ const BUILTINS: Record<string, (args: unknown[]) => unknown> = {
|
|
|
254
267
|
return 'null';
|
|
255
268
|
}
|
|
256
269
|
},
|
|
257
|
-
// 读磁盘 JSON 文件并取值:path
|
|
258
|
-
//
|
|
270
|
+
// 读磁盘 JSON 文件并取值:path 相对当前有效 cwd(绝对路径先经 cwd 安全校验,
|
|
271
|
+
// 越界直接 null,与 fileExists 同 fail-closed 策略);给了 key 则返回顶层
|
|
272
|
+
// obj[key],否则返回整值。缺文件 / 坏 JSON / 键不存在 / 越界 → null
|
|
259
273
|
//(与 match/extract 同「不抛」哲学)。动机:让确定性脚本产出的 manifest
|
|
260
274
|
//(如 source_assets.json)直接驱动工作流 for-each / branch —— 填补「磁盘
|
|
261
275
|
// 结构化数据 → 控制流」的空白(与 fileExists 平级的基础能力)。
|
|
262
|
-
readJson(args) {
|
|
276
|
+
readJson(args, cwd) {
|
|
263
277
|
const p = String(args[0] ?? '');
|
|
264
278
|
if (!p) return null;
|
|
265
|
-
const abs = isAbsolute(p) ? p : join(
|
|
279
|
+
const abs = isAbsolute(p) ? p : join(cwd, p);
|
|
280
|
+
if (!isAbsWithinCwd(cwd, abs)) return null; // 越界 → null(不抛,fail-closed)
|
|
266
281
|
try {
|
|
267
282
|
// 剥前导 BOM:某些工具/重定向写出的 JSON 带 BOM,JSON.parse 会因此抛错。
|
|
268
283
|
let raw = readFileSync(abs, 'utf8');
|
|
@@ -436,7 +451,7 @@ function parsePrimary(c: Cursor, vars: VarStack): unknown {
|
|
|
436
451
|
eat(c);
|
|
437
452
|
const fn = BUILTINS[t.value];
|
|
438
453
|
if (!fn) throw new Error(`未知函数 "${t.value}"。白名单:${Object.keys(BUILTINS).join(', ')}`);
|
|
439
|
-
return fn(argv);
|
|
454
|
+
return fn(argv, cwdOf(vars));
|
|
440
455
|
}
|
|
441
456
|
// 字面常量
|
|
442
457
|
if (t.value === 'true') return true;
|
|
@@ -612,8 +627,11 @@ export function interpolateDeep<T>(
|
|
|
612
627
|
if (value && typeof value === 'object') {
|
|
613
628
|
const out: Record<string, unknown> = {};
|
|
614
629
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
615
|
-
// 注意:key 也插值(loop 里 `hook_${idx}` 这种),但只对字符串 key
|
|
630
|
+
// 注意:key 也插值(loop 里 `hook_${idx}` 这种),但只对字符串 key 有意义;
|
|
631
|
+
// 插值后的 key 若命中危险键(__proto__/constructor/prototype)→ 直接跳过不写,
|
|
632
|
+
// 防止经动态 key 注入原型链属性(与 VarStack 防线保持一致)。
|
|
616
633
|
const newKey = interpolate(k, vars, opts);
|
|
634
|
+
if (DANGEROUS_KEYS.has(newKey)) continue;
|
|
617
635
|
out[newKey] = interpolateDeep(v, vars, opts);
|
|
618
636
|
}
|
|
619
637
|
return out as unknown as T;
|
|
@@ -53,7 +53,20 @@ export function parseWorkflowArgs(rawArgs) {
|
|
|
53
53
|
}
|
|
54
54
|
return { name, inputs, positional };
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* shell 风格 token 化:空白切分 token;引号(' 或 ")在 token **任意位置**(不只开头)
|
|
58
|
+
* 出现即进入引号模式——引号内的空白不切分、引号字符本身被剥离。
|
|
59
|
+
*
|
|
60
|
+
* 修复:`--input topic="带空格的值"`(引号在 `=` 后,token 开头是 `t`)曾被按空白
|
|
61
|
+
* 硬切 → 值截断 + 引号残留(实测 inputs.topic 带 `"` 尾,positional 里散落被拆的字)。
|
|
62
|
+
* 现在 token 内任意位置的引号都正确分组。
|
|
63
|
+
*
|
|
64
|
+
* 边界语义(被 parseWorkflowArgs.test.ts 冻结):
|
|
65
|
+
* - 未闭合引号:剩余部分并入当前 token(不报错)
|
|
66
|
+
* - 双引号内的单引号是字面量(不触发嵌套)
|
|
67
|
+
* - 反斜杠不转义(`a\"b` 中 `"` 仍开启引号模式,行为变化但测试用 toBeDefined 容错)
|
|
68
|
+
* - 连续空白合并、`""` 产生空 token
|
|
69
|
+
*/
|
|
57
70
|
function tokenizeArgs(s) {
|
|
58
71
|
const out = [];
|
|
59
72
|
let i = 0;
|
|
@@ -62,24 +75,27 @@ function tokenizeArgs(s) {
|
|
|
62
75
|
i++;
|
|
63
76
|
if (i >= s.length)
|
|
64
77
|
break;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
i
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
let acc = '';
|
|
79
|
+
while (i < s.length) {
|
|
80
|
+
const c = s[i];
|
|
81
|
+
if (c === '"' || c === "'") {
|
|
82
|
+
// 引号模式:收集到闭合引号,引号内空白保留
|
|
83
|
+
const q = c;
|
|
84
|
+
i++;
|
|
85
|
+
while (i < s.length && s[i] !== q)
|
|
86
|
+
acc += s[i++];
|
|
87
|
+
if (i < s.length)
|
|
88
|
+
i++; // 跳过闭合引号
|
|
89
|
+
}
|
|
90
|
+
else if (/\s/.test(c)) {
|
|
91
|
+
break;
|
|
71
92
|
}
|
|
72
|
-
|
|
93
|
+
else {
|
|
94
|
+
acc += c;
|
|
73
95
|
i++;
|
|
74
|
-
out.push(acc);
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
let acc = '';
|
|
78
|
-
while (i < s.length && !/\s/.test(s[i])) {
|
|
79
|
-
acc += s[i++];
|
|
80
96
|
}
|
|
81
|
-
out.push(acc);
|
|
82
97
|
}
|
|
98
|
+
out.push(acc);
|
|
83
99
|
}
|
|
84
100
|
return out;
|
|
85
101
|
}
|
|
@@ -167,12 +183,17 @@ export async function* runWorkflowFromCommand(rawArgs, opts) {
|
|
|
167
183
|
// - 默认:严格按"第 i 个位置参数 ↔ def.inputs[i]"对应。
|
|
168
184
|
// --input k=v 显式优先:已显式的 input 不被位置参数覆盖(对应位置参数被丢弃,会 warn)。
|
|
169
185
|
const overriddenByKV = [];
|
|
186
|
+
const greedyNotes = [];
|
|
170
187
|
const greedyFirst = def.inputs?.[0]?.greedy === true;
|
|
171
188
|
if (greedyFirst && def.inputs && positional.length > 0) {
|
|
172
189
|
const greedyName = def.inputs[0].name;
|
|
173
190
|
if (greedyName in rawInputs) {
|
|
174
|
-
//
|
|
175
|
-
|
|
191
|
+
// 首参已由 --input 提供时,位置参数仍按贪婪语义【拼回】首参末尾--
|
|
192
|
+
// 含空格的值未加引号时会被 shell 拆开(--input topic=A B C 只有 A 进 KV,
|
|
193
|
+
// B C 散落成位置参数),静默丢弃曾把整段中文调研主题截成半句、
|
|
194
|
+
// 连锁触发任务仲裁误判 new-task 清空全部产物。
|
|
195
|
+
rawInputs[greedyName] = [rawInputs[greedyName], ...positional].join(' ');
|
|
196
|
+
greedyNotes.push(`${greedyName}:--input 值未加引号被拆成 ${positional.length} 段位置参数,已自动拼回(下次请写 --input "${greedyName}=完整值")`);
|
|
176
197
|
}
|
|
177
198
|
else {
|
|
178
199
|
// 贪婪吸收:整句进首参(空格 join 还原用户原话,不逐位拆映射)
|
|
@@ -202,6 +223,13 @@ export async function* runWorkflowFromCommand(rawArgs, opts) {
|
|
|
202
223
|
message: `⚠ 忽略了 ${dropped.length} 个多余位置参数: ${dropped.map((d) => JSON.stringify(d)).join(' ')}(workflow "${name}" 只声明了 ${def.inputs.length} 个 input)`,
|
|
203
224
|
};
|
|
204
225
|
}
|
|
226
|
+
// warn:贪婪首参的 --input 值被 shell 拆开后自动拼回
|
|
227
|
+
if (greedyNotes.length > 0) {
|
|
228
|
+
yield {
|
|
229
|
+
type: 'warning',
|
|
230
|
+
message: `⚠ ${greedyNotes.join(';')}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
205
233
|
// warn:位置参数被 --input 覆盖
|
|
206
234
|
if (overriddenByKV.length > 0) {
|
|
207
235
|
yield {
|
|
@@ -91,29 +91,43 @@ export function parseWorkflowArgs(rawArgs: string): {
|
|
|
91
91
|
return { name, inputs, positional };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* shell 风格 token 化:空白切分 token;引号(' 或 ")在 token **任意位置**(不只开头)
|
|
96
|
+
* 出现即进入引号模式——引号内的空白不切分、引号字符本身被剥离。
|
|
97
|
+
*
|
|
98
|
+
* 修复:`--input topic="带空格的值"`(引号在 `=` 后,token 开头是 `t`)曾被按空白
|
|
99
|
+
* 硬切 → 值截断 + 引号残留(实测 inputs.topic 带 `"` 尾,positional 里散落被拆的字)。
|
|
100
|
+
* 现在 token 内任意位置的引号都正确分组。
|
|
101
|
+
*
|
|
102
|
+
* 边界语义(被 parseWorkflowArgs.test.ts 冻结):
|
|
103
|
+
* - 未闭合引号:剩余部分并入当前 token(不报错)
|
|
104
|
+
* - 双引号内的单引号是字面量(不触发嵌套)
|
|
105
|
+
* - 反斜杠不转义(`a\"b` 中 `"` 仍开启引号模式,行为变化但测试用 toBeDefined 容错)
|
|
106
|
+
* - 连续空白合并、`""` 产生空 token
|
|
107
|
+
*/
|
|
95
108
|
function tokenizeArgs(s: string): string[] {
|
|
96
109
|
const out: string[] = [];
|
|
97
110
|
let i = 0;
|
|
98
111
|
while (i < s.length) {
|
|
99
112
|
while (i < s.length && /\s/.test(s[i])) i++;
|
|
100
113
|
if (i >= s.length) break;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
i
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
acc +=
|
|
114
|
+
let acc = '';
|
|
115
|
+
while (i < s.length) {
|
|
116
|
+
const c = s[i];
|
|
117
|
+
if (c === '"' || c === "'") {
|
|
118
|
+
// 引号模式:收集到闭合引号,引号内空白保留
|
|
119
|
+
const q = c;
|
|
120
|
+
i++;
|
|
121
|
+
while (i < s.length && s[i] !== q) acc += s[i++];
|
|
122
|
+
if (i < s.length) i++; // 跳过闭合引号
|
|
123
|
+
} else if (/\s/.test(c)) {
|
|
124
|
+
break;
|
|
125
|
+
} else {
|
|
126
|
+
acc += c;
|
|
127
|
+
i++;
|
|
114
128
|
}
|
|
115
|
-
out.push(acc);
|
|
116
129
|
}
|
|
130
|
+
out.push(acc);
|
|
117
131
|
}
|
|
118
132
|
return out;
|
|
119
133
|
}
|
|
@@ -219,13 +233,18 @@ export async function* runWorkflowFromCommand(
|
|
|
219
233
|
// - 默认:严格按"第 i 个位置参数 ↔ def.inputs[i]"对应。
|
|
220
234
|
// --input k=v 显式优先:已显式的 input 不被位置参数覆盖(对应位置参数被丢弃,会 warn)。
|
|
221
235
|
const overriddenByKV: string[] = [];
|
|
236
|
+
const greedyNotes: string[] = [];
|
|
222
237
|
const greedyFirst = def.inputs?.[0]?.greedy === true;
|
|
223
238
|
if (greedyFirst && def.inputs && positional.length > 0) {
|
|
224
239
|
const greedyName = def.inputs[0].name;
|
|
225
240
|
if (greedyName in rawInputs) {
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
241
|
+
// 首参已由 --input 提供时,位置参数仍按贪婪语义【拼回】首参末尾--
|
|
242
|
+
// 含空格的值未加引号时会被 shell 拆开(--input topic=A B C 只有 A 进 KV,
|
|
243
|
+
// B C 散落成位置参数),静默丢弃曾把整段中文调研主题截成半句、
|
|
244
|
+
// 连锁触发任务仲裁误判 new-task 清空全部产物。
|
|
245
|
+
rawInputs[greedyName] = [rawInputs[greedyName], ...positional].join(' ');
|
|
246
|
+
greedyNotes.push(
|
|
247
|
+
`${greedyName}:--input 值未加引号被拆成 ${positional.length} 段位置参数,已自动拼回(下次请写 --input "${greedyName}=完整值")`,
|
|
229
248
|
);
|
|
230
249
|
} else {
|
|
231
250
|
// 贪婪吸收:整句进首参(空格 join 还原用户原话,不逐位拆映射)
|
|
@@ -256,6 +275,13 @@ export async function* runWorkflowFromCommand(
|
|
|
256
275
|
message: `⚠ 忽略了 ${dropped.length} 个多余位置参数: ${dropped.map((d) => JSON.stringify(d)).join(' ')}(workflow "${name}" 只声明了 ${def.inputs.length} 个 input)`,
|
|
257
276
|
};
|
|
258
277
|
}
|
|
278
|
+
// warn:贪婪首参的 --input 值被 shell 拆开后自动拼回
|
|
279
|
+
if (greedyNotes.length > 0) {
|
|
280
|
+
yield {
|
|
281
|
+
type: 'warning',
|
|
282
|
+
message: `⚠ ${greedyNotes.join(';')}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
259
285
|
// warn:位置参数被 --input 覆盖
|
|
260
286
|
if (overriddenByKV.length > 0) {
|
|
261
287
|
yield {
|
|
@@ -81,8 +81,15 @@ export async function* runRecovery(step, vars, ctx, errorMsg) {
|
|
|
81
81
|
: recoverPrompt + schemaHint;
|
|
82
82
|
try {
|
|
83
83
|
// 委托 TAOR 引擎执行(共享消费层 runStepQuery)——事件透传给 UI(显示恢复进度)。
|
|
84
|
-
// 注意剥掉 step 的 allowed_tools
|
|
85
|
-
//
|
|
84
|
+
// 注意剥掉 step 的 allowed_tools 白名单:【allowed_tools 本身不是安全边界,是弱模型聚焦优化】
|
|
85
|
+
// (原步限定一小撮工具给能力弱的模型,避免它瞎试跑遍所有工具)。
|
|
86
|
+
// 恢复 agent 的职责正是「随机应变换路径」,需要它能调用原步没考虑过的替代工具,
|
|
87
|
+
// 所以这里解除聚焦限制。真正的安全边界在**另外两层**,此处没有动:
|
|
88
|
+
// 1. `StepDef.uses: [browser|computer]` —— 宿主能力切片(buildWorkflowToolContext 仍生效),
|
|
89
|
+
// 原步没声明 computer 的话,恢复 agent 也调不通 ComputerUse。
|
|
90
|
+
// 2. `ctx.tools` —— 插件层按 runtime + 宿主白名单裁剪后的有效工具集合,
|
|
91
|
+
// 无论 allowed_tools 写什么都不能超出它。
|
|
92
|
+
// 保持 v0.9 行为(放开 allowed_tools),但明确安全层次不发生变化。
|
|
86
93
|
const { rawText, sawLlmError, sawMaxTurns, llmErrorDetail } = yield* runStepQuery(fullPrompt, { ...step, allowed_tools: undefined }, ctx, { maxTurns: step.maxTurns ?? 25 });
|
|
87
94
|
// 恢复是兜底路径:无论 provider 失败还是模型空答,都按「没救回」返回
|
|
88
95
|
// ok:false(不抛重试——retry 在进入 recover 前已用尽);只是归因更诚实。
|
|
@@ -104,8 +104,15 @@ export async function* runRecovery(
|
|
|
104
104
|
|
|
105
105
|
try {
|
|
106
106
|
// 委托 TAOR 引擎执行(共享消费层 runStepQuery)——事件透传给 UI(显示恢复进度)。
|
|
107
|
-
// 注意剥掉 step 的 allowed_tools
|
|
108
|
-
//
|
|
107
|
+
// 注意剥掉 step 的 allowed_tools 白名单:【allowed_tools 本身不是安全边界,是弱模型聚焦优化】
|
|
108
|
+
// (原步限定一小撮工具给能力弱的模型,避免它瞎试跑遍所有工具)。
|
|
109
|
+
// 恢复 agent 的职责正是「随机应变换路径」,需要它能调用原步没考虑过的替代工具,
|
|
110
|
+
// 所以这里解除聚焦限制。真正的安全边界在**另外两层**,此处没有动:
|
|
111
|
+
// 1. `StepDef.uses: [browser|computer]` —— 宿主能力切片(buildWorkflowToolContext 仍生效),
|
|
112
|
+
// 原步没声明 computer 的话,恢复 agent 也调不通 ComputerUse。
|
|
113
|
+
// 2. `ctx.tools` —— 插件层按 runtime + 宿主白名单裁剪后的有效工具集合,
|
|
114
|
+
// 无论 allowed_tools 写什么都不能超出它。
|
|
115
|
+
// 保持 v0.9 行为(放开 allowed_tools),但明确安全层次不发生变化。
|
|
109
116
|
const { rawText, sawLlmError, sawMaxTurns, llmErrorDetail } =
|
|
110
117
|
yield* runStepQuery(
|
|
111
118
|
fullPrompt,
|
|
@@ -25,7 +25,8 @@ import { existsSync } from 'node:fs';
|
|
|
25
25
|
import { resolve } from 'node:path';
|
|
26
26
|
import { dlog, getWorkingDir } from '../../../src/plugin-sdk.js';
|
|
27
27
|
import { evalExpr, interpolate } from './expressions.js';
|
|
28
|
-
import {
|
|
28
|
+
import { effectiveWorkflowCwd } from './toolRuntime.js';
|
|
29
|
+
import { buildTaskArbitrator, DEFAULT_SESSION_ID, generateDefaultSessionId, isContinuationPhrase, sanitizeWorkflowDirName } from './taskIdentity.js';
|
|
29
30
|
import { execAgentStep } from './stepExecutors/agent.js';
|
|
30
31
|
import { execAssertStep } from './stepExecutors/assert.js';
|
|
31
32
|
import { execVerifyStep } from './stepExecutors/verify.js';
|
|
@@ -153,7 +154,7 @@ async function* dispatchActionStep(step, vars, ctx) {
|
|
|
153
154
|
if (step.type === 'assert')
|
|
154
155
|
return execAssertStep(step, vars, ctx);
|
|
155
156
|
if (step.type === 'verify')
|
|
156
|
-
return await execVerifyStep(step, vars);
|
|
157
|
+
return await execVerifyStep(step, vars, ctx);
|
|
157
158
|
// set / echo 从 runSteps 的内联块搬进执行器体系:它们不影响迭代、只产出 StepResult。
|
|
158
159
|
// 搬过来的直接收益是 `capture:` 自然生效(此前它们从不调 bindCapture —— 静默失效)。
|
|
159
160
|
if (step.type === 'set')
|
|
@@ -340,6 +341,20 @@ function collectDeclaredArtifacts(def) {
|
|
|
340
341
|
export async function* runWorkflow(def, inputs, ctx) {
|
|
341
342
|
const vars = new VarStack();
|
|
342
343
|
vars.setGlobal('inputs', inputs);
|
|
344
|
+
// 让 fileExists / readJson(done_when、when、over 都靠它们)与写入侧锚同一个目录。
|
|
345
|
+
// 传函数而非当前值:worktree 是运行途中才可能进入的,锚点必须每次求值时重算。
|
|
346
|
+
vars.resolveCwd = () => effectiveWorkflowCwd(ctx);
|
|
347
|
+
// Phase 3:session 即任务实例。优先用宿主显式提供的 sessionId(TUI 当前会话 /
|
|
348
|
+
// -p 的 --session);缺失或为缺省 'default' 时,由工作流引擎自动命名一个唯一
|
|
349
|
+
// session(generateDefaultSessionId,见 taskIdentity.ts)——保证每次工作流运行都
|
|
350
|
+
// 有独立目录,多 session 并行互不覆盖;宿主显式指定时仍可复用/续跑该 session。
|
|
351
|
+
// 注入全局变量 `session`(sanitize 后的目录安全名),供 yaml 产物路径用
|
|
352
|
+
// ${session} 占位符(如 out/research/${session}/01-goal.md)。
|
|
353
|
+
const explicitSessionId = ctx.runtimeContext?.sessionId;
|
|
354
|
+
const sessionId = explicitSessionId && explicitSessionId !== DEFAULT_SESSION_ID
|
|
355
|
+
? explicitSessionId
|
|
356
|
+
: generateDefaultSessionId();
|
|
357
|
+
vars.setGlobal('session', sanitizeWorkflowDirName(sessionId));
|
|
343
358
|
// onError:'recover' 用:把工作流总目标(description)透传给恢复 agent,
|
|
344
359
|
// 让它知道「这条工作流到底想干什么」。已有 goal 则不覆盖。
|
|
345
360
|
const runCtx = { ...ctx, goal: ctx.goal ?? def.description };
|
|
@@ -353,13 +368,17 @@ export async function* runWorkflow(def, inputs, ctx) {
|
|
|
353
368
|
runCtx.maxTurnsRetryDelayMs = def.self_heal?.max_turns_retry_delay_ms;
|
|
354
369
|
// 声明产物清单:state.init 用于 new-task 清理/completed 报产物;
|
|
355
370
|
// 挂到 runCtx 供 tool 步覆盖判定(声明产物自动 pre-Read 解 Write 守卫)。
|
|
356
|
-
|
|
371
|
+
// Phase 3:产物路径可能含 ${session} 占位符,收集后按 vars 展开为实际路径
|
|
372
|
+
//(new-task 清理 / completed 报告都以展开后的真实路径为准)。
|
|
373
|
+
const declaredArtifacts = collectDeclaredArtifacts(def)
|
|
374
|
+
.map((a) => interpolate(a, vars))
|
|
375
|
+
.filter((a) => a.length > 0);
|
|
357
376
|
runCtx.declaredArtifacts = declaredArtifacts;
|
|
358
377
|
// 任务身份语义仲裁器(hash 不等 + 旧任务未完成时的二审,见 taskIdentity.ts):
|
|
359
378
|
// 短语快路径 → LLM 单轮仲裁 → AskUserQuestion → 兜底 new-task。
|
|
360
379
|
// init 非 generator,仲裁过程的人话记录收进 notes,init 返回后统一 yield。
|
|
361
380
|
const arbitrationNotes = [];
|
|
362
|
-
const state = new WorkflowState(getWorkingDir(), def.name);
|
|
381
|
+
const state = new WorkflowState(getWorkingDir(), def.name, sessionId);
|
|
363
382
|
const resume = await state.init(def.name, inputs, def.steps, def.self_heal?.backoff, declaredArtifacts, buildTaskArbitrator({
|
|
364
383
|
provider: runCtx.provider,
|
|
365
384
|
chat: runCtx.chat,
|
|
@@ -396,9 +415,16 @@ export async function* runWorkflow(def, inputs, ctx) {
|
|
|
396
415
|
message: '♻ 工作流定义已变更(步骤结构与旧断点不再匹配),旧执行现场已作废,本次从头开始执行。',
|
|
397
416
|
};
|
|
398
417
|
}
|
|
399
|
-
// fresh/new-task
|
|
418
|
+
// fresh/new-task 启动清理可见化:全新任务开始前清理的残留产物(上次任务
|
|
400
419
|
// 的声明产物 + /new 后无主孤儿)。用户约定:全新 plan 一律从干净状态开始;
|
|
401
420
|
// 删除范围仅限本工作流声明的产物,同任务 resume 不清理。
|
|
421
|
+
// 清理一律先归档到 .trash 回收目录再删残留:仲裁误判 new-task 时成果可找回。
|
|
422
|
+
if (resume.archivedTo) {
|
|
423
|
+
yield {
|
|
424
|
+
type: 'warning',
|
|
425
|
+
message: `全新任务:上次残留的旧产物已归档到 ${resume.archivedTo}/(确认不需要后可删除该目录),从干净状态开始。`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
402
428
|
if (resume.cleanedArtifacts?.length) {
|
|
403
429
|
yield {
|
|
404
430
|
type: 'warning',
|
|
@@ -28,7 +28,8 @@ import { dlog, executeTool, getWorkingDir, type LoopEvent } from '../../../src/p
|
|
|
28
28
|
import { isMaxTurnsCutoff, truncate } from './agentCommon.js';
|
|
29
29
|
import { evalExpr, interpolate, interpolateDeep, interpolateTyped } from './expressions.js';
|
|
30
30
|
import { runRecovery } from './recovery.js';
|
|
31
|
-
import {
|
|
31
|
+
import { effectiveWorkflowCwd } from './toolRuntime.js';
|
|
32
|
+
import { buildTaskArbitrator, DEFAULT_SESSION_ID, generateDefaultSessionId, isContinuationPhrase, sanitizeWorkflowDirName } from './taskIdentity.js';
|
|
32
33
|
import { execAgentStep } from './stepExecutors/agent.js';
|
|
33
34
|
import { execAssertStep } from './stepExecutors/assert.js';
|
|
34
35
|
import { execVerifyStep } from './stepExecutors/verify.js';
|
|
@@ -206,7 +207,7 @@ async function* dispatchActionStep(
|
|
|
206
207
|
ctx: RunContext,
|
|
207
208
|
): AsyncGenerator<LoopEvent, StepResult, void> {
|
|
208
209
|
if (step.type === 'assert') return execAssertStep(step, vars, ctx);
|
|
209
|
-
if (step.type === 'verify') return await execVerifyStep(step, vars);
|
|
210
|
+
if (step.type === 'verify') return await execVerifyStep(step, vars, ctx);
|
|
210
211
|
// set / echo 从 runSteps 的内联块搬进执行器体系:它们不影响迭代、只产出 StepResult。
|
|
211
212
|
// 搬过来的直接收益是 `capture:` 自然生效(此前它们从不调 bindCapture —— 静默失效)。
|
|
212
213
|
if (step.type === 'set') return yield* execSetStep(step, vars);
|
|
@@ -395,6 +396,22 @@ export async function* runWorkflow(
|
|
|
395
396
|
): AsyncGenerator<WorkflowEvent | LoopEvent, void, void> {
|
|
396
397
|
const vars = new VarStack();
|
|
397
398
|
vars.setGlobal('inputs', inputs);
|
|
399
|
+
// 让 fileExists / readJson(done_when、when、over 都靠它们)与写入侧锚同一个目录。
|
|
400
|
+
// 传函数而非当前值:worktree 是运行途中才可能进入的,锚点必须每次求值时重算。
|
|
401
|
+
vars.resolveCwd = () => effectiveWorkflowCwd(ctx);
|
|
402
|
+
|
|
403
|
+
// Phase 3:session 即任务实例。优先用宿主显式提供的 sessionId(TUI 当前会话 /
|
|
404
|
+
// -p 的 --session);缺失或为缺省 'default' 时,由工作流引擎自动命名一个唯一
|
|
405
|
+
// session(generateDefaultSessionId,见 taskIdentity.ts)——保证每次工作流运行都
|
|
406
|
+
// 有独立目录,多 session 并行互不覆盖;宿主显式指定时仍可复用/续跑该 session。
|
|
407
|
+
// 注入全局变量 `session`(sanitize 后的目录安全名),供 yaml 产物路径用
|
|
408
|
+
// ${session} 占位符(如 out/research/${session}/01-goal.md)。
|
|
409
|
+
const explicitSessionId = ctx.runtimeContext?.sessionId;
|
|
410
|
+
const sessionId =
|
|
411
|
+
explicitSessionId && explicitSessionId !== DEFAULT_SESSION_ID
|
|
412
|
+
? explicitSessionId
|
|
413
|
+
: generateDefaultSessionId();
|
|
414
|
+
vars.setGlobal('session', sanitizeWorkflowDirName(sessionId));
|
|
398
415
|
|
|
399
416
|
// onError:'recover' 用:把工作流总目标(description)透传给恢复 agent,
|
|
400
417
|
// 让它知道「这条工作流到底想干什么」。已有 goal 则不覆盖。
|
|
@@ -412,14 +429,18 @@ export async function* runWorkflow(
|
|
|
412
429
|
|
|
413
430
|
// 声明产物清单:state.init 用于 new-task 清理/completed 报产物;
|
|
414
431
|
// 挂到 runCtx 供 tool 步覆盖判定(声明产物自动 pre-Read 解 Write 守卫)。
|
|
415
|
-
|
|
432
|
+
// Phase 3:产物路径可能含 ${session} 占位符,收集后按 vars 展开为实际路径
|
|
433
|
+
//(new-task 清理 / completed 报告都以展开后的真实路径为准)。
|
|
434
|
+
const declaredArtifacts = collectDeclaredArtifacts(def)
|
|
435
|
+
.map((a) => interpolate(a, vars))
|
|
436
|
+
.filter((a) => a.length > 0);
|
|
416
437
|
runCtx.declaredArtifacts = declaredArtifacts;
|
|
417
438
|
|
|
418
439
|
// 任务身份语义仲裁器(hash 不等 + 旧任务未完成时的二审,见 taskIdentity.ts):
|
|
419
440
|
// 短语快路径 → LLM 单轮仲裁 → AskUserQuestion → 兜底 new-task。
|
|
420
441
|
// init 非 generator,仲裁过程的人话记录收进 notes,init 返回后统一 yield。
|
|
421
442
|
const arbitrationNotes: string[] = [];
|
|
422
|
-
const state = new WorkflowState(getWorkingDir(), def.name);
|
|
443
|
+
const state = new WorkflowState(getWorkingDir(), def.name, sessionId);
|
|
423
444
|
const resume = await state.init(
|
|
424
445
|
def.name,
|
|
425
446
|
inputs,
|
|
@@ -469,9 +490,16 @@ export async function* runWorkflow(
|
|
|
469
490
|
};
|
|
470
491
|
}
|
|
471
492
|
|
|
472
|
-
// fresh/new-task
|
|
493
|
+
// fresh/new-task 启动清理可见化:全新任务开始前清理的残留产物(上次任务
|
|
473
494
|
// 的声明产物 + /new 后无主孤儿)。用户约定:全新 plan 一律从干净状态开始;
|
|
474
495
|
// 删除范围仅限本工作流声明的产物,同任务 resume 不清理。
|
|
496
|
+
// 清理一律先归档到 .trash 回收目录再删残留:仲裁误判 new-task 时成果可找回。
|
|
497
|
+
if (resume.archivedTo) {
|
|
498
|
+
yield {
|
|
499
|
+
type: 'warning',
|
|
500
|
+
message: `全新任务:上次残留的旧产物已归档到 ${resume.archivedTo}/(确认不需要后可删除该目录),从干净状态开始。`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
475
503
|
if (resume.cleanedArtifacts?.length) {
|
|
476
504
|
yield {
|
|
477
505
|
type: 'warning',
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* 让后续 LLM 步骤能看到验证通过的信息(如"文件已存在""变量值符合预期"等)。
|
|
9
9
|
* ============================================================
|
|
10
10
|
*/
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
11
12
|
import { evalExpr, interpolate } from '../expressions.js';
|
|
12
13
|
export function execAssertStep(step, vars, ctx) {
|
|
13
14
|
if (typeof step.condition !== 'string') {
|
|
@@ -34,7 +35,7 @@ export function execAssertStep(step, vars, ctx) {
|
|
|
34
35
|
role: 'assistant',
|
|
35
36
|
content: `[workflow:${step.id}] ✅ 断言通过:${step.condition}`,
|
|
36
37
|
timestamp: Date.now(),
|
|
37
|
-
id:
|
|
38
|
+
id: randomUUID(),
|
|
38
39
|
});
|
|
39
40
|
}
|
|
40
41
|
return { raw: { ok: true }, preview: 'assert passed' };
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* ============================================================
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
|
|
12
14
|
import { evalExpr, interpolate } from '../expressions.js';
|
|
13
15
|
import type { RunContext, StepDef, StepResult, VarStack } from '../types.js';
|
|
14
16
|
|
|
@@ -42,7 +44,7 @@ export function execAssertStep(
|
|
|
42
44
|
role: 'assistant',
|
|
43
45
|
content: `[workflow:${step.id}] ✅ 断言通过:${step.condition}`,
|
|
44
46
|
timestamp: Date.now(),
|
|
45
|
-
id:
|
|
47
|
+
id: randomUUID(),
|
|
46
48
|
});
|
|
47
49
|
}
|
|
48
50
|
|