@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.
Files changed (77) hide show
  1. package/agents/{reviewer.md → code-reviewer.md} +20 -3
  2. package/agents/context-builder.md +5 -0
  3. package/agents/doc-reviewer.md +9 -2
  4. package/agents/explorer.md +5 -0
  5. package/agents/general-purpose.md +5 -0
  6. package/agents/oracle.md +20 -4
  7. package/agents/orchestrator.md +6 -1
  8. package/agents/planner.md +5 -0
  9. package/agents/researcher.md +5 -0
  10. package/agents/worker.md +5 -0
  11. package/package.json +6 -4
  12. package/src/execution/__tests__/agent-registry.test.ts +189 -119
  13. package/src/execution/__tests__/crash-recovery.test.ts +0 -1
  14. package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
  15. package/src/execution/__tests__/index-session-start.test.ts +0 -1
  16. package/src/execution/__tests__/model-resolver.test.ts +20 -0
  17. package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
  18. package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
  19. package/src/execution/agent-registry.ts +92 -169
  20. package/src/execution/execute-options-mapper.ts +2 -2
  21. package/src/execution/model-config-service.ts +13 -34
  22. package/src/execution/model-resolver.ts +5 -3
  23. package/src/execution/subagent-service.ts +9 -6
  24. package/src/execution/subprocess-agent-runner.ts +3 -2
  25. package/src/index.ts +4 -25
  26. package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
  27. package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
  28. package/src/injectors/subagent-list-injector.ts +99 -48
  29. package/src/injectors/workflow-list-injector.ts +65 -50
  30. package/src/interface/__tests__/detectors.test.ts +100 -43
  31. package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
  32. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
  33. package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
  34. package/src/interface/subagent-tool.ts +7 -4
  35. package/src/interface/tool-workflow-script.ts +27 -10
  36. package/src/interface/tool-workflow.ts +174 -81
  37. package/src/orchestration/__tests__/args-validator.test.ts +143 -0
  38. package/src/orchestration/__tests__/config-loader.test.ts +124 -40
  39. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
  40. package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
  41. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
  42. package/src/orchestration/__tests__/script-lint.test.ts +167 -1
  43. package/src/orchestration/__tests__/worker-host.test.ts +120 -0
  44. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
  45. package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
  46. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
  47. package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
  48. package/src/orchestration/agent-opts-resolver.ts +51 -101
  49. package/src/orchestration/args-validator.ts +127 -0
  50. package/src/orchestration/config-loader.ts +63 -94
  51. package/src/orchestration/error-recovery.ts +6 -9
  52. package/src/orchestration/launcher.ts +77 -41
  53. package/src/orchestration/lifecycle.ts +7 -0
  54. package/src/orchestration/models/ports.ts +0 -14
  55. package/src/orchestration/models/run-spec.ts +24 -1
  56. package/src/orchestration/models/types.ts +15 -8
  57. package/src/orchestration/models/workflow-script-registry.ts +3 -0
  58. package/src/orchestration/models/workflow-script.ts +10 -14
  59. package/src/orchestration/script-lint.ts +159 -0
  60. package/src/orchestration/worker-host.ts +5 -0
  61. package/src/orchestration/worker-script-builder.ts +15 -4
  62. package/src/orchestration/workflow-script-registry-impl.ts +34 -29
  63. package/src/shared/__tests__/meta-parser.test.ts +304 -0
  64. package/src/shared/__tests__/resource-discovery.test.ts +167 -7
  65. package/src/shared/__tests__/resource-meta.test.ts +51 -0
  66. package/src/shared/agent-ref.ts +36 -0
  67. package/src/shared/meta-parser.ts +257 -0
  68. package/src/shared/resource-discovery.ts +88 -2
  69. package/src/shared/resource-meta.ts +60 -0
  70. package/workflows/README.md +2 -2
  71. package/workflows/_shared/agent-refs.cjs +40 -0
  72. package/workflows/chain.js +30 -6
  73. package/workflows/map-reduce.js +33 -5
  74. package/workflows/parallel.js +34 -7
  75. package/workflows/review-fix-loop-utils.cjs +23 -103
  76. package/workflows/review-fix-loop.js +121 -58
  77. 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 name (system prompt + tools). If omitted, defaults to "general-purpose" — a generic agent that inherits the main agent\'s model and project context. Available: general-purpose (default fallback), worker, researcher, explorer, planner, reviewer, oracle, context-builder, orchestrator. Custom agents configurable.',
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(["off", "minimal", "low", "medium", "high", "xhigh"] as const)),
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 (explorer/worker/reviewer/oracle)",
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 (researcher/worker), 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 (reviewer/oracle) — doing these yourself floods your context with implementation detail and loses the orchestration view.
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 a built-in workflow (chain/parallel/scatter-gather/map-reduce) already " +
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/. Script can be run immediately via the workflow tool.",
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 built-in workflows (chain/parallel/scatter-gather/map-reduce) " +
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 chain/parallel/scatter-gather/map-reduce. " +
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
- if (!script.includes("const meta") && !script.includes("export const meta")) {
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, description, phases }",
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 name (run action)" }),
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: "Maximum token budget (run action)" })),
86
- time: Type.Optional(Type.Number({ description: "Maximum time budget in ms (run action)" })),
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
- /** 已知 workflow args 子字段——run action 的 args 顶层键。弱模型常把 task/items 等
100
- * 平铺到 workflow params 顶层(缺 args 嵌套),actionRun 静默 args={} 启动缺参 run(P0)。
101
- * 用此清单检测平铺形态,报错带 Correct 正例纠正。 */
102
- const KNOWN_ARG_KEYS = [
103
- "task", "target", "perspectives", "items", "itemsJson", "operation",
104
- // review-fix-loop 参数(内置 workflow,2026-08 新增;与 workflows/review-fix-loop-utils.cjs
105
- // 的 VALID_ARG_KEYS 保持同步:model/maxFixAttempts/convergeNewIssues/convergeRounds
106
- // 补齐于 review round-1 S-13,避免弱模型平铺时 P0 静默 args={} 漏检。
107
- // _runId 为内部注入键不在此列)
108
- "targetType", "agents", "batchNames", "reviewPrompt", "fixPrompt",
109
- "autoCommit", "maxRounds", "stuckThreshold", "skipCleanAgents", "recheckAfterFix", "fixAgent",
110
- "model", "maxFixAttempts", "convergeNewIssues", "convergeRounds",
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
- /** 前缀式参数(batch1..batchN 动态编号,无法枚举) */
114
- const KNOWN_ARG_KEY_PREFIXES = [/^batch\d+$/];
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(params: unknown): string[] {
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
- KNOWN_ARG_KEYS.includes(k) || KNOWN_ARG_KEY_PREFIXES.some((re) => re.test(k));
127
- return Object.keys(p).filter((k) => isKnownKey(k) && !(args !== undefined && k in args));
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
- "BUILT-IN workflows run DIRECTLY with action:run, do NOT use workflow-script generate for these: " +
253
- "chain (sequential 3-step: analyze→transform→synthesize; args: task), " +
254
- "parallel (multi-perspective analysis; args: target, optional perspectives), " +
255
- "scatter-gather (split→parallel→merge; args: task), " +
256
- "map-reduce (parallel map→reduce; args: items/itemsJson + operation), " +
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 chain/parallel/scatter-gather/map-reduce orchestration, ALWAYS use action:run with the built-in name. " +
275
- "NEVER use workflow-script action:generate to create these patterns — they already exist. " +
276
- "workflow-script generate is ONLY for novel patterns not covered by built-ins.",
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\":\"<script>\",\"args\":{...}}", true);
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 嵌套)。下面 args ?? {} 会静默 args={},启动缺参 run 不报错——比 subagent
370
- // 平铺事故更严重。这里检测顶层平铺,报错带 Correct 正例纠正。
371
- const flattened = findFlattenedArgKeys(params);
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
- const script = await deps.registry.get(name);
391
- if (!script) {
392
- // 模糊匹配建议
393
- const all = await deps.registry.loadAll();
394
- const available = all.filter((wf) => wf.available);
395
- const suggestions = available
396
- .map((wf) => ` - ${wf.name}: ${wf.meta.description || "(no description)"}`)
397
- .join("\n");
398
- return {
399
- content: [
400
- {
401
- type: "text",
402
- text: `Workflow '${name}' not found. Available:\n${suggestions || " (none)"}`,
403
- },
404
- ],
405
- details: { action: "run", runId: "", status: "not_found", name },
406
- isError: true,
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
+ });