@zhushanwen/pi-subagent-workflow 5.0.2 → 7.0.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/agents/{reviewer.md → code-reviewer.md} +20 -3
- package/agents/context-builder.md +5 -0
- package/agents/doc-reviewer.md +9 -2
- package/agents/explorer.md +5 -0
- package/agents/general-purpose.md +5 -0
- package/agents/oracle.md +20 -4
- package/agents/orchestrator.md +6 -1
- package/agents/planner.md +5 -0
- package/agents/researcher.md +5 -0
- package/agents/worker.md +5 -0
- package/package.json +6 -4
- package/src/execution/__tests__/agent-registry.test.ts +189 -119
- package/src/execution/__tests__/crash-recovery.test.ts +0 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
- package/src/execution/__tests__/index-session-start.test.ts +0 -1
- package/src/execution/__tests__/model-resolver.test.ts +20 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
- package/src/execution/agent-registry.ts +92 -169
- package/src/execution/execute-options-mapper.ts +2 -2
- package/src/execution/model-config-service.ts +13 -34
- package/src/execution/model-resolver.ts +5 -3
- package/src/execution/subagent-service.ts +9 -6
- package/src/execution/subprocess-agent-runner.ts +3 -2
- package/src/index.ts +4 -25
- package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
- package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
- package/src/injectors/subagent-list-injector.ts +99 -48
- package/src/injectors/workflow-list-injector.ts +65 -50
- package/src/interface/__tests__/detectors.test.ts +100 -43
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
- package/src/interface/subagent-tool.ts +7 -4
- package/src/interface/tool-workflow-script.ts +27 -10
- package/src/interface/tool-workflow.ts +174 -81
- package/src/orchestration/__tests__/args-validator.test.ts +143 -0
- package/src/orchestration/__tests__/config-loader.test.ts +124 -40
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
- package/src/orchestration/__tests__/script-lint.test.ts +167 -1
- package/src/orchestration/__tests__/worker-host.test.ts +120 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
- package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
- package/src/orchestration/agent-opts-resolver.ts +51 -101
- package/src/orchestration/args-validator.ts +127 -0
- package/src/orchestration/config-loader.ts +63 -94
- package/src/orchestration/error-recovery.ts +6 -9
- package/src/orchestration/launcher.ts +77 -41
- package/src/orchestration/lifecycle.ts +7 -0
- package/src/orchestration/models/ports.ts +0 -14
- package/src/orchestration/models/run-spec.ts +24 -1
- package/src/orchestration/models/types.ts +15 -8
- package/src/orchestration/models/workflow-script-registry.ts +3 -0
- package/src/orchestration/models/workflow-script.ts +10 -14
- package/src/orchestration/script-lint.ts +159 -0
- package/src/orchestration/worker-host.ts +5 -0
- package/src/orchestration/worker-script-builder.ts +15 -4
- package/src/orchestration/workflow-script-registry-impl.ts +34 -29
- package/src/shared/__tests__/meta-parser.test.ts +304 -0
- package/src/shared/__tests__/resource-discovery.test.ts +167 -7
- package/src/shared/__tests__/resource-meta.test.ts +51 -0
- package/src/shared/agent-ref.ts +36 -0
- package/src/shared/meta-parser.ts +257 -0
- package/src/shared/resource-discovery.ts +88 -2
- package/src/shared/resource-meta.ts +60 -0
- package/workflows/README.md +2 -2
- package/workflows/_shared/agent-refs.cjs +40 -0
- package/workflows/chain.js +30 -6
- package/workflows/map-reduce.js +33 -5
- package/workflows/parallel.js +34 -7
- package/workflows/review-fix-loop-utils.cjs +23 -103
- package/workflows/review-fix-loop.js +121 -58
- package/workflows/scatter-gather.js +30 -6
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
* 注入每 turn 会膨胀 prompt)
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
|
|
20
20
|
import type {
|
|
21
21
|
BeforeAgentStartEvent,
|
|
22
22
|
BeforeAgentStartEventResult,
|
|
23
23
|
ExtensionAPI,
|
|
24
24
|
ExtensionContext,
|
|
25
|
+
SessionShutdownEvent,
|
|
26
|
+
SessionStartEvent,
|
|
25
27
|
} from "@earendil-works/pi-coding-agent";
|
|
26
28
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
27
29
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
@@ -29,20 +31,30 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
|
29
31
|
import {
|
|
30
32
|
discoverResources,
|
|
31
33
|
findWorkspaceRoot,
|
|
34
|
+
getCachedFileContent,
|
|
32
35
|
} from "../shared/resource-discovery.ts";
|
|
36
|
+
import { parseResourceMeta } from "../shared/meta-parser.ts";
|
|
33
37
|
|
|
34
38
|
const logger = getLogger("injector");
|
|
35
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Session 级 workflow 列表缓存(per-process = per-session),与 agentCache 对称。
|
|
42
|
+
* 见 subagent-list-injector.ts 的 agentCache 注释。
|
|
43
|
+
*/
|
|
44
|
+
let workflowCache: WorkflowEntry[] | null = null;
|
|
45
|
+
|
|
36
46
|
/** 注入段中单个 workflow 的最大描述长度(控制每 turn prompt 体积) */
|
|
37
47
|
const MAX_DESC_LEN = 160;
|
|
38
48
|
|
|
39
49
|
/** 断句阈值比例:句末标点位置须 >= maxLen 的 40% 才采用,否则硬截断保留更多信息 */
|
|
40
50
|
const DESC_BOUNDARY_MIN_RATIO = 0.4;
|
|
41
51
|
|
|
42
|
-
/** 解析后的 workflow 条目(name + 截断后的 description
|
|
52
|
+
/** 解析后的 workflow 条目(name + 截断后的 description + agentRef 路径) */
|
|
43
53
|
export interface WorkflowEntry {
|
|
44
54
|
name: string;
|
|
45
55
|
description: string;
|
|
56
|
+
/** workflowRef:脚本 .js 文件的绝对路径(注入段 <location>,模型直接引用) */
|
|
57
|
+
path: string;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
/**
|
|
@@ -68,46 +80,17 @@ export function summarizeDescription(
|
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
/**
|
|
71
|
-
*
|
|
72
|
-
* 无 meta 块返回 null。
|
|
73
|
-
*/
|
|
74
|
-
function extractMetaBlock(src: string): string | null {
|
|
75
|
-
const startMatch = src.match(/const\s+meta\s*=\s*\{/);
|
|
76
|
-
if (!startMatch || startMatch.index === undefined) return null;
|
|
77
|
-
const afterOpen = startMatch.index + startMatch[0].length;
|
|
78
|
-
let depth = 1;
|
|
79
|
-
let i = afterOpen;
|
|
80
|
-
while (i < src.length && depth > 0) {
|
|
81
|
-
const ch = src[i];
|
|
82
|
-
if (ch === "{") depth++;
|
|
83
|
-
else if (ch === "}") depth--;
|
|
84
|
-
i++;
|
|
85
|
-
}
|
|
86
|
-
if (depth !== 0) return null;
|
|
87
|
-
return src.slice(startMatch.index, i);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** 从 meta 块中提取单行字符串字段值(双引号或单引号包裹)。 */
|
|
91
|
-
function extractMetaField(block: string, field: string): string | null {
|
|
92
|
-
const re = new RegExp(`^[ \\t]*${field}:\\s*("([^"]*)"|'([^']*)')`, "m");
|
|
93
|
-
const m = block.match(re);
|
|
94
|
-
if (!m) return null;
|
|
95
|
-
return m[2] ?? m[3] ?? null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* 解析 workflow .js/.mjs 文件的 meta 对象(name + description)。
|
|
83
|
+
* 解析 workflow .js 文件的 meta(name + description),经 IF1 parseResourceMeta。
|
|
100
84
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
85
|
+
* m2 收敛:删 extractMetaBlock/extractMetaField(本地 brace-match parser),
|
|
86
|
+
* 改调 shared/meta-parser.ts parseResourceMeta(统一 parser)。仅认 @pi-meta 新格式。
|
|
87
|
+
* 投影到 WorkflowEntry {name, description(summarized)} 注入用。
|
|
103
88
|
*/
|
|
104
89
|
export function parseWorkflowMeta(content: string): WorkflowEntry | null {
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (!name || !description) return null;
|
|
110
|
-
return { name, description: summarizeDescription(description) };
|
|
90
|
+
const meta = parseResourceMeta(content, "workflow");
|
|
91
|
+
if (!meta || meta.kind !== "workflow") return null;
|
|
92
|
+
// path 由 discoverAllWorkflows 从 DiscoveredResource.path 填充
|
|
93
|
+
return { name: meta.name, description: summarizeDescription(meta.description), path: "" };
|
|
111
94
|
}
|
|
112
95
|
|
|
113
96
|
/**
|
|
@@ -129,9 +112,9 @@ export async function discoverAllWorkflows(
|
|
|
129
112
|
for (const resource of resources) {
|
|
130
113
|
if (!resource.available) continue;
|
|
131
114
|
try {
|
|
132
|
-
const content =
|
|
115
|
+
const content = getCachedFileContent(resource.path) ?? "";
|
|
133
116
|
const wf = parseWorkflowMeta(content);
|
|
134
|
-
if (wf) map.set(wf.name, wf);
|
|
117
|
+
if (wf) map.set(wf.name, { ...wf, path: resource.path });
|
|
135
118
|
} catch (err) {
|
|
136
119
|
logger.error(
|
|
137
120
|
`[workflow-list-injector] skip unreadable workflow file ${resource.path}`,
|
|
@@ -164,11 +147,13 @@ export function formatWorkflowList(workflows: WorkflowEntry[]): string {
|
|
|
164
147
|
|
|
165
148
|
const lines = [
|
|
166
149
|
"\n\n<available_workflows>",
|
|
167
|
-
|
|
150
|
+
// 引导语与具体 workflow 解耦:不写死内置名(列表本身已含全部 workflow,
|
|
151
|
+
// 名字/描述每 turn 由 @pi-meta 动态注入),只给通用路由指引 + read location 参数指针。
|
|
152
|
+
'The following workflows are available. Do NOT call list to discover available workflows — they are listed below; use list only for running state. All listed workflows run directly via action:run — do NOT use workflow-script generate for any listed workflow. For parameter details, read the <location> script file (script header has @pi-meta parameters + usage).',
|
|
168
153
|
];
|
|
169
154
|
for (const wf of workflows) {
|
|
170
155
|
lines.push(
|
|
171
|
-
` <workflow><name>${escapeXml(wf.name)}</name><description>${escapeXml(wf.description)}</description></workflow>`,
|
|
156
|
+
` <workflow><name>${escapeXml(wf.name)}</name><description>${escapeXml(wf.description)}</description><location>${escapeXml(wf.path)}</location></workflow>`,
|
|
172
157
|
);
|
|
173
158
|
}
|
|
174
159
|
lines.push("</available_workflows>");
|
|
@@ -176,10 +161,30 @@ export function formatWorkflowList(workflows: WorkflowEntry[]): string {
|
|
|
176
161
|
}
|
|
177
162
|
|
|
178
163
|
/**
|
|
179
|
-
* 注册
|
|
180
|
-
*
|
|
164
|
+
* 注册 session 生命周期 handler,注入 `<available_workflows>` 段。
|
|
165
|
+
*
|
|
166
|
+
* 与 setupSubagentListInjector 对称:session_start 发现+缓存,before_agent_start
|
|
167
|
+
* 读缓存(miss fallback)渲染注入,session_shutdown 清缓存。与 subagent 注入
|
|
168
|
+
* handler 链式(pi 串联多 handler 的 systemPrompt 返回值)。
|
|
181
169
|
*/
|
|
182
170
|
export function setupWorkflowListInjector(pi: ExtensionAPI): void {
|
|
171
|
+
pi.on(
|
|
172
|
+
"session_start",
|
|
173
|
+
async (_event: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
174
|
+
try {
|
|
175
|
+
workflowCache = await discoverAllWorkflows(
|
|
176
|
+
findWorkspaceRoot(ctx.cwd),
|
|
177
|
+
getAgentDir(),
|
|
178
|
+
);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// fail-safe:发现异常不阻断 session,缓存保持 null(before_agent_start 会 fallback)
|
|
181
|
+
logger.error("[workflow-list-injector] session_start discover failed", {
|
|
182
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
183
188
|
pi.on(
|
|
184
189
|
"before_agent_start",
|
|
185
190
|
async (
|
|
@@ -187,11 +192,14 @@ export function setupWorkflowListInjector(pi: ExtensionAPI): void {
|
|
|
187
192
|
ctx: ExtensionContext,
|
|
188
193
|
): Promise<BeforeAgentStartEventResult | void> => {
|
|
189
194
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
// 读缓存;miss(session_start 未触发/缓存被清)则 fallback 重新发现+赋值
|
|
196
|
+
if (workflowCache === null) {
|
|
197
|
+
workflowCache = await discoverAllWorkflows(
|
|
198
|
+
findWorkspaceRoot(ctx.cwd),
|
|
199
|
+
getAgentDir(),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const injection = formatWorkflowList(workflowCache);
|
|
195
203
|
if (!injection) return;
|
|
196
204
|
return { systemPrompt: event.systemPrompt + injection };
|
|
197
205
|
} catch (err) {
|
|
@@ -201,4 +209,11 @@ export function setupWorkflowListInjector(pi: ExtensionAPI): void {
|
|
|
201
209
|
}
|
|
202
210
|
},
|
|
203
211
|
);
|
|
212
|
+
|
|
213
|
+
pi.on(
|
|
214
|
+
"session_shutdown",
|
|
215
|
+
(_event: SessionShutdownEvent, _ctx: ExtensionContext): void => {
|
|
216
|
+
workflowCache = null;
|
|
217
|
+
},
|
|
218
|
+
);
|
|
204
219
|
}
|
|
@@ -14,80 +14,137 @@
|
|
|
14
14
|
|
|
15
15
|
import { describe, expect, it } from "vitest";
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { argKeysFromMeta, findFlattenedArgKeys } from "../tool-workflow";
|
|
21
|
+
import { parseResourceMeta } from "../../shared/meta-parser.ts";
|
|
22
|
+
|
|
23
|
+
// 真实参数集(argKeysFromMeta 产物——m6 动态数据源,与 TC2 同源防漂移)
|
|
24
|
+
function rflKeys() {
|
|
25
|
+
const src = readFileSync(join(__dirname, "../../../workflows/review-fix-loop.js"), "utf-8");
|
|
26
|
+
const meta = parseResourceMeta(src, "workflow");
|
|
27
|
+
if (!meta || meta.kind !== "workflow" || !meta.parameters) throw new Error("rfl meta");
|
|
28
|
+
return argKeysFromMeta(meta.parameters);
|
|
29
|
+
}
|
|
30
|
+
function chainKeys() {
|
|
31
|
+
const src = readFileSync(join(__dirname, "../../../workflows/chain.js"), "utf-8");
|
|
32
|
+
const meta = parseResourceMeta(src, "workflow");
|
|
33
|
+
if (!meta || meta.kind !== "workflow" || !meta.parameters) throw new Error("chain meta");
|
|
34
|
+
return argKeysFromMeta(meta.parameters);
|
|
35
|
+
}
|
|
36
|
+
const RFL = rflKeys();
|
|
37
|
+
const CHAIN = chainKeys();
|
|
18
38
|
|
|
19
39
|
describe("findFlattenedArgKeys (workflow args flatten detector — P0)", () => {
|
|
20
|
-
it("
|
|
21
|
-
expect(findFlattenedArgKeys({ action: "run", name: "chain", task: "x" })).toEqual(["task"]);
|
|
22
|
-
expect(findFlattenedArgKeys({ action: "run", name: "x", items: ["a"] })).toEqual(["items"]);
|
|
40
|
+
it("TC3a: trigger——chain 参数集平铺 task 被识别;跨 workflow 区分(items 非 chain 参数不触发)", () => {
|
|
23
41
|
expect(
|
|
24
|
-
findFlattenedArgKeys({ action: "run", name: "
|
|
25
|
-
).toEqual(["task"
|
|
42
|
+
findFlattenedArgKeys({ action: "run", name: "chain", task: "x" }, CHAIN.exact, CHAIN.patterns),
|
|
43
|
+
).toEqual(["task"]);
|
|
44
|
+
// 跨 workflow:items 是 map-reduce 参数非 chain 参数 → 不触发(m6 动态集语义)
|
|
45
|
+
expect(
|
|
46
|
+
findFlattenedArgKeys({ action: "run", name: "chain", items: ["a"] }, CHAIN.exact, CHAIN.patterns),
|
|
47
|
+
).toEqual([]);
|
|
26
48
|
});
|
|
27
49
|
|
|
28
|
-
it("
|
|
50
|
+
it("TC3b: 嵌套 args 不触发", () => {
|
|
29
51
|
expect(
|
|
30
|
-
findFlattenedArgKeys({ action: "run", name: "x", args: { task: "x", items: ["a"] } }),
|
|
52
|
+
findFlattenedArgKeys({ action: "run", name: "x", args: { task: "x", items: ["a"] } }, CHAIN.exact, CHAIN.patterns),
|
|
31
53
|
).toEqual([]);
|
|
32
54
|
});
|
|
33
55
|
|
|
34
|
-
it("
|
|
35
|
-
// 同时传 args.task 和顶层 task:args 已提供,顶层冗余被忽略,不算平铺。
|
|
36
|
-
// 这是 reviewer 点名的 untested edge。
|
|
56
|
+
it("TC3c: 顶层 + args 内共存不触发(args-排除保留)", () => {
|
|
37
57
|
expect(
|
|
38
|
-
findFlattenedArgKeys({ action: "run", name: "x", args: { task: "x" }, task: "y" }),
|
|
58
|
+
findFlattenedArgKeys({ action: "run", name: "x", args: { task: "x" }, task: "y" }, CHAIN.exact, CHAIN.patterns),
|
|
39
59
|
).toEqual([]);
|
|
40
60
|
});
|
|
41
61
|
|
|
42
|
-
it("
|
|
43
|
-
expect(
|
|
44
|
-
|
|
62
|
+
it("TC3d: 无已知键不触发;空集不触发", () => {
|
|
63
|
+
expect(
|
|
64
|
+
findFlattenedArgKeys({ action: "run", name: "x", args: {} }, CHAIN.exact, CHAIN.patterns),
|
|
65
|
+
).toEqual([]);
|
|
66
|
+
expect(
|
|
67
|
+
findFlattenedArgKeys({ action: "status" }, new Set(), []),
|
|
68
|
+
).toEqual([]);
|
|
45
69
|
});
|
|
46
70
|
|
|
47
|
-
it("review-fix-loop fixAgent
|
|
48
|
-
expect(findFlattenedArgKeys({ action: "run", name: "review-fix-loop", fixAgent: "worker" })).toEqual(["fixAgent"]);
|
|
71
|
+
it("TC3e: review-fix-loop fixAgent 平铺被识别(动态集 exact)", () => {
|
|
49
72
|
expect(
|
|
50
|
-
findFlattenedArgKeys({ action: "run", name: "review-fix-loop",
|
|
73
|
+
findFlattenedArgKeys({ action: "run", name: "review-fix-loop", fixAgent: "worker" }, RFL.exact, RFL.patterns),
|
|
74
|
+
).toEqual(["fixAgent"]);
|
|
75
|
+
expect(
|
|
76
|
+
findFlattenedArgKeys({ action: "run", name: "review-fix-loop", args: { fixAgent: "worker" } }, RFL.exact, RFL.patterns),
|
|
51
77
|
).toEqual([]);
|
|
52
78
|
});
|
|
53
79
|
|
|
54
|
-
it("
|
|
80
|
+
it("TC3f: batchN 经 pattern 触发 + batchl 拼错不触发(数字后缀语义)", () => {
|
|
55
81
|
expect(
|
|
56
|
-
findFlattenedArgKeys(
|
|
82
|
+
findFlattenedArgKeys(
|
|
83
|
+
{ action: "run", name: "review-fix-loop", targetType: "git-diff", target: "main" },
|
|
84
|
+
RFL.exact,
|
|
85
|
+
RFL.patterns,
|
|
86
|
+
),
|
|
57
87
|
).toEqual(["targetType", "target"]);
|
|
58
88
|
expect(
|
|
59
|
-
findFlattenedArgKeys(
|
|
89
|
+
findFlattenedArgKeys(
|
|
90
|
+
{ action: "run", name: "review-fix-loop", batch1: "reviewer", autoCommit: true },
|
|
91
|
+
RFL.exact,
|
|
92
|
+
RFL.patterns,
|
|
93
|
+
),
|
|
60
94
|
).toEqual(["batch1", "autoCommit"]);
|
|
61
95
|
expect(
|
|
62
|
-
findFlattenedArgKeys(
|
|
96
|
+
findFlattenedArgKeys(
|
|
97
|
+
{ action: "run", name: "review-fix-loop", args: { targetType: "git-diff", target: "main" } },
|
|
98
|
+
RFL.exact,
|
|
99
|
+
RFL.patterns,
|
|
100
|
+
),
|
|
101
|
+
).toEqual([]);
|
|
102
|
+
// batchl 拼错(非数字后缀)不触发(m6 评审 C-1:pattern 自带数字后缀校验)
|
|
103
|
+
expect(
|
|
104
|
+
findFlattenedArgKeys({ action: "run", name: "x", batchl: "reviewer" }, RFL.exact, RFL.patterns),
|
|
63
105
|
).toEqual([]);
|
|
64
|
-
// 未知键(如 batchl 拼错)不属于白名单,不触发平铺检测(由 workflow 内白名单校验报错)
|
|
65
|
-
expect(findFlattenedArgKeys({ action: "run", name: "x", batchl: "reviewer" })).toEqual([]);
|
|
66
106
|
});
|
|
67
107
|
|
|
68
|
-
it("
|
|
108
|
+
it("TC3g: 收敛参数平铺被识别(动态集 exact——S-13 3 键,model 升格 TOOL_TOP_LEVEL 后排除)", () => {
|
|
69
109
|
expect(
|
|
70
|
-
findFlattenedArgKeys(
|
|
71
|
-
action: "run",
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
convergeRounds: 3,
|
|
77
|
-
}),
|
|
78
|
-
).toEqual(["model", "maxFixAttempts", "convergeNewIssues", "convergeRounds"]);
|
|
110
|
+
findFlattenedArgKeys(
|
|
111
|
+
{ action: "run", name: "review-fix-loop", model: "ds-flash", maxFixAttempts: 3, convergeNewIssues: 2, convergeRounds: 3 },
|
|
112
|
+
RFL.exact,
|
|
113
|
+
RFL.patterns,
|
|
114
|
+
),
|
|
115
|
+
).toEqual(["maxFixAttempts", "convergeNewIssues", "convergeRounds"]);
|
|
79
116
|
expect(
|
|
80
|
-
findFlattenedArgKeys(
|
|
81
|
-
action: "run",
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}),
|
|
117
|
+
findFlattenedArgKeys(
|
|
118
|
+
{ action: "run", name: "review-fix-loop", args: { model: "ds-flash", maxFixAttempts: 3 }, convergeRounds: 3 },
|
|
119
|
+
RFL.exact,
|
|
120
|
+
RFL.patterns,
|
|
121
|
+
),
|
|
86
122
|
).toEqual(["convergeRounds"]);
|
|
87
123
|
});
|
|
88
124
|
|
|
89
|
-
it("
|
|
90
|
-
expect(findFlattenedArgKeys(null)).toEqual([]);
|
|
91
|
-
expect(findFlattenedArgKeys(undefined)).toEqual([]);
|
|
125
|
+
it("TC3h: non-object 输入返回 []", () => {
|
|
126
|
+
expect(findFlattenedArgKeys(null, new Set(), [])).toEqual([]);
|
|
127
|
+
expect(findFlattenedArgKeys(undefined, new Set(), [])).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("TC3i: tool 顶层键不误报(M-3 回归——合成 parameters 直测 argKeysFromMeta 排除)", () => {
|
|
131
|
+
// 合成 parameters:workflow 声明参数 name(tool 键撞名)——argKeysFromMeta 必须排除
|
|
132
|
+
const keys = argKeysFromMeta({
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: { name: { type: "string" }, task: { type: "string" } },
|
|
135
|
+
required: ["name"],
|
|
136
|
+
});
|
|
137
|
+
expect(keys.exact.has("task")).toBe(true);
|
|
138
|
+
expect(keys.exact.has("name")).toBe(false); // TOOL_TOP_LEVEL 排除(M-3 真回归锁定)
|
|
139
|
+
// 合法调用不误报
|
|
140
|
+
expect(
|
|
141
|
+
findFlattenedArgKeys(
|
|
142
|
+
{ action: "run", name: "mywf", args: { name: "x" } },
|
|
143
|
+
keys.exact,
|
|
144
|
+
keys.patterns,
|
|
145
|
+
),
|
|
146
|
+
).toEqual([]);
|
|
147
|
+
// 真实 meta 不含 name(探针实测 16 键)——附加验证
|
|
148
|
+
expect(RFL.exact.has("name")).toBe(false);
|
|
92
149
|
});
|
|
93
150
|
});
|
|
@@ -101,18 +101,14 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
|
|
|
101
101
|
expect(DESCRIPTION).not.toContain('"sa_');
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it("agent
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
];
|
|
113
|
-
for (const name of expected) {
|
|
114
|
-
expect(SUBAGENT_TOOL_SRC).toContain(name);
|
|
115
|
-
}
|
|
104
|
+
it("agent 字段 description 不写死枚举,指向 <available_subagents>(通用化防漂移)", () => {
|
|
105
|
+
// 原实现把 9 个内置 agent 名写死在 schema field description(防漏),
|
|
106
|
+
// 但枚举与包内 agents/*.md 存在漂移风险(新增/删除 agent 要手改两处)。
|
|
107
|
+
// 现改为指向每 turn 动态注入的 <available_subagents> 列表——防漏职责由注入段承担,
|
|
108
|
+
// 工具描述只保留通用指引(2026-08 通用化重构)。
|
|
109
|
+
expect(SUBAGENT_TOOL_SRC).toContain("available_subagents");
|
|
110
|
+
// 通用化约束:不写死任何具体 agent 名(名字随 agents/*.md 动态变化)
|
|
111
|
+
expect(SUBAGENT_TOOL_SRC).not.toMatch(/orchestrator|code-reviewer|context-builder/);
|
|
116
112
|
});
|
|
117
113
|
|
|
118
114
|
it("Anti-patterns 段明确 list/cancel 仍 nested(防过度泛化 flatten)", () => {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* actionGenerate 行为测试(m0 wave / TC1-TC7 + [P-generate-roundtrip])
|
|
3
|
+
*
|
|
4
|
+
* mock node:fs(mkdirSync/writeFileSync)避免真实落盘 .pi/workflows/.tmp/。
|
|
5
|
+
* parseResourceMetaDetailed 真实调用(不 mock m1)——round-trip 探针必须用真实 IF2。
|
|
6
|
+
*
|
|
7
|
+
* 框架:vitest(禁 node:test)。
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import { actionGenerate, type ScriptParams, type TextContent } from "../tool-workflow-script.ts";
|
|
14
|
+
|
|
15
|
+
vi.mock("node:fs", () => ({
|
|
16
|
+
mkdirSync: vi.fn(),
|
|
17
|
+
writeFileSync: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
|
21
|
+
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
22
|
+
|
|
23
|
+
function gen(script: string, name = "test-wf"): ScriptParams {
|
|
24
|
+
return { action: "generate", name, script } as ScriptParams;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** TextContent.text 在 content[0].text。 */
|
|
28
|
+
function textOf(r: TextContent): string {
|
|
29
|
+
return r.content[0]?.text ?? "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PI_META_VALID = `/* @pi-meta
|
|
33
|
+
name: test-wf
|
|
34
|
+
description: 合法新格式
|
|
35
|
+
phases: [a, b]
|
|
36
|
+
parameters:
|
|
37
|
+
type: object
|
|
38
|
+
properties:
|
|
39
|
+
task: { type: string }
|
|
40
|
+
required: [task]
|
|
41
|
+
*/
|
|
42
|
+
const agent = require("./agent");
|
|
43
|
+
agent("worker", { task: $ARGS.task });
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const PI_META_MALFORMED = `/* @pi-meta
|
|
47
|
+
name: test-wf
|
|
48
|
+
description: bad
|
|
49
|
+
broken: indent
|
|
50
|
+
phases: [a]
|
|
51
|
+
*/
|
|
52
|
+
const agent = require("./agent");
|
|
53
|
+
agent("w");
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const PI_META_REGEX_SINGLE_BS = `/* @pi-meta
|
|
57
|
+
name: test-wf
|
|
58
|
+
description: regex
|
|
59
|
+
phases: [A]
|
|
60
|
+
parameters:
|
|
61
|
+
type: object
|
|
62
|
+
patternProperties:
|
|
63
|
+
"^batch\\d+$": { type: string }
|
|
64
|
+
*/
|
|
65
|
+
const agent = require("./agent");
|
|
66
|
+
agent("w");
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
const LEGACY_CONST_META = `const meta = {
|
|
70
|
+
name: test-wf,
|
|
71
|
+
description: legacy,
|
|
72
|
+
phases: ["a"]
|
|
73
|
+
};
|
|
74
|
+
const agent = require("./agent");
|
|
75
|
+
agent("w");
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
const NO_META = `const agent = require("./agent");
|
|
79
|
+
agent("w");
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
const ESM_IMPORT = `/* @pi-meta
|
|
83
|
+
name: x
|
|
84
|
+
description: d
|
|
85
|
+
phases: [a]
|
|
86
|
+
*/
|
|
87
|
+
import { foo } from "bar";
|
|
88
|
+
const agent = require("./agent");
|
|
89
|
+
agent("w");
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const NO_AGENT = `/* @pi-meta
|
|
93
|
+
name: x
|
|
94
|
+
description: d
|
|
95
|
+
phases: [a]
|
|
96
|
+
*/
|
|
97
|
+
const x = 1;
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
describe("actionGenerate (m0: @pi-meta 认可 + round-trip)", () => {
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
mockedWriteFileSync.mockClear();
|
|
103
|
+
mockedMkdirSync.mockClear();
|
|
104
|
+
});
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.clearAllMocks();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("TC1: 合法 @pi-meta → ready + writeFileSync 被调用", () => {
|
|
110
|
+
const r = actionGenerate(gen(PI_META_VALID), undefined);
|
|
111
|
+
expect(r.isError).toBeFalsy();
|
|
112
|
+
expect(textOf(r)).toMatch(/ready|generated|test-wf/i);
|
|
113
|
+
expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("TC2: malformed @pi-meta YAML → error + writeFileSync 未调用 [P-generate-roundtrip]", () => {
|
|
117
|
+
const r = actionGenerate(gen(PI_META_MALFORMED), undefined);
|
|
118
|
+
expect(r.isError).toBe(true);
|
|
119
|
+
expect(textOf(r)).toMatch(/cannot be parsed|无法解析|line/i);
|
|
120
|
+
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("TC3: legacy const meta(过渡期)→ ready + writeFileSync 被调用", () => {
|
|
124
|
+
const r = actionGenerate(gen(LEGACY_CONST_META), undefined);
|
|
125
|
+
expect(r.isError).toBeFalsy();
|
|
126
|
+
expect(mockedWriteFileSync).toHaveBeenCalledTimes(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("TC4: 无 meta → error 提及 @pi-meta 新格式", () => {
|
|
130
|
+
const r = actionGenerate(gen(NO_META), undefined);
|
|
131
|
+
expect(r.isError).toBe(true);
|
|
132
|
+
expect(textOf(r)).toMatch(/meta declaration/i);
|
|
133
|
+
expect(textOf(r)).toMatch(/pi-meta/i);
|
|
134
|
+
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("TC5: @pi-meta 单反斜杠正则 → error(LLM 高频错)[P-generate-roundtrip]", () => {
|
|
138
|
+
const r = actionGenerate(gen(PI_META_REGEX_SINGLE_BS), undefined);
|
|
139
|
+
expect(r.isError).toBe(true);
|
|
140
|
+
expect(textOf(r)).toMatch(/escape|cannot be parsed|无法解析|line/i);
|
|
141
|
+
expect(mockedWriteFileSync).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("TC6: ESM import → reject(保留现有行为)", () => {
|
|
145
|
+
const r = actionGenerate(gen(ESM_IMPORT), undefined);
|
|
146
|
+
expect(r.isError).toBe(true);
|
|
147
|
+
expect(textOf(r)).toMatch(/ESM|import/i);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("TC7: 无 agent() → reject(保留现有行为)", () => {
|
|
151
|
+
const r = actionGenerate(gen(NO_AGENT), undefined);
|
|
152
|
+
expect(r.isError).toBe(true);
|
|
153
|
+
expect(textOf(r)).toMatch(/agent\(\)/i);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("TC8: signal aborted → reject(保留现有行为)", () => {
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
controller.abort();
|
|
159
|
+
const r = actionGenerate(gen(PI_META_VALID), controller.signal);
|
|
160
|
+
expect(r.isError).toBe(true);
|
|
161
|
+
expect(textOf(r)).toMatch(/abort/i);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -23,13 +23,46 @@ const TOOL_WORKFLOW_SCRIPT_SRC = readFileSync(
|
|
|
23
23
|
"utf-8",
|
|
24
24
|
);
|
|
25
25
|
|
|
26
|
+
/** 截取 promptGuidelines 数组文本——防 KNOWN_ARG_KEYS 等注释/代码中的裸词污染断言。
|
|
27
|
+
* 数到数组闭合(跳过字符串字面量内的括号,exec-review F9:'],' 序列静默截断)。 */
|
|
28
|
+
function promptGuidelinesText(src: string): string {
|
|
29
|
+
const start = src.indexOf("promptGuidelines: [");
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let inStr = false;
|
|
32
|
+
for (let i = start; i < src.length; i++) {
|
|
33
|
+
const ch = src[i];
|
|
34
|
+
if (inStr) {
|
|
35
|
+
if (ch === "\\") { i++; continue; }
|
|
36
|
+
if (ch === '"') inStr = false;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (ch === '"') inStr = true;
|
|
40
|
+
else if (ch === "[") depth++;
|
|
41
|
+
else if (ch === "]") {
|
|
42
|
+
depth--;
|
|
43
|
+
if (depth === 0) return src.slice(start, i + 1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return src.slice(start);
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
describe("U1: workflow tool prompt mentions built-in workflows", () => {
|
|
27
|
-
it("
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
expect(
|
|
32
|
-
expect(
|
|
50
|
+
it("TC4a: promptGuidelines 不含具体内置 args 枚举(m4 瘦身——参数知识在 read location)", () => {
|
|
51
|
+
// m4:BUILT-IN 枚举删除,发现职责转移给 <available_workflows> 注入段 + read location。
|
|
52
|
+
// 截取 promptGuidelines 段断言("batch1..batchN" 等在 KNOWN_ARG_KEYS 注释中出现)。
|
|
53
|
+
const guidelines = promptGuidelinesText(TOOL_WORKFLOW_SRC);
|
|
54
|
+
expect(guidelines).not.toContain("chain (sequential");
|
|
55
|
+
expect(guidelines).not.toContain("args: task");
|
|
56
|
+
expect(guidelines).not.toContain("batch1..batchN");
|
|
57
|
+
expect(guidelines).not.toContain("chain/parallel/scatter-gather/map-reduce");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("TC4b: promptGuidelines 引导 read location 获取参数细节(info 已砍,ADR-0003 D5)", () => {
|
|
61
|
+
const guidelines = promptGuidelinesText(TOOL_WORKFLOW_SRC);
|
|
62
|
+
expect(guidelines).toContain("read the <location>");
|
|
63
|
+
expect(guidelines).toContain("script file");
|
|
64
|
+
// info action 已砍,引导语不再含 workflow info
|
|
65
|
+
expect(guidelines).not.toContain("workflow info");
|
|
33
66
|
});
|
|
34
67
|
|
|
35
68
|
it("tool-workflow.ts promptGuidelines 含 workflow-script list 交叉引用", () => {
|
|
@@ -58,11 +91,14 @@ describe("U1: workflow tool prompt mentions built-in workflows", () => {
|
|
|
58
91
|
expect(TOOL_WORKFLOW_SRC).toContain("top level");
|
|
59
92
|
});
|
|
60
93
|
|
|
61
|
-
it("runtime handler 错误文案含 Correct 纠正正例 +
|
|
94
|
+
it("runtime handler 错误文案含 Correct 纠正正例 + 平铺检测(m6:动态参数集)", () => {
|
|
62
95
|
// 读源码文本断言 actionRun/必填校验的错误文案含 Correct 正例,
|
|
63
|
-
// 让弱模型撞错后第二次能直接照抄正确形态。
|
|
96
|
+
// 让弱模型撞错后第二次能直接照抄正确形态。m6 后平铺检测数据源为动态
|
|
97
|
+
// argKeysFromMeta(schema 即 SSOT)——断言新机制存在(KNOWN_ARG_KEYS 已删)。
|
|
64
98
|
expect(TOOL_WORKFLOW_SRC).toContain("Correct:");
|
|
65
|
-
expect(TOOL_WORKFLOW_SRC).toContain("
|
|
99
|
+
expect(TOOL_WORKFLOW_SRC).toContain("findFlattenedArgKeys");
|
|
100
|
+
expect(TOOL_WORKFLOW_SRC).toContain("argKeysFromMeta");
|
|
101
|
+
expect(TOOL_WORKFLOW_SRC).not.toMatch(/const\s+KNOWN_ARG_KEYS/);
|
|
66
102
|
});
|
|
67
103
|
|
|
68
104
|
it("tool-workflow-script.ts list action 的 promptGuidelines 含 workflow run 交叉引用", () => {
|
|
@@ -80,4 +116,14 @@ describe("U1: workflow tool prompt mentions built-in workflows", () => {
|
|
|
80
116
|
expect(TOOL_WORKFLOW_SCRIPT_SRC).toContain("CRITICAL ANTI-PATTERN");
|
|
81
117
|
expect(TOOL_WORKFLOW_SCRIPT_SRC).toContain("NEVER generate");
|
|
82
118
|
});
|
|
119
|
+
|
|
120
|
+
it("promptGuidelines + parameter description 标注 budget 默认不限制(B/C:除非用户要求否则别设)", () => {
|
|
121
|
+
// budget 是 run 级参数,运行时 maxTokens===undefined → 不限制(budget.ts isExceeded 守卫)。
|
|
122
|
+
// 但 call shape 示例展示了 tokens/time 字段,LLM 会误以为每次都该填。
|
|
123
|
+
// promptGuidelines 必须明确 "Do NOT set ... unless user explicitly requests",
|
|
124
|
+
// parameter description 同步标 "omit = unlimited",双重约束压过示例的反引导。
|
|
125
|
+
const guidelines = promptGuidelinesText(TOOL_WORKFLOW_SRC);
|
|
126
|
+
expect(guidelines).toContain("Do NOT set tokens/time unless the user explicitly requests");
|
|
127
|
+
expect(TOOL_WORKFLOW_SRC).toContain("omit = unlimited (default)");
|
|
128
|
+
});
|
|
83
129
|
});
|