@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
|
@@ -16,6 +16,7 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
|
16
16
|
import { type Static, Type } from "typebox";
|
|
17
17
|
|
|
18
18
|
import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
|
|
19
|
+
import { THINKING_ORDER } from "../execution/model-resolver.ts";
|
|
19
20
|
import { getSubagentService } from "../execution/subagent-service.ts";
|
|
20
21
|
import type { SubagentToolResult } from "../execution/types.ts";
|
|
21
22
|
import { extractAgentName } from "./format.ts";
|
|
@@ -86,12 +87,14 @@ const SubagentParams = Type.Object({
|
|
|
86
87
|
maxLength: SLUG_MAX_LENGTH,
|
|
87
88
|
})),
|
|
88
89
|
agent: Type.Optional(Type.String({
|
|
89
|
-
description: 'Agent
|
|
90
|
+
description: 'Agent ref: absolute path to the agent .md file (use <location> from <available_subagents>). If omitted, defaults to "general-purpose" — a generic agent that inherits the main agent\'s model and project context. Do not invent names — only use paths from the injected list.',
|
|
90
91
|
})),
|
|
91
92
|
model: Type.Optional(Type.String({
|
|
92
93
|
description: 'Model override in "provider/modelId" format. Resolution order (top wins): (1) this param, (2) agent .md frontmatter model, (3) the main agent\'s current model (zero-config default). An explicit model (param or frontmatter) that is missing or unauthorized THROWS — there is no silent fallback to the main model. Omit this param to inherit the main model.',
|
|
93
94
|
})),
|
|
94
|
-
thinkingLevel: Type.Optional(StringEnum(
|
|
95
|
+
thinkingLevel: Type.Optional(StringEnum(THINKING_ORDER, {
|
|
96
|
+
description: "Thinking depth override (derived from THINKING_ORDER SSOT, includes 'max'). Omit to inherit the main agent's thinking level.",
|
|
97
|
+
})),
|
|
95
98
|
skillPath: Type.Optional(Type.String()),
|
|
96
99
|
appendSystemPrompt: Type.Optional(Type.Array(Type.String())),
|
|
97
100
|
schema: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
@@ -172,14 +175,14 @@ export function registerSubagentTool(pi: ExtensionAPI): void {
|
|
|
172
175
|
pi.registerTool({
|
|
173
176
|
name: "subagent",
|
|
174
177
|
label: "Subagent",
|
|
175
|
-
promptSnippet: "Delegate to specialized subagents (
|
|
178
|
+
promptSnippet: "Delegate to specialized subagents (agentRef = absolute .md path from <available_subagents>)",
|
|
176
179
|
description: `Delegate a task to a specialized subagent — when to delegate rather than do it yourself.
|
|
177
180
|
|
|
178
181
|
CRITICAL — executionMode "sequential": multiple \`subagent\` calls in the SAME message run one-after-another, NOT in parallel. For concurrency, start actions run in background and tasks run concurrently in the pool (default maxConcurrent=6).
|
|
179
182
|
|
|
180
183
|
## When to delegate
|
|
181
184
|
|
|
182
|
-
Delegate when the task needs a distinct role
|
|
185
|
+
Delegate when the task needs a distinct specialized role, context isolation (fork/worktree), or parallelism while you do other work. Delegate FIRST when the task involves any of: reading 3+ files, writing 100+ lines of implementation, parallel research, or specialized review — doing these yourself floods your context with implementation detail and loses the orchestration view.
|
|
183
186
|
|
|
184
187
|
## Actions
|
|
185
188
|
|
|
@@ -35,6 +35,7 @@ import { lintScript } from "../orchestration/script-lint.ts";
|
|
|
35
35
|
import { deleteWorkflow, saveWorkflow } from "../orchestration/workflow-files.ts";
|
|
36
36
|
import { toGuiCtx } from "./gui-mappers.ts";
|
|
37
37
|
import { renderTextFallback } from "./views/format.ts";
|
|
38
|
+
import { parseResourceMetaDetailed } from "../shared/meta-parser.ts";
|
|
38
39
|
|
|
39
40
|
// ── Parameter schema ─────────────────────────────────────────
|
|
40
41
|
|
|
@@ -56,7 +57,7 @@ const WorkflowScriptParams = Type.Object({
|
|
|
56
57
|
),
|
|
57
58
|
});
|
|
58
59
|
|
|
59
|
-
type ScriptParams = Static<typeof WorkflowScriptParams>;
|
|
60
|
+
export type ScriptParams = Static<typeof WorkflowScriptParams>;
|
|
60
61
|
|
|
61
62
|
// ── Tool result types (S3: typed details, replaces Record<string, unknown>) ──
|
|
62
63
|
|
|
@@ -166,20 +167,20 @@ export function registerWorkflowScriptTool(
|
|
|
166
167
|
description:
|
|
167
168
|
"Manage workflow scripts: generate (AI creates tmp script), lint (static check), " +
|
|
168
169
|
"save (tmp→permanent), delete, list. Before generating a new script, use action:list " +
|
|
169
|
-
"to check if
|
|
170
|
+
"to check if an available workflow already " +
|
|
170
171
|
"covers the use case. Replaces workflow-generate + workflow-lint tools.",
|
|
171
172
|
promptSnippet: "Generate, lint, save, delete, or list workflow scripts",
|
|
172
173
|
promptGuidelines: [
|
|
173
|
-
"generate: AI writes a tmp workflow script to .pi/workflows/.tmp/.
|
|
174
|
+
"generate: AI writes a tmp workflow script to .pi/workflows/.tmp/. Declare metadata as a /* @pi-meta */ YAML block comment (name/description/phases required; parameters JSON Schema + usage markdown optional). NOT a const meta variable. Generate round-trip-validates the YAML and reports line/col on error (common pitfall: patternProperties regex must use double backslash \\d, not \d).",
|
|
174
175
|
"lint: Statically check a script for common API misuse (outputSchema, result.output, file state).",
|
|
175
176
|
"save: Promote a tmp script to permanent (.pi/workflows/).",
|
|
176
177
|
"delete: Remove a script (blocked if a run is active).",
|
|
177
178
|
"list: Show all available workflow scripts with source tags. " +
|
|
178
|
-
"Use this to discover
|
|
179
|
+
"Use this to discover available workflows (see <available_workflows> injection) " +
|
|
179
180
|
"and user-generated scripts before starting a run. After listing, start a script via " +
|
|
180
181
|
"the workflow tool with action:run and the script name.",
|
|
181
|
-
"CRITICAL ANTI-PATTERN: NEVER generate scripts for
|
|
182
|
-
"These are BUILT-IN — use the workflow tool with action:run directly. " +
|
|
182
|
+
"CRITICAL ANTI-PATTERN: NEVER generate scripts for patterns already covered by available " +
|
|
183
|
+
"workflows. These are BUILT-IN — use the workflow tool with action:run directly. " +
|
|
183
184
|
"generate is for NOVEL orchestration patterns ONLY. When in doubt, action:list first, " +
|
|
184
185
|
"then action:run — not action:generate.",
|
|
185
186
|
],
|
|
@@ -238,7 +239,7 @@ export function registerWorkflowScriptTool(
|
|
|
238
239
|
|
|
239
240
|
// ── generate action ──────────────────────────────────────────
|
|
240
241
|
|
|
241
|
-
function actionGenerate(params: ScriptParams, signal: AbortSignal | undefined): TextContent {
|
|
242
|
+
export function actionGenerate(params: ScriptParams, signal: AbortSignal | undefined): TextContent {
|
|
242
243
|
if (signal?.aborted) {
|
|
243
244
|
return textResult("Operation aborted before start", true);
|
|
244
245
|
}
|
|
@@ -265,10 +266,12 @@ function actionGenerate(params: ScriptParams, signal: AbortSignal | undefined):
|
|
|
265
266
|
);
|
|
266
267
|
}
|
|
267
268
|
|
|
268
|
-
// 2. Validate meta declaration
|
|
269
|
-
|
|
269
|
+
// 2. Validate meta declaration (/* @pi-meta */ new format preferred; legacy const meta accepted during transition — m0)
|
|
270
|
+
const hasPiMeta = /\/\*\s*@pi-meta\s*\n/.test(script);
|
|
271
|
+
const hasLegacyMeta = script.includes("const meta") || script.includes("export const meta");
|
|
272
|
+
if (!hasPiMeta && !hasLegacyMeta) {
|
|
270
273
|
return textResult(
|
|
271
|
-
"Script must contain a meta declaration: const meta = { name
|
|
274
|
+
"Script must contain a meta declaration: a /* @pi-meta */ YAML block comment (preferred) or legacy const meta = { ... }. The block has the form: a block comment starting with /* @pi-meta followed by YAML (name/description/phases/parameters?/usage?), closed by */ on its own line.",
|
|
272
275
|
true,
|
|
273
276
|
);
|
|
274
277
|
}
|
|
@@ -290,6 +293,20 @@ function actionGenerate(params: ScriptParams, signal: AbortSignal | undefined):
|
|
|
290
293
|
return textResult(`Syntax error in script: ${msg}`, true);
|
|
291
294
|
}
|
|
292
295
|
|
|
296
|
+
// 4b. Round-trip: validate /* @pi-meta */ YAML before writing (v5 §4.7 / ERR4 — report linePos, don't write bad files)
|
|
297
|
+
if (hasPiMeta) {
|
|
298
|
+
const detailed = parseResourceMetaDetailed(script, "workflow");
|
|
299
|
+
if (!detailed.ok) {
|
|
300
|
+
const loc = "linePos" in detailed && detailed.linePos
|
|
301
|
+
? ` (line ${detailed.linePos.line}, col ${detailed.linePos.col})`
|
|
302
|
+
: "";
|
|
303
|
+
return textResult(
|
|
304
|
+
`Generated /* @pi-meta */ YAML cannot be parsed${loc}: ${detailed.error}. Common causes: YAML indent errors, patternProperties regex must use double backslash (\\d not \d), or a stray star-slash inside the YAML body. Fix the meta block and retry.`,
|
|
305
|
+
true,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
293
310
|
// 5. Write to .tmp directory
|
|
294
311
|
const tmpDir = pathResolve(".pi/workflows/.tmp");
|
|
295
312
|
mkdirSync(tmpDir, { recursive: true });
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
|
|
20
20
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
21
21
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
23
|
+
|
|
24
|
+
const logger = getLogger("tool-workflow");
|
|
22
25
|
import { Text } from "@earendil-works/pi-tui";
|
|
23
26
|
import {
|
|
24
27
|
guiComponent,
|
|
@@ -30,7 +33,9 @@ import {
|
|
|
30
33
|
import { type Static, Type } from "typebox";
|
|
31
34
|
|
|
32
35
|
import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
|
|
36
|
+
import { THINKING_ORDER } from "../execution/model-resolver.ts";
|
|
33
37
|
import type { LauncherDeps } from "../orchestration/launcher.ts";
|
|
38
|
+
import { ArgsValidationError } from "../orchestration/args-validator.ts";
|
|
34
39
|
import { abortRun, pauseRun, resumeRun, runWorkflow } from "../orchestration/lifecycle.ts";
|
|
35
40
|
import type { RunStore } from "../orchestration/models/ports.ts";
|
|
36
41
|
import type { WorkflowRun } from "../orchestration/models/workflow-run.ts";
|
|
@@ -64,7 +69,7 @@ const WORKFLOW_ACTIONS: readonly WorkflowAction[] = [
|
|
|
64
69
|
const WorkflowParams = Type.Object({
|
|
65
70
|
action: StringEnum(WORKFLOW_ACTIONS, { description: "Workflow action to execute" }),
|
|
66
71
|
name: Type.Optional(
|
|
67
|
-
Type.String({ description: "Workflow
|
|
72
|
+
Type.String({ description: "Workflow ref: absolute path to the .js script (use <location> from <available_workflows>; run action)" }),
|
|
68
73
|
),
|
|
69
74
|
slug: Type.Optional(
|
|
70
75
|
Type.String({
|
|
@@ -82,11 +87,17 @@ const WorkflowParams = Type.Object({
|
|
|
82
87
|
description: "Arguments passed to workflow as key-value pairs (run action)",
|
|
83
88
|
}),
|
|
84
89
|
),
|
|
85
|
-
tokens: Type.Optional(Type.Number({ description: "
|
|
86
|
-
time: Type.Optional(Type.Number({ description: "
|
|
90
|
+
tokens: Type.Optional(Type.Number({ description: "Max token budget — ONLY set when user explicitly requests a limit; omit = unlimited (default)" })),
|
|
91
|
+
time: Type.Optional(Type.Number({ description: "Max time budget in ms — ONLY set when user explicitly requests a limit; omit = unlimited (default)" })),
|
|
87
92
|
error: Type.Optional(
|
|
88
93
|
Type.String({ description: "Error/reason message (optional, used with abort)" }),
|
|
89
94
|
),
|
|
95
|
+
model: Type.Optional(Type.String({
|
|
96
|
+
description: "Run-level model override in 'provider/modelId' format. When set, all agents spawned by this run inherit it by default (unless a per-call agent() opts.model is set). Omit to inherit the main agent's model.",
|
|
97
|
+
})),
|
|
98
|
+
thinkingLevel: Type.Optional(StringEnum(THINKING_ORDER, {
|
|
99
|
+
description: "Run-level thinkingLevel override (off/minimal/low/medium/high/xhigh/max). All agents in this run inherit it by default. Omit to inherit the main agent's thinking level.",
|
|
100
|
+
})),
|
|
90
101
|
});
|
|
91
102
|
|
|
92
103
|
type WorkflowToolParams = Static<typeof WorkflowParams>;
|
|
@@ -96,35 +107,98 @@ type WorkflowToolParams = Static<typeof WorkflowParams>;
|
|
|
96
107
|
/** runId 截断长度(显示用)。 */
|
|
97
108
|
const RUNID_SHORT = 8;
|
|
98
109
|
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"
|
|
109
|
-
"
|
|
110
|
-
"
|
|
111
|
-
|
|
110
|
+
/**
|
|
111
|
+
* tool 自身顶层键(workflow params schema 键)——workflow 参数名与 tool 键撞名时
|
|
112
|
+
* (如 workflow 声明参数 name),顶层同名键是 tool 参数而非平铺(m6 评审 M-3)。
|
|
113
|
+
* 未来新增 tool 顶层键需同步此集合。
|
|
114
|
+
*/
|
|
115
|
+
const TOOL_TOP_LEVEL = new Set([
|
|
116
|
+
"action",
|
|
117
|
+
"name",
|
|
118
|
+
"slug",
|
|
119
|
+
"runId",
|
|
120
|
+
"args",
|
|
121
|
+
"tokens",
|
|
122
|
+
"time",
|
|
123
|
+
"error",
|
|
124
|
+
// Run-level overrides (Option B): excluded from flattening detection so a
|
|
125
|
+
// workflow that declares its own `model`/`thinkingLevel` parameter does not
|
|
126
|
+
// trip a false "belongs inside args" warning when the tool's top-level fields
|
|
127
|
+
// are present. They flow via workerData → $MODEL/$THINKING_LEVEL globals.
|
|
128
|
+
"model",
|
|
129
|
+
"thinkingLevel",
|
|
130
|
+
]);
|
|
112
131
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
132
|
+
/**
|
|
133
|
+
* 从 workflow 参数 schema 动态构建平铺检测的已知键集(m6:schema 即 SSOT——
|
|
134
|
+
* 替代 21 键硬编码 KNOWN_ARG_KEYS,消除与参数定义的漂移面)。
|
|
135
|
+
*
|
|
136
|
+
* - exact:properties keys(精确匹配)
|
|
137
|
+
* - patterns:patternProperties 原样转正则数组(如 /^batch\\d+$/——与旧
|
|
138
|
+
* KNOWN_ARG_KEY_PREFIXES 语义一致,自动兼容 \\d{2} 等变体;schema pattern 已是
|
|
139
|
+
* 正则源码,直接 new RegExp 即可)
|
|
140
|
+
* - 构建时排除 TOOL_TOP_LEVEL(撞名保护)
|
|
141
|
+
*/
|
|
142
|
+
export function argKeysFromMeta(
|
|
143
|
+
parameters: Record<string, unknown> | undefined,
|
|
144
|
+
): { exact: ReadonlySet<string>; patterns: readonly RegExp[] } {
|
|
145
|
+
const exact = new Set<string>();
|
|
146
|
+
const patterns: RegExp[] = [];
|
|
147
|
+
if (parameters === undefined || parameters === null || typeof parameters !== "object") {
|
|
148
|
+
return { exact, patterns };
|
|
149
|
+
}
|
|
150
|
+
const props = parameters.properties;
|
|
151
|
+
if (props !== null && typeof props === "object") {
|
|
152
|
+
for (const k of Object.keys(props as Record<string, unknown>)) {
|
|
153
|
+
if (!TOOL_TOP_LEVEL.has(k)) exact.add(k);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const pp = parameters.patternProperties;
|
|
157
|
+
if (pp !== null && typeof pp === "object") {
|
|
158
|
+
for (const p of Object.keys(pp as Record<string, unknown>)) {
|
|
159
|
+
try {
|
|
160
|
+
const re = new RegExp(p); // schema pattern 已是正则源码
|
|
161
|
+
// S1(m6 exec-review):跳过能命中 tool 顶层键的 pattern——否则
|
|
162
|
+
// ^run.*$ 类 pattern 会匹配 runId/name 等 tool 键,合法调用恒误报
|
|
163
|
+
if ([...TOOL_TOP_LEVEL].some((tk) => re.test(tk))) continue;
|
|
164
|
+
patterns.push(re);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// 非法 pattern(schema 校验 m3 已保证合法,双保险)——跳过并记录
|
|
167
|
+
logger.warn(`[tool-workflow] patternProperties 非法正则跳过: ${p}`, {
|
|
168
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { exact, patterns };
|
|
174
|
+
}
|
|
115
175
|
|
|
116
176
|
/**
|
|
117
177
|
* 检测弱模型把 args 子字段平铺到 workflow params 顶层(P0 静默失败防护)。
|
|
118
178
|
* 返回被平铺的键名列表(空 = 未平铺)。export 供 behavioral 测试(trigger/no-trigger/edge)。
|
|
119
179
|
* 参数取 unknown 以便测试构造任意对象、并解耦 WorkflowToolParams 的 index-signature 限制。
|
|
180
|
+
*
|
|
181
|
+
* knownKeys/knownPatterns 由 argKeysFromMeta 动态构建(m6)——匹配谓词:
|
|
182
|
+
* knownKeys.has(k) || knownPatterns.some(re => re.test(k))(pattern 自带数字后缀
|
|
183
|
+
* 语义——loose startsWith 会误报 batchl/target1);保留 args-排除(顶层 + args
|
|
184
|
+
* 内共存不算平铺)。
|
|
120
185
|
*/
|
|
121
|
-
export function findFlattenedArgKeys(
|
|
186
|
+
export function findFlattenedArgKeys(
|
|
187
|
+
params: unknown,
|
|
188
|
+
knownKeys: ReadonlySet<string>,
|
|
189
|
+
knownPatterns: readonly RegExp[],
|
|
190
|
+
): string[] {
|
|
122
191
|
if (typeof params !== "object" || params === null) return [];
|
|
123
192
|
const p = params as Record<string, unknown>;
|
|
124
193
|
const args = typeof p.args === "object" && p.args !== null ? p.args : undefined;
|
|
125
194
|
const isKnownKey = (k: string) =>
|
|
126
|
-
|
|
127
|
-
|
|
195
|
+
knownKeys.has(k) || knownPatterns.some((re) => re.test(k));
|
|
196
|
+
// hasOwnProperty.call 而非 in(原型链——constructor/toString 类参数名不被继承键掩盖)
|
|
197
|
+
return Object.keys(p).filter(
|
|
198
|
+
(k) =>
|
|
199
|
+
isKnownKey(k) &&
|
|
200
|
+
!(args !== undefined && Object.prototype.hasOwnProperty.call(args, k)),
|
|
201
|
+
);
|
|
128
202
|
}
|
|
129
203
|
|
|
130
204
|
// ── Types ────────────────────────────────────────────────────
|
|
@@ -153,7 +227,7 @@ interface RunSummary {
|
|
|
153
227
|
* without unsafe casts.
|
|
154
228
|
*/
|
|
155
229
|
export type WorkflowToolDetails =
|
|
156
|
-
| { action: "run"; runId: string; status: "running" | "not_found"; name: string; slug?: string; stateFile?: string; __gui__?: GuiRenderResult }
|
|
230
|
+
| { action: "run"; runId: string; status: "running" | "not_found" | "invalid_args"; name: string; slug?: string; stateFile?: string; __gui__?: GuiRenderResult }
|
|
157
231
|
| { action: "status"; runs: RunSummary[]; __gui__?: GuiRenderResult }
|
|
158
232
|
| { action: "pause" | "resume" | "abort"; runId: string; status: string; reason?: string; __gui__?: GuiRenderResult };
|
|
159
233
|
|
|
@@ -249,31 +323,22 @@ export function registerWorkflowTool(
|
|
|
249
323
|
promptSnippet: "Run, pause, resume, abort, or check workflow status",
|
|
250
324
|
promptGuidelines: [
|
|
251
325
|
"PRIORITY: When user says 'workflow', 'run workflow', try run action FIRST.",
|
|
252
|
-
"
|
|
253
|
-
"
|
|
254
|
-
"
|
|
255
|
-
"
|
|
256
|
-
"
|
|
257
|
-
"review-fix-loop (multi-batch review→fix loop; args: targetType + target required, " +
|
|
258
|
-
"batch1..batchN required (no default); optional fixAgent (builtin agent name or agent.md " +
|
|
259
|
-
"path — same value semantics as batchN agents, consumed in fix phase) + " +
|
|
260
|
-
"maxFixAttempts/convergeNewIssues/convergeRounds for fix convergence control). " +
|
|
261
|
-
"Example: {\"action\":\"run\",\"name\":\"parallel\",\"args\":{\"target\":\"src/auth.ts\"}}. " +
|
|
262
|
-
"Use review-fix-loop when the user wants iterative code/doc review with fixes until clean " +
|
|
263
|
-
"(it is the ONLY built-in workflow that writes files; autoCommit defaults to false). " +
|
|
264
|
-
"DISCOVERY: Use action:list / workflow-script action:list ONLY to check what's " +
|
|
265
|
-
"RUNNING (active runs), not to discover what's available — built-in workflows are " +
|
|
266
|
-
"listed above, run them directly with action:run.",
|
|
267
|
-
"run: discover by name/description, then start in background (no user confirmation needed).",
|
|
326
|
+
"All listed workflows run DIRECTLY with action:run — refs/descriptions come from " +
|
|
327
|
+
"<available_workflows> (injected each turn). For parameter details, read the <location> " +
|
|
328
|
+
"script file (script header has @pi-meta parameters + usage + phases). Do NOT use " +
|
|
329
|
+
"workflow-script generate for patterns already covered by available workflows.",
|
|
330
|
+
"run: pass the absolute .js path from <available_workflows> <location> as name, then start in background (no user confirmation needed).",
|
|
268
331
|
"Do NOT poll status after starting — results appear automatically via notifyDone.",
|
|
269
332
|
"Call shapes (JSON): " +
|
|
270
|
-
"- run: {\"action\":\"run\",\"name\":\"<script>\",\"args\":{...},\"tokens\":N,\"time\":N}. " +
|
|
333
|
+
"- run: {\"action\":\"run\",\"name\":\"<script>\",\"args\":{...},\"tokens\":N,\"time\":N,\"model\":\"<provider/modelId>\",\"thinkingLevel\":\"<level>\"}. " +
|
|
271
334
|
"- status: {\"action\":\"status\"}. " +
|
|
272
335
|
"- pause/resume/abort: {\"action\":\"pause\",\"runId\":\"<id>\"} (abort optional: ,\"error\":\"<reason>\"}).",
|
|
336
|
+
"Budget: Do NOT set tokens/time unless the user explicitly requests a limit. Built-in workflows run unlimited by default.",
|
|
337
|
+
"Model/thinkingLevel: omit by default (inherit main agent's model). Only set model/thinkingLevel when the user explicitly requests a specific model or thinking depth for this run.",
|
|
273
338
|
"Anti-patterns: Flattening args sub-fields (task/items/...) to the top level — they belong inside args. Calling {\"action\":\"run\"} without name.",
|
|
274
|
-
"CRITICAL: For
|
|
275
|
-
"NEVER use workflow-script action:generate to
|
|
276
|
-
"workflow-script generate is ONLY for novel patterns
|
|
339
|
+
"CRITICAL: For orchestration patterns, ALWAYS use action:run with an existing built-in " +
|
|
340
|
+
"name — NEVER use workflow-script action:generate to recreate patterns already covered " +
|
|
341
|
+
"by available workflows. workflow-script generate is ONLY for novel patterns.",
|
|
277
342
|
],
|
|
278
343
|
parameters: WorkflowParams,
|
|
279
344
|
|
|
@@ -356,19 +421,50 @@ export function registerWorkflowTool(
|
|
|
356
421
|
|
|
357
422
|
// ── run action ───────────────────────────────────────────────
|
|
358
423
|
|
|
359
|
-
async function actionRun(
|
|
424
|
+
export async function actionRun(
|
|
360
425
|
params: WorkflowToolParams,
|
|
361
426
|
deps: LauncherDeps,
|
|
362
427
|
signal: AbortSignal | undefined,
|
|
363
428
|
): Promise<ToolResult> {
|
|
364
429
|
const name = params.name;
|
|
365
430
|
if (!name) {
|
|
366
|
-
return textResult("run requires 'name' parameter. Correct: {\"action\":\"run\",\"name\":\"<
|
|
431
|
+
return textResult("run requires 'name' parameter (absolute .js path from <available_workflows> <location>). Correct: {\"action\":\"run\",\"name\":\"<ref>\",\"args\":{...}}", true);
|
|
367
432
|
}
|
|
368
433
|
// 弱模型常见误用(P0 静默失败):把 task/items 等 args 子字段平铺到 workflow params
|
|
369
|
-
// 顶层(缺 args
|
|
370
|
-
//
|
|
371
|
-
|
|
434
|
+
// 顶层(缺 args 嵌套)。args ?? {} 会静默 args={},启动缺参 run 不报错——比 subagent
|
|
435
|
+
// 平铺事故更严重。m6:先 registry.getPath(动态参数集来源——schema 即 SSOT),
|
|
436
|
+
// not_found 优先返回;平铺检测报错带 Correct 正例纠正。
|
|
437
|
+
const script = await deps.registry.getPath(name);
|
|
438
|
+
if (!script) {
|
|
439
|
+
// 模糊匹配建议
|
|
440
|
+
const all = await deps.registry.loadAll();
|
|
441
|
+
const available = all.filter((wf) => wf.available);
|
|
442
|
+
const suggestions = available
|
|
443
|
+
.map((wf) => ` - ${wf.name}: ${wf.meta.description || "(no description)"}`)
|
|
444
|
+
.join("\n");
|
|
445
|
+
return {
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: "text",
|
|
449
|
+
text: `Workflow '${name}' not found. Available:\n${suggestions || " (none)"}\nUse <location> from <available_workflows> for the absolute .js path.`,
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
details: { action: "run", runId: "", status: "not_found", name },
|
|
453
|
+
isError: true,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// m6:动态参数集(schema 即 SSOT)→ 平铺检测;无 parameters → 单次 warn + 跳过
|
|
458
|
+
// (legacy const-meta 类永久无检测——D1 无 adapter 声明)
|
|
459
|
+
const { exact: knownKeys, patterns: knownPatterns } = argKeysFromMeta(script.meta.parameters);
|
|
460
|
+
if (knownKeys.size === 0 && knownPatterns.length === 0) {
|
|
461
|
+
// M-2 显式信号:无参数契约(未声明/解析空)→ 单次 warn——静默退化变显式
|
|
462
|
+
// (m6 exec-review M1:原实现排除 undefined 与设计相反)
|
|
463
|
+
logger.warn(
|
|
464
|
+
`[tool-workflow] ${script.name}: 未声明参数契约(或解析为空)——平铺检测跳过,args 不校验`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const flattened = findFlattenedArgKeys(params, knownKeys, knownPatterns);
|
|
372
468
|
if (flattened.length > 0) {
|
|
373
469
|
return textResult(
|
|
374
470
|
`Detected ${flattened.join(", ")} at top level — they belong inside 'args'. ` +
|
|
@@ -387,42 +483,38 @@ async function actionRun(
|
|
|
387
483
|
const tokens = params.tokens;
|
|
388
484
|
const time = params.time;
|
|
389
485
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
486
|
+
// 构建 RunSpec + 启动(m3:parameters 从 script.meta 拷贝——chokepoint 校验用;
|
|
487
|
+
// 校验失败 → isError ToolResult 带 §5.3 指引,非 ArgsValidationError 保持传播)
|
|
488
|
+
let runId: string;
|
|
489
|
+
try {
|
|
490
|
+
runId = await runWorkflow(
|
|
491
|
+
{
|
|
492
|
+
scriptSource: script.toExecutable(),
|
|
493
|
+
args,
|
|
494
|
+
budgetTokens: tokens,
|
|
495
|
+
budgetTimeMs: time,
|
|
496
|
+
scriptName: script.name,
|
|
497
|
+
slug: params.slug,
|
|
498
|
+
scriptPath: script.path,
|
|
499
|
+
description: script.meta.description,
|
|
500
|
+
parameters: script.meta.parameters,
|
|
501
|
+
model: params.model,
|
|
502
|
+
thinkingLevel: params.thinkingLevel,
|
|
503
|
+
},
|
|
504
|
+
deps,
|
|
505
|
+
signal,
|
|
506
|
+
);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
if (err instanceof ArgsValidationError) {
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: "text", text: err.message }],
|
|
511
|
+
details: { action: "run", runId: "", status: "invalid_args", name: script.name },
|
|
512
|
+
isError: true,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
throw err;
|
|
408
516
|
}
|
|
409
517
|
|
|
410
|
-
// 构建 RunSpec + 启动
|
|
411
|
-
const runId = await runWorkflow(
|
|
412
|
-
{
|
|
413
|
-
scriptSource: script.toExecutable(),
|
|
414
|
-
args,
|
|
415
|
-
budgetTokens: tokens,
|
|
416
|
-
budgetTimeMs: time,
|
|
417
|
-
scriptName: script.name,
|
|
418
|
-
slug: params.slug,
|
|
419
|
-
scriptPath: script.path,
|
|
420
|
-
description: script.meta.description,
|
|
421
|
-
},
|
|
422
|
-
deps,
|
|
423
|
-
signal,
|
|
424
|
-
);
|
|
425
|
-
|
|
426
518
|
return {
|
|
427
519
|
content: [
|
|
428
520
|
{
|
|
@@ -436,6 +528,7 @@ async function actionRun(
|
|
|
436
528
|
};
|
|
437
529
|
}
|
|
438
530
|
|
|
531
|
+
|
|
439
532
|
// ── status action ────────────────────────────────────────────
|
|
440
533
|
|
|
441
534
|
function actionStatus(deps: LauncherDeps): ToolResult {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// m3: args-validator 单元测试(TC1-TC7)
|
|
2
|
+
//
|
|
3
|
+
// TC1 校验通过:review-fix-loop 真实 schema 的合法 args
|
|
4
|
+
// TC2 必填缺失 fail → ArgsValidationError
|
|
5
|
+
// TC3 类型错误 + null/空串 required fail(design-review major-1 回归)
|
|
6
|
+
// TC4 字符串 coerce 原地生效(m2 MAJOR-1 m3 闭环)
|
|
7
|
+
// TC5 无 parameters 跳过(安全退化)
|
|
8
|
+
// TC6 畸形 schema → ArgsValidationError;strictSchema:false 容忍自定义关键字
|
|
9
|
+
// TC7 无缓存:每次均校验
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
import { ArgsValidationError, validateRunArgs } from "../args-validator.ts";
|
|
17
|
+
import type { RunSpec } from "../models/run-spec.ts";
|
|
18
|
+
import { parseResourceMeta } from "../../shared/meta-parser.ts";
|
|
19
|
+
|
|
20
|
+
const WORKFLOWS_DIR = join(__dirname, "../../../workflows");
|
|
21
|
+
|
|
22
|
+
function reviewFixLoopParameters(): Record<string, unknown> {
|
|
23
|
+
const src = readFileSync(join(WORKFLOWS_DIR, "review-fix-loop.js"), "utf-8");
|
|
24
|
+
const meta = parseResourceMeta(src, "workflow");
|
|
25
|
+
if (!meta || meta.kind !== "workflow" || !meta.parameters) {
|
|
26
|
+
throw new Error("review-fix-loop parameters not parsed");
|
|
27
|
+
}
|
|
28
|
+
return meta.parameters;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeSpec(parameters: Record<string, unknown> | undefined, args: Record<string, unknown>): RunSpec {
|
|
32
|
+
return {
|
|
33
|
+
scriptSource: "",
|
|
34
|
+
args,
|
|
35
|
+
parameters,
|
|
36
|
+
scriptName: "test-wf",
|
|
37
|
+
scriptPath: "/x/test-wf.js",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("validateRunArgs — 校验语义", () => {
|
|
42
|
+
it("TC1: 合法 args 通过校验(review-fix-loop 真实 schema)", () => {
|
|
43
|
+
const spec = makeSpec(reviewFixLoopParameters(), {
|
|
44
|
+
targetType: "git-diff",
|
|
45
|
+
target: "main",
|
|
46
|
+
batch1: "code-reviewer",
|
|
47
|
+
autoCommit: false,
|
|
48
|
+
});
|
|
49
|
+
expect(() => validateRunArgs(spec)).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("TC2: 必填缺失 → ArgsValidationError(含 workflow 名 + 缺失字段 + info 指引)", () => {
|
|
53
|
+
const spec = makeSpec(reviewFixLoopParameters(), { batch1: "code-reviewer" });
|
|
54
|
+
try {
|
|
55
|
+
validateRunArgs(spec);
|
|
56
|
+
expect.unreachable("should throw");
|
|
57
|
+
} catch (err) {
|
|
58
|
+
expect(err).toBeInstanceOf(ArgsValidationError);
|
|
59
|
+
const e = err as ArgsValidationError;
|
|
60
|
+
expect(e.workflowName).toBe("test-wf");
|
|
61
|
+
expect(e.message).toContain("Invalid args for workflow 'test-wf'");
|
|
62
|
+
expect(e.message).toContain("targetType");
|
|
63
|
+
expect(e.message).toContain("Read the workflow script file");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("TC3a: 不可转换类型 'yes' → ArgsValidationError", () => {
|
|
68
|
+
const spec = makeSpec(reviewFixLoopParameters(), {
|
|
69
|
+
targetType: "git-diff",
|
|
70
|
+
target: "main",
|
|
71
|
+
autoCommit: "yes",
|
|
72
|
+
});
|
|
73
|
+
expect(() => validateRunArgs(spec)).toThrow(ArgsValidationError);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("TC3b: required 字段 null 视为缺失 → ArgsValidationError(design-review major-1 回归)", () => {
|
|
77
|
+
const spec = makeSpec(reviewFixLoopParameters(), {
|
|
78
|
+
targetType: "git-diff",
|
|
79
|
+
target: null,
|
|
80
|
+
});
|
|
81
|
+
expect(() => validateRunArgs(spec)).toThrow(ArgsValidationError);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("TC3c: required 字符串空串 → ArgsValidationError(与脚本 !target 语义对齐)", () => {
|
|
85
|
+
const spec = makeSpec(reviewFixLoopParameters(), {
|
|
86
|
+
targetType: "git-diff",
|
|
87
|
+
target: "",
|
|
88
|
+
});
|
|
89
|
+
expect(() => validateRunArgs(spec)).toThrow(ArgsValidationError);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("TC4: 字符串 'false'/'10' coerce 原地生效,args 引用不变(m2 MAJOR-1 m3 闭环)", () => {
|
|
93
|
+
const args = { targetType: "git-diff", target: "main", autoCommit: "false", maxRounds: "10" };
|
|
94
|
+
const spec = makeSpec(reviewFixLoopParameters(), args);
|
|
95
|
+
expect(() => validateRunArgs(spec)).not.toThrow();
|
|
96
|
+
expect(spec.args).toBe(args); // 原地 mutate,引用不变(worker/resume 同一对象)
|
|
97
|
+
expect(spec.args.autoCommit).toBe(false);
|
|
98
|
+
expect(spec.args.maxRounds).toBe(10);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("TC5: 无 parameters → 不校验不 throw(安全退化)", () => {
|
|
102
|
+
const spec = makeSpec(undefined, { whatever: "x" });
|
|
103
|
+
expect(() => validateRunArgs(spec)).not.toThrow();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("M1 回归: nullable schema 的 null 是合法输入(null-scan 不删 nullable 键)", () => {
|
|
107
|
+
const spec = makeSpec(
|
|
108
|
+
{ type: "object", properties: { model: { type: ["string", "null"] } }, required: ["model"] },
|
|
109
|
+
{ model: null },
|
|
110
|
+
);
|
|
111
|
+
expect(() => validateRunArgs(spec)).not.toThrow();
|
|
112
|
+
expect(spec.args.model).toBeNull(); // 未被删除
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("TC6a: 真畸形 schema → ArgsValidationError「schema 无效」(不泄漏原始 throw)", () => {
|
|
116
|
+
const spec = makeSpec({ type: "not-a-type" }, { a: 1 });
|
|
117
|
+
try {
|
|
118
|
+
validateRunArgs(spec);
|
|
119
|
+
expect.unreachable("should throw");
|
|
120
|
+
} catch (err) {
|
|
121
|
+
expect(err).toBeInstanceOf(ArgsValidationError);
|
|
122
|
+
expect((err as ArgsValidationError).message).toContain("invalid parameter schema");
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("TC6b: strictSchema:false 容忍自定义关键字与 format(design-review major-3 回归)", () => {
|
|
127
|
+
const spec = makeSpec(
|
|
128
|
+
{ type: "object", properties: { target: { type: "string", format: "uri" } }, required: ["target"], "x-custom": true },
|
|
129
|
+
{ target: "https://example.com" },
|
|
130
|
+
);
|
|
131
|
+
expect(() => validateRunArgs(spec)).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("TC7: 无缓存——每次 validateRunArgs 均校验(compile 0.006ms 实测,缓存无价值;无缓存声明本身靠 [P-compile] 探针实证,本用例是行为冒烟)", () => {
|
|
135
|
+
const params = reviewFixLoopParameters();
|
|
136
|
+
const good = makeSpec(params, { targetType: "git-diff", target: "main" });
|
|
137
|
+
const bad = makeSpec(params, { targetType: "git-diff" });
|
|
138
|
+
// 同一 schema 对象校验 3 次:每次均正确执行(无缓存命中概念)
|
|
139
|
+
expect(() => validateRunArgs(good)).not.toThrow();
|
|
140
|
+
expect(() => validateRunArgs(bad)).toThrow(ArgsValidationError);
|
|
141
|
+
expect(() => validateRunArgs(good)).not.toThrow();
|
|
142
|
+
});
|
|
143
|
+
});
|