@zhushanwen/pi-subagent-workflow 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-subagent-workflow",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "description": "Unified subagent execution and multi-agent workflow orchestration for Pi — spawned-process agent runtime with sync/background modes, stateful workflow management with persistence, state machine, and execution tracing.",
@@ -22,6 +22,25 @@ describe("mapToExecuteOptions (D-A2)", () => {
22
22
  skillPath: "/path/to/skill.md",
23
23
  };
24
24
 
25
+ it("T3.10 slug 截断: description > SLUG_MAX_LENGTH(35) → 截断到 35", () => {
26
+ const opts: AgentCallOpts = { ...baseOpts, description: "a".repeat(36) };
27
+ const result = mapToExecuteOptions(opts);
28
+ expect(result.slug).toHaveLength(35);
29
+ expect(result.slug).toBe("a".repeat(35));
30
+ });
31
+
32
+ it("T3.10 slug 边界: description = 35 → 不截断", () => {
33
+ const opts: AgentCallOpts = { ...baseOpts, description: "a".repeat(35) };
34
+ const result = mapToExecuteOptions(opts);
35
+ expect(result.slug).toHaveLength(35);
36
+ expect(result.slug).toBe("a".repeat(35));
37
+ });
38
+
39
+ it("T3.10 slug 回落: 无 description → agentName", () => {
40
+ const result = mapToExecuteOptions(baseOpts);
41
+ expect(result.slug).toBe("worker");
42
+ });
43
+
25
44
  it("T3.4 基本映射: prompt→task, agent→agent, cwd→cwd", () => {
26
45
  const result = mapToExecuteOptions(baseOpts);
27
46
  expect(result.task).toBe("test task");
@@ -37,12 +56,12 @@ describe("mapToExecuteOptions (D-A2)", () => {
37
56
  it("T3.9 schemaEnv 透传 (D-A6 bridge)", () => {
38
57
  const opts: AgentCallOpts = { ...baseOpts, schemaEnv: '{"type":"object"}' };
39
58
  const result = mapToExecuteOptions(opts);
40
- expect((result as unknown as { schemaEnv?: string }).schemaEnv).toBe('{"type":"object"}');
59
+ expect(result.schemaEnv).toBe('{"type":"object"}');
41
60
  });
42
61
 
43
62
  it("T3.9 schemaEnv 不传 → schemaEnv undefined", () => {
44
63
  const result = mapToExecuteOptions(baseOpts);
45
- expect((result as unknown as { schemaEnv?: string }).schemaEnv).toBeUndefined();
64
+ expect(result.schemaEnv).toBeUndefined();
46
65
  });
47
66
 
48
67
  it("T3.5 model: opts.model 优先(显式 override 透传,不与 ctxModel 混合)", () => {
@@ -828,4 +828,84 @@ describe("runSpawn", () => {
828
828
 
829
829
  // 注:C1(orphan 进程兜底)与 M8(stdout 边界)describe 块已移至 run-spawn-edges.test.ts,
830
830
  // 拆分以保持本文件 < 1000 行(pre-commit hook 限制)。两文件各自独立声明文件级 mock。
831
+
832
+ // ============================================================
833
+ // E2E:runSpawn 从主进程 process.argv 镜像 extension/approve flag 到子进程
834
+ // 验证完整链路:runSpawn 真实执行 → 读 process.argv → buildSpawnArgs 拼参 →
835
+ // spawn 收到的 args 含镜像 flag。spawn 被 mock(FakeChild),但 runSpawn 体内逻辑
836
+ // 全真实跑,process.argv 用真实进程变量(测试中临时覆写 + 还原)。
837
+ // ============================================================
838
+ describe("E2E: 镜像主进程 argv flag 到子进程 spawn args", () => {
839
+ const originalArgv = process.argv;
840
+
841
+ afterEach(() => {
842
+ process.argv = originalArgv;
843
+ });
844
+
845
+ it("主进程 argv 含 --extension/--no-extensions/--approve → 子进程 spawn args 全部镜像", async () => {
846
+ // 模拟主 pi 进程启动参数(xyz-agent runtime 启动 pi 的真实形态)
847
+ process.argv = [
848
+ "bun", "/path/to/pi",
849
+ "--mode", "rpc",
850
+ "--no-extensions",
851
+ "--approve",
852
+ "--extension", "/exts/goal",
853
+ "--extension", "/exts/todo",
854
+ "--session-dir", "/sessions",
855
+ ];
856
+
857
+ const record = makeRecord();
858
+ const promise = runSpawn(record, "Task", makeOpts(), makeCtx());
859
+ await waitForSpawn();
860
+ const child = lastSpawnedChild();
861
+ mockSessionFileExists(
862
+ "/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Z_sess-mirror.jsonl",
863
+ );
864
+ emitStdoutLine(child, sessionHeader("sess-mirror"));
865
+ child.stdout.end();
866
+ child.stderr.end();
867
+ child.emit("close", 0);
868
+ await promise;
869
+
870
+ // 断言 spawn 收到的调用参数含全部镜像 flag
871
+ const spawnCall = mockSpawn.mock.calls[0];
872
+ // spawn(command, args, options) → args 是第二个参数
873
+ const spawnArgs = spawnCall[1] as string[];
874
+
875
+ expect(spawnArgs).toContain("--no-extensions");
876
+ expect(spawnArgs).toContain("--approve");
877
+ // 两个 extension 路径都镜像,顺序保留
878
+ const extIdxs = spawnArgs
879
+ .map((a, i) => (a === "--extension" ? i : -1))
880
+ .filter((i) => i >= 0);
881
+ expect(extIdxs).toHaveLength(2);
882
+ expect(spawnArgs[extIdxs[0] + 1]).toBe("/exts/goal");
883
+ expect(spawnArgs[extIdxs[1] + 1]).toBe("/exts/todo");
884
+ });
885
+
886
+ it("主进程 argv 无目标 flag → 子进程 spawn args 不含镜像 flag(向后兼容)", async () => {
887
+ // 模拟纯 pi CLI 直接跑(无 extension/approve 配置)
888
+ process.argv = ["bun", "/path/to/pi", "--mode", "rpc"];
889
+
890
+ const record = makeRecord();
891
+ const promise = runSpawn(record, "Task", makeOpts(), makeCtx());
892
+ await waitForSpawn();
893
+ const child = lastSpawnedChild();
894
+ mockSessionFileExists(
895
+ "/tmp/test/agents/subagents/--tmp-test--/sessions/2026-07-03T12-00-00-000Zsess-noflag.jsonl",
896
+ );
897
+ emitStdoutLine(child, sessionHeader("sess-noflag"));
898
+ child.stdout.end();
899
+ child.stderr.end();
900
+ child.emit("close", 0);
901
+ await promise;
902
+
903
+ const spawnCall = mockSpawn.mock.calls[0];
904
+ const spawnArgs = spawnCall[1] as string[];
905
+
906
+ expect(spawnArgs).not.toContain("--no-extensions");
907
+ expect(spawnArgs).not.toContain("--approve");
908
+ expect(spawnArgs).not.toContain("--extension");
909
+ });
910
+ });
831
911
  });
@@ -7,6 +7,7 @@ import * as path from "node:path";
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
8
 
9
9
  import { MAX_FORK_DEPTH } from "../session-context-resolver.ts";
10
+ import { mirrorMainProcessFlags } from "../argv-mirror.ts";
10
11
  import { buildEnvBlock, buildSpawnArgs } from "../session-runner.ts";
11
12
 
12
13
  describe("buildSpawnArgs", () => {
@@ -131,6 +132,105 @@ describe("buildSpawnArgs", () => {
131
132
  );
132
133
  expect(args).not.toContain("--tools");
133
134
  });
135
+
136
+ // ============================================================
137
+ // mirrorFlags 透传:子进程镜像主进程 extension/approve flag
138
+ // ============================================================
139
+
140
+ it("mirrorFlags 透传:noExtensions+approve+extensionPaths 全量 push(TC5)", () => {
141
+ const args = buildSpawnArgs({
142
+ ...baseParams,
143
+ mirrorFlags: { noExtensions: true, approve: true, extensionPaths: ["/e1", "/e2"] },
144
+ });
145
+ expect(args).toContain("--no-extensions");
146
+ expect(args).toContain("--approve");
147
+ // 每个 extension 独立 token,顺序保留
148
+ const extIdxs = args.map((a, i) => (a === "--extension" ? i : -1)).filter((i) => i >= 0);
149
+ expect(extIdxs).toHaveLength(2);
150
+ expect(args[extIdxs[0] + 1]).toBe("/e1");
151
+ expect(args[extIdxs[1] + 1]).toBe("/e2");
152
+ });
153
+
154
+ it("mirrorFlags 全 false/空 → 不追加任何目标 flag(TC6)", () => {
155
+ const args = buildSpawnArgs({
156
+ ...baseParams,
157
+ mirrorFlags: { noExtensions: false, approve: false, extensionPaths: [] },
158
+ });
159
+ expect(args).not.toContain("--no-extensions");
160
+ expect(args).not.toContain("--approve");
161
+ expect(args).not.toContain("--extension");
162
+ // 仅基础参数
163
+ expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir"]);
164
+ });
165
+
166
+ it("mirrorFlags undefined → 行为等同旧版(TC7)", () => {
167
+ const args = buildSpawnArgs(baseParams);
168
+ expect(args).toEqual(["--mode", "rpc", "--session-dir", "/sessions/dir"]);
169
+ expect(args).not.toContain("--extension");
170
+ expect(args).not.toContain("--no-extensions");
171
+ expect(args).not.toContain("--approve");
172
+ });
173
+ });
174
+
175
+ // ============================================================
176
+ // mirrorMainProcessFlags:从主进程 argv 解析可镜像的 flag
177
+ // ============================================================
178
+
179
+ describe("mirrorMainProcessFlags", () => {
180
+ it("--extension 多次出现(空格分隔)+ 布尔 flag(TC1)", () => {
181
+ const r = mirrorMainProcessFlags([
182
+ "bun", "/pi", "--mode", "rpc", "--no-extensions", "--approve",
183
+ "--extension", "/a", "--extension", "/b",
184
+ ]);
185
+ expect(r).toEqual({ noExtensions: true, approve: true, extensionPaths: ["/a", "/b"] });
186
+ });
187
+
188
+ it("--extension=path 等号形式 + 短形式 -e/-ne/-a(TC2)", () => {
189
+ const r = mirrorMainProcessFlags([
190
+ "bun", "/pi", "--extension=/x", "-ne", "-a",
191
+ ]);
192
+ expect(r).toEqual({ noExtensions: true, approve: true, extensionPaths: ["/x"] });
193
+ });
194
+
195
+ it("混合形式(空格 + 等号),顺序保留(TC3)", () => {
196
+ const r = mirrorMainProcessFlags([
197
+ "bun", "/pi", "--extension", "/a", "--extension=/b", "--extension", "/c",
198
+ ]);
199
+ expect(r.extensionPaths).toEqual(["/a", "/b", "/c"]);
200
+ expect(r.noExtensions).toBe(false);
201
+ expect(r.approve).toBe(false);
202
+ });
203
+
204
+ it("无目标 flag → 全空/全 false(向后兼容,TC4)", () => {
205
+ const r = mirrorMainProcessFlags(["bun", "/pi", "--mode", "rpc"]);
206
+ expect(r).toEqual({ noExtensions: false, approve: false, extensionPaths: [] });
207
+ });
208
+
209
+ it("不误吃其他 flag 值与 positional 参数(TC8)", () => {
210
+ const r = mirrorMainProcessFlags([
211
+ "bun", "/pi", "--no-extensions", "--skill", "/sk", "some prompt text",
212
+ ]);
213
+ expect(r.noExtensions).toBe(true);
214
+ expect(r.extensionPaths).toEqual([]);
215
+ // --skill 的 /sk 不混入 extensionPaths;positional prompt 被忽略
216
+ });
217
+
218
+ it("空 argv / 仅前导两项 → 全空", () => {
219
+ expect(mirrorMainProcessFlags([])).toEqual({ noExtensions: false, approve: false, extensionPaths: [] });
220
+ expect(mirrorMainProcessFlags(["bun", "/pi"])).toEqual({
221
+ noExtensions: false, approve: false, extensionPaths: [],
222
+ });
223
+ });
224
+
225
+ it("--extension 末尾无值 → 跳过(不越界、不误吃下一个 token)", () => {
226
+ const r = mirrorMainProcessFlags(["bun", "/pi", "--extension"]);
227
+ expect(r.extensionPaths).toEqual([]);
228
+ });
229
+
230
+ it("-e 短形式多次 + 等号混用", () => {
231
+ const r = mirrorMainProcessFlags(["bun", "/pi", "-e", "/a", "-e=/b", "-e", "/c"]);
232
+ expect(r.extensionPaths).toEqual(["/a", "/b", "/c"]);
233
+ });
134
234
  });
135
235
 
136
236
  // ============================================================
@@ -89,11 +89,11 @@ describe("startHandler", () => {
89
89
  await expect(startHandler(svc, { task: "ok", slug: " " }, undefined)).rejects.toThrow(/slug is required/);
90
90
  });
91
91
 
92
- it("slug 超 20 字符 → throw", async () => {
92
+ it("slug 超 35 字符 → throw", async () => {
93
93
  const svc = makeService();
94
94
  await expect(
95
- startHandler(svc, { task: "ok", slug: "a".repeat(21) }, undefined),
96
- ).rejects.toThrow(/≤20 chars/);
95
+ startHandler(svc, { task: "ok", slug: "a".repeat(36) }, undefined),
96
+ ).rejects.toThrow(/≤35 chars/);
97
97
  });
98
98
 
99
99
  it("background 启动 → kind=bg + bgResponse.message 含 detached", async () => {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 从主进程 argv 解析可镜像给子进程的 flag。
3
+ *
4
+ * 独立模块:argv 解析是独立职责,从 session-runner.ts 拆出避免该文件超行数上限。
5
+ */
6
+
7
+ /**
8
+ * 镜像 flag 集合:从主进程 argv 解析出的可透传给子进程的 flag。
9
+ * 面向 buildSpawnArgs 的入参形态(解析后)。undefined 字段语义与解析为空一致。
10
+ */
11
+ export interface MirrorFlags {
12
+ noExtensions: boolean;
13
+ approve: boolean;
14
+ extensionPaths: string[];
15
+ }
16
+
17
+ /**
18
+ * 有值 flag 的解析规则表:成员是「后跟一个值」的 flag 名(含长短形式)。
19
+ * 用于 mirrorMainProcessFlags 跳过其他 flag 的值时不误吃。
20
+ */
21
+ const VALUED_FLAGS = new Set<string>([
22
+ "--extension", "-e",
23
+ "--skill",
24
+ "--model", "--system-prompt", "--append-system-prompt",
25
+ "--tools", "-t", "--exclude-tools", "-xt",
26
+ "--fork", "--session-dir", "--mode",
27
+ "--thinking", "--models",
28
+ ]);
29
+
30
+ /** argv 中 flag 的起始索引:argv[0]=runtime, argv[1]=binary 路径。 */
31
+ const ARGV_FLAG_START = 2;
32
+
33
+ /**
34
+ * 从主进程 argv 解析可镜像的 flag(--no-extensions/--approve/--extension)。
35
+ *
36
+ * 数据源是主 pi 进程的 process.argv:子进程 spawn 的父就是主进程,
37
+ * 主进程 argv 完整保留启动时收到的全部 flag(已运行时验证)。这让子进程
38
+ * extension/approve 加载行为与主进程一致,且对任意 pi 宿主通用(不止 xyz-agent)。
39
+ *
40
+ * 解析规则:
41
+ * - --no-extensions / -ne、--approve / -a:布尔 flag
42
+ * - --extension / -e:支持 `--extension <path>`(空格)与 `--extension=<path>`(等号)
43
+ * - 其他 flag(在 VALUED_FLAGS 中)的值不被误当 extension 路径
44
+ * - positional 参数被忽略
45
+ * - argv[0]/argv[1] 是 bun/pi binary 路径,从 argv[2:] 开始扫
46
+ */
47
+ export function mirrorMainProcessFlags(argv: readonly string[]): MirrorFlags {
48
+ let hasNoExtensions = false;
49
+ let hasApprove = false;
50
+ const extensionPaths: string[] = [];
51
+
52
+ // argv[0]=runtime(bun), argv[1]=pi binary 路径;flag 从 argv[2] 起
53
+ const flagArgs = argv.length > ARGV_FLAG_START ? argv.slice(ARGV_FLAG_START) : [];
54
+
55
+ for (let i = 0; i < flagArgs.length; i++) {
56
+ const tok = flagArgs[i];
57
+ if (tok === "--no-extensions" || tok === "-ne") {
58
+ hasNoExtensions = true;
59
+ continue;
60
+ }
61
+ if (tok === "--approve" || tok === "-a") {
62
+ hasApprove = true;
63
+ continue;
64
+ }
65
+ // 等号形式 --extension=path / -e=path
66
+ const eqMatch = /^(--extension|-e)=(.*)$/.exec(tok);
67
+ if (eqMatch) {
68
+ const val = eqMatch[2];
69
+ if (val) extensionPaths.push(val);
70
+ continue;
71
+ }
72
+ // 空格形式 --extension <path> / -e <path>:值在下一个 token
73
+ if (tok === "--extension" || tok === "-e") {
74
+ const next = flagArgs[i + 1];
75
+ if (next !== undefined && !next.startsWith("-") && next.length > 0) {
76
+ extensionPaths.push(next);
77
+ i++; // 跳过值
78
+ }
79
+ continue;
80
+ }
81
+ // 其他有值 flag:跳过其值,避免误吃(如 --skill /a 的 /a)
82
+ if (VALUED_FLAGS.has(tok)) {
83
+ i++;
84
+ continue;
85
+ }
86
+ // 其他情况(未知 flag、--flag=val 形式、positional)忽略
87
+ }
88
+
89
+ return { noExtensions: hasNoExtensions, approve: hasApprove, extensionPaths };
90
+ }
@@ -11,9 +11,11 @@ import type { ExecuteOptions } from "./types.ts";
11
11
 
12
12
  /**
13
13
  * slug 最大长度(字符)。subagent/workflow 创建时 slug 超过此值会被截断。
14
- * subagent tool schema 的 maxLength: 20 保持一致。
14
+ * subagent/workflow tool schema 的 maxLength 引用此常量(单一真相,勿再硬编码)。
15
+ * 历史值 20 偏紧——描述性 slug 如 "audit-structured-output"(23)/"fix-subagent-wf-tools"(21)
16
+ * 会撞上限,放宽到 35 兼顾「短到能塞进 TUI 标题行」与「容纳合理描述性 kebab-case 名」。
15
17
  */
16
- export const SLUG_MAX_LENGTH = 20;
18
+ export const SLUG_MAX_LENGTH = 35;
17
19
 
18
20
  /**
19
21
  * D-A2: AgentCallOpts → ExecuteOptions 映射。
@@ -23,7 +25,7 @@ export const SLUG_MAX_LENGTH = 20;
23
25
  *
24
26
  * 映射规则:
25
27
  * prompt → task
26
- * description → slug(≤20 字符,超长截断。缺失时回落 agent 名)
28
+ * description → slug(≤35 字符,超长截断。缺失时回落 agent 名)
27
29
  * agent → agent
28
30
  * schema → schema
29
31
  * schemaEnv → schemaEnv(D-A6 bridge)
@@ -11,6 +11,7 @@ import * as fs from "node:fs";
11
11
 
12
12
  import type { ExtensionMode } from "@mariozechner/pi-coding-agent";
13
13
 
14
+ import { type MirrorFlags, mirrorMainProcessFlags } from "./argv-mirror.ts";
14
15
  import { writeAliveMarker } from "./alive-store.ts";
15
16
  import { type DialogGlobalQueue, type UiRequestHandler } from "./dialog-queue.ts";
16
17
  import { updateFromEvent } from "./execution-record.ts";
@@ -422,6 +423,11 @@ export function buildSpawnArgs(
422
423
  sessionDir: string;
423
424
  forkSource: string | undefined;
424
425
  skillPaths: string[] | undefined;
426
+ /**
427
+ * 镜像自主进程 argv 的 flag(--no-extensions/--approve/--extension)。
428
+ * undefined 或全空/全 false 时行为不变(向后兼容)。
429
+ */
430
+ mirrorFlags?: MirrorFlags;
425
431
  },
426
432
  ): string[] {
427
433
  // task 不通过命令行传——pi 的 runRpcMode 只消费 stdin RpcCommand,
@@ -451,6 +457,16 @@ export function buildSpawnArgs(
451
457
  args.push("--skill", sp);
452
458
  }
453
459
  }
460
+ // 镜像主进程的 extension/approve flag:让子进程 extension 加载行为与主进程一致。
461
+ // undefined/空值时不追加(向后兼容)。顺序紧跟 skill 之后,注入类 flag 集中。
462
+ const mf = params.mirrorFlags;
463
+ if (mf) {
464
+ if (mf.noExtensions) args.push("--no-extensions");
465
+ if (mf.approve) args.push("--approve");
466
+ for (const ep of mf.extensionPaths) {
467
+ args.push("--extension", ep);
468
+ }
469
+ }
454
470
  return args;
455
471
  }
456
472
 
@@ -625,6 +641,8 @@ export async function runSpawn(
625
641
  sessionDir,
626
642
  forkSource,
627
643
  skillPaths: skillPaths.length > 0 ? skillPaths : undefined,
644
+ // 镜像主进程 argv 的 extension/approve flag,让子进程加载行为对齐主进程
645
+ mirrorFlags: mirrorMainProcessFlags(process.argv),
628
646
  },
629
647
  );
630
648
  const invocation = getPiInvocation(spawnArgs);
@@ -307,7 +307,7 @@ export interface ExecutionRecord {
307
307
  readonly mode: ExecutionMode;
308
308
  readonly task: string;
309
309
  /**
310
- * 人类可读的短标签(≤20 字符),简述本次 subagent「在做什么」。
310
+ * 人类可读的短标签(≤35 字符),简述本次 subagent「在做什么」。
311
311
  * 区别于 agent(类型名)/ task(完整 prompt)。旧持久化 record 反序列化时缺失兜底空串。
312
312
  */
313
313
  readonly slug: string;
@@ -367,7 +367,7 @@ export interface SubagentToolDetails {
367
367
  agent: string;
368
368
  model: string;
369
369
  thinkingLevel: string | undefined;
370
- /** 短标签(≤20 字符),来自 record.slug。旧 record 反序列化时为空串。 */
370
+ /** 短标签(≤35 字符),来自 record.slug。旧 record 反序列化时为空串。 */
371
371
  slug: string;
372
372
  turns: number;
373
373
  totalTokens: number;
@@ -395,7 +395,7 @@ export interface SubagentToolDetails {
395
395
  export interface ExecuteOptions {
396
396
  task: string;
397
397
  /**
398
- * 短标签(≤20 字符),简述本次执行用途,展示在 TUI。必填。
398
+ * 短标签(≤35 字符),简述本次执行用途,展示在 TUI。必填。
399
399
  * workflow 内 agent() 调用时从 AgentCallOpts.description 透传而来。
400
400
  */
401
401
  slug: string;
@@ -448,7 +448,7 @@ export type ExecutionHandle = {
448
448
  export interface SubagentListItem {
449
449
  subagentId: string;
450
450
  agent: string;
451
- /** 短标签(≤20 字符),来自 record.slug。旧 record 反序列化时为空串。 */
451
+ /** 短标签(≤35 字符),来自 record.slug。旧 record 反序列化时为空串。 */
452
452
  slug: string;
453
453
  status: ExecutionStatus;
454
454
  mode: ExecutionMode;
@@ -503,7 +503,7 @@ export interface SubagentRecord {
503
503
  agent: string;
504
504
  /** 任务提示词(详情面板置顶展示)。磁盘/内存源均有。 */
505
505
  task: string;
506
- /** 短标签(≤20 字符)。磁盘重建源旧文件可能缺失→兜底空串。 */
506
+ /** 短标签(≤35 字符)。磁盘重建源旧文件可能缺失→兜底空串。 */
507
507
  slug: string;
508
508
  status: ExecutionStatus;
509
509
  mode: ExecutionMode;
@@ -570,7 +570,7 @@ export interface RecordSnapshot {
570
570
  readonly thinkingLevel: string | undefined;
571
571
  readonly mode: ExecutionMode;
572
572
  readonly task: string;
573
- /** 短标签(≤20 字符)。来自 record.slug。 */
573
+ /** 短标签(≤35 字符)。来自 record.slug。 */
574
574
  readonly slug: string;
575
575
  readonly status: ExecutionStatus;
576
576
  readonly turns: number;
@@ -0,0 +1,76 @@
1
+ // Behavioral tests for weak-model parameter-misuse detectors.
2
+ //
3
+ // Complements the source-text prompt-quality tests (subagent-tool-prompt.test.ts /
4
+ // workflow-tool-prompt.test.ts): those lock that the Correct examples / anti-pattern
5
+ // STRINGS exist in source; these lock the actual trigger/no-trigger LOGIC, so a
6
+ // refactor that inverts a condition or swaps keys cannot pass just by keeping the
7
+ // literal string alive.
8
+ //
9
+ // Covers the detectors added in the weak-model-robustness PR:
10
+ // - subagent hasFlattenedStartFields (startParam envelope missing)
11
+ // - workflow findFlattenedArgKeys (args sub-fields flattened to top level — P0)
12
+
13
+ import { describe, expect, it } from "vitest";
14
+
15
+ import { hasFlattenedStartFields } from "../subagent-tool";
16
+ import { findFlattenedArgKeys } from "../tool-workflow";
17
+
18
+ describe("hasFlattenedStartFields (subagent startParam flatten detector)", () => {
19
+ it("triggers when task/slug flattened to top level (the original failure mode)", () => {
20
+ expect(hasFlattenedStartFields({ action: "start", task: "x", slug: "s" })).toBe(true);
21
+ expect(hasFlattenedStartFields({ action: "start", task: "x" })).toBe(true);
22
+ expect(hasFlattenedStartFields({ action: "start", slug: "s" })).toBe(true);
23
+ });
24
+
25
+ it("does NOT trigger when startParam envelope is present (correct nesting)", () => {
26
+ expect(
27
+ hasFlattenedStartFields({ action: "start", startParam: { task: "x", slug: "s" } }),
28
+ ).toBe(false);
29
+ });
30
+
31
+ it("does NOT trigger when neither task nor slug is present", () => {
32
+ expect(hasFlattenedStartFields({ action: "start" })).toBe(false);
33
+ expect(hasFlattenedStartFields({ action: "list" })).toBe(false);
34
+ });
35
+
36
+ it("returns false for non-object input", () => {
37
+ expect(hasFlattenedStartFields(null)).toBe(false);
38
+ expect(hasFlattenedStartFields(undefined)).toBe(false);
39
+ expect(hasFlattenedStartFields("start")).toBe(false);
40
+ expect(hasFlattenedStartFields(42)).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("findFlattenedArgKeys (workflow args flatten detector — P0)", () => {
45
+ it("triggers when args sub-fields flattened to top level", () => {
46
+ expect(findFlattenedArgKeys({ action: "run", name: "chain", task: "x" })).toEqual(["task"]);
47
+ expect(findFlattenedArgKeys({ action: "run", name: "x", items: ["a"] })).toEqual(["items"]);
48
+ expect(
49
+ findFlattenedArgKeys({ action: "run", name: "x", task: "t", perspectives: ["p"] }),
50
+ ).toEqual(["task", "perspectives"]);
51
+ });
52
+
53
+ it("does NOT trigger when fields correctly nested in args", () => {
54
+ expect(
55
+ findFlattenedArgKeys({ action: "run", name: "x", args: { task: "x", items: ["a"] } }),
56
+ ).toEqual([]);
57
+ });
58
+
59
+ it("edge: key present at BOTH top-level and inside args is NOT flagged", () => {
60
+ // 同时传 args.task 和顶层 task:args 已提供,顶层冗余被忽略,不算平铺。
61
+ // 这是 reviewer 点名的 untested edge。
62
+ expect(
63
+ findFlattenedArgKeys({ action: "run", name: "x", args: { task: "x" }, task: "y" }),
64
+ ).toEqual([]);
65
+ });
66
+
67
+ it("does NOT trigger when no known arg keys present", () => {
68
+ expect(findFlattenedArgKeys({ action: "run", name: "x", args: {} })).toEqual([]);
69
+ expect(findFlattenedArgKeys({ action: "status" })).toEqual([]);
70
+ });
71
+
72
+ it("returns [] for non-object input", () => {
73
+ expect(findFlattenedArgKeys(null)).toEqual([]);
74
+ expect(findFlattenedArgKeys(undefined)).toEqual([]);
75
+ });
76
+ });
@@ -9,11 +9,12 @@
9
9
  // 删掉或弱化。读源码而非 import,避免 mock 链(subagent-tool.ts 依赖 pi-ai/
10
10
  // typebox/pi-tui/ExtensionAPI 等值导入)。
11
11
 
12
- import { describe, expect, it } from "vitest";
13
12
  import { readFileSync } from "node:fs";
14
- import { join, dirname } from "node:path";
13
+ import { dirname,join } from "node:path";
15
14
  import { fileURLToPath } from "node:url";
16
15
 
16
+ import { describe, expect, it } from "vitest";
17
+
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
19
  const SUBAGENT_TOOL_SRC = readFileSync(
19
20
  join(__dirname, "../subagent-tool.ts"),
@@ -30,10 +31,12 @@ function extractDescription(src: string): string {
30
31
  const DESCRIPTION = extractDescription(SUBAGENT_TOOL_SRC);
31
32
 
32
33
  describe("subagent tool description — 行为约束器(非功能说明书)", () => {
33
- it("词数 ≤ 400(高风险 description 密度上限)", () => {
34
+ it("词数 ≤ 550(高风险 description 密度上限)", () => {
34
35
  // 高风险 tool 的 description 应聚焦约束而非功能铺陈;过长会稀释信号。
36
+ // 上限从 400 放宽到 550:补了 JSON 调用正例段(start/list/cancel 三 action 完整 JSON),
37
+ // 正例对弱模型首次调用用对参数的价值 > 节省这点 description 预算。
35
38
  const words = DESCRIPTION.trim().split(/\s+/).filter(Boolean).length;
36
- expect(words).toBeLessThanOrEqual(400);
39
+ expect(words).toBeLessThanOrEqual(550);
37
40
  });
38
41
 
39
42
  it("含 'When to delegate' 调用条件段(何时委派 vs 自己做)", () => {
@@ -81,4 +84,24 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
81
84
  expect(DESCRIPTION).toMatch(/sequential/);
82
85
  expect(DESCRIPTION).toMatch(/SAME message/i);
83
86
  });
87
+
88
+ it("Examples 段含完整 JSON 正例(含 startParam 嵌套结构)", () => {
89
+ // 弱模型信任 schema 结构信号 > 文本信号,容易把 task/slug 平铺到顶层。
90
+ // description 必须有完整 JSON 正例,让模型能直接照抄 startParam 嵌套结构。
91
+ expect(DESCRIPTION).toContain('{"action":"start","startParam"');
92
+ });
93
+
94
+ it("Anti-patterns 段含参数结构反例(top level 平铺 task/slug)", () => {
95
+ // 显式说明 task/slug 不能平铺到顶层,必须嵌在 startParam 里。
96
+ expect(DESCRIPTION).toContain("top level");
97
+ });
98
+ });
99
+
100
+ describe("subagent tool runtime handler — 错误文案含纠正正例", () => {
101
+ // 读源码文本断言 executeSubagent 的平铺检测 throw 含 Correct 正例,
102
+ // 让弱模型撞错后第二次能直接照抄正确形态。
103
+ it("subagent-tool.ts 含 runtime 平铺检测 throw + Correct 纠正正例", () => {
104
+ expect(SUBAGENT_TOOL_SRC).toContain("Correct:");
105
+ expect(SUBAGENT_TOOL_SRC).toContain("params.action === \"start\" && !params.startParam");
106
+ });
84
107
  });
@@ -7,11 +7,12 @@
7
7
  // 本测试用源码断言(读 .ts 文件文本)验证提示词内容,避免 import 重 mock 链
8
8
  // (tool-workflow.ts 依赖 pi-ai/typebox/pi-tui/lifecycle 等值导入)。
9
9
 
10
- import { describe, expect, it } from "vitest";
11
10
  import { readFileSync } from "node:fs";
12
- import { join, dirname } from "node:path";
11
+ import { dirname,join } from "node:path";
13
12
  import { fileURLToPath } from "node:url";
14
13
 
14
+ import { describe, expect, it } from "vitest";
15
+
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const TOOL_WORKFLOW_SRC = readFileSync(
17
18
  join(__dirname, "../tool-workflow.ts"),
@@ -43,6 +44,27 @@ describe("U1: workflow tool prompt mentions built-in workflows", () => {
43
44
  expect(TOOL_WORKFLOW_SRC).toMatch(/workflow run .+--args/i);
44
45
  });
45
46
 
47
+ it("promptGuidelines 含 JSON 调用正例(run/status/lifecycle/retry-node)", () => {
48
+ // 弱模型信任 schema 结构信号 > 文本信号,容易把 args 子字段平铺到顶层。
49
+ // promptGuidelines 必须有完整 JSON 调用正例,让模型能直接照抄 {"action":"run",...} 嵌套结构。
50
+ expect(TOOL_WORKFLOW_SRC).toContain('{"action":"run"');
51
+ expect(TOOL_WORKFLOW_SRC).toContain("Call shapes (JSON)");
52
+ });
53
+
54
+ it("promptGuidelines 含参数结构反例(args 平铺到顶层)", () => {
55
+ // 显式说明 args 子字段不能平铺到顶层,必须嵌在 args 里。
56
+ expect(TOOL_WORKFLOW_SRC).toContain("args");
57
+ expect(TOOL_WORKFLOW_SRC).toContain("Anti-patterns");
58
+ expect(TOOL_WORKFLOW_SRC).toContain("top level");
59
+ });
60
+
61
+ it("runtime handler 错误文案含 Correct 纠正正例 + 平铺检测", () => {
62
+ // 读源码文本断言 actionRun/必填校验的错误文案含 Correct 正例,
63
+ // 让弱模型撞错后第二次能直接照抄正确形态。KNOWN_ARG_KEYS 证明平铺检测存在。
64
+ expect(TOOL_WORKFLOW_SRC).toContain("Correct:");
65
+ expect(TOOL_WORKFLOW_SRC).toContain("KNOWN_ARG_KEYS");
66
+ });
67
+
46
68
  it("tool-workflow-script.ts list action 的 promptGuidelines 含 workflow run 交叉引用", () => {
47
69
  // 反向交叉引用:list 的指引里要提到用 workflow tool 的 run action 启动脚本。
48
70
  expect(TOOL_WORKFLOW_SCRIPT_SRC).toMatch(/workflow.*tool.*run|run.*workflow.*tool/i);
@@ -9,6 +9,12 @@
9
9
  // content(JSON 字符串)给 LLM,details(SubagentToolResult)给 renderResult,同源同处生成。
10
10
 
11
11
  import type { AgentToolResult } from "@mariozechner/pi-coding-agent";
12
+ import {
13
+ guiComponent,
14
+ type GuiContext,
15
+ guiResult,
16
+ isGuiCapable,
17
+ } from "@xyz-agent/extension-protocol";
12
18
 
13
19
  import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
14
20
  import { computeElapsedSeconds } from "../execution/execution-record.ts";
@@ -22,12 +28,6 @@ import type {
22
28
  SubagentRecord,
23
29
  SubagentToolResult,
24
30
  } from "../execution/types.ts";
25
- import {
26
- guiComponent,
27
- type GuiContext,
28
- guiResult,
29
- isGuiCapable,
30
- } from "@xyz-agent/extension-protocol";
31
31
  import { mapRunIcon, mapRunStatus } from "./gui-mappers.ts";
32
32
 
33
33
  // ============================================================
@@ -42,6 +42,9 @@ const MAX_LIST_LIMIT = 100;
42
42
  /** background 启动提示文案(spec FR-3 bgResponse.message)。 */
43
43
  const BG_MESSAGE = "detached, will notify on completion (auto-injected message, do not poll)";
44
44
 
45
+ /** subagentId(UUID)在 GUI header 的截断显示长度。 */
46
+ const SUBAGENT_ID_PREVIEW = 8;
47
+
45
48
  // ============================================================
46
49
  // 入参 / 出参类型
47
50
  // ============================================================
@@ -49,7 +52,7 @@ const BG_MESSAGE = "detached, will notify on completion (auto-injected message,
49
52
  /** start 入参(从 tool params.startParam 来,task + slug 必填)。 */
50
53
  export interface StartHandlerInput {
51
54
  task?: string;
52
- /** 短标签(≤20 字符),必填。 */
55
+ /** 短标签(≤35 字符,kebab-case),必填。 */
53
56
  slug?: string;
54
57
  agent?: string;
55
58
  model?: string;
@@ -141,7 +144,7 @@ export async function startHandler(
141
144
  // slug 必填 + 空白校验 + 长度校验(≤ SLUG_MAX_LENGTH 字符)
142
145
  const slug = input.slug?.trim();
143
146
  if (!slug) throw new Error("startParam.slug is required (and must not be whitespace-only)");
144
- if (slug.length > SLUG_MAX_LENGTH) throw new Error(`startParam.slug must be ≤${SLUG_MAX_LENGTH} chars (got ${slug.length})`);
147
+ if (slug.length > SLUG_MAX_LENGTH) throw new Error(`startParam.slug must be ≤${SLUG_MAX_LENGTH} chars (got ${slug.length}). Shorten to a kebab-case label, e.g. "fix-login", "extract-urls".`);
145
148
 
146
149
  const handle = await service.execute({
147
150
  task,
@@ -291,7 +294,7 @@ export function buildGuiComponent(
291
294
  // 利用 input.domain 的身份信息,让并发 subagent 可区分。
292
295
  const d = input.domain as StartHandlerResult;
293
296
  return guiComponent("card", {
294
- header: d.slug ? `${d.slug}` : d.subagentId.slice(0, 8),
297
+ header: d.slug ? `${d.slug}` : d.subagentId.slice(0, SUBAGENT_ID_PREVIEW),
295
298
  body: [guiComponent("stats-line", {
296
299
  items: [{ value: "running", severity: "ok" }],
297
300
  })],
@@ -14,6 +14,7 @@ import { StringEnum } from "@mariozechner/pi-ai";
14
14
  import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
15
15
  import { Type } from "@sinclair/typebox";
16
16
 
17
+ import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
17
18
  import { getSubagentService } from "../execution/subagent-service.ts";
18
19
  import type { SubagentToolResult } from "../execution/types.ts";
19
20
  import { extractAgentName } from "./format.ts";
@@ -32,7 +33,7 @@ import { type RenderContext,renderSubagentCall, renderSubagentResult } from "./t
32
33
  */
33
34
  interface StartParam {
34
35
  task: string;
35
- /** 短标签(≤20 字符),必填。展示在 TUI 标题行/列表。 */
36
+ /** 短标签(≤35 字符,kebab-case),必填。展示在 TUI 标题行/列表。 */
36
37
  slug: string;
37
38
  agent?: string;
38
39
  model?: string;
@@ -107,9 +108,9 @@ const SubagentParams = Type.Object({
107
108
  }),
108
109
  slug: Type.String({
109
110
  description:
110
- "REQUIRED for action:'start'. Short label (≤20 chars) for this subagent, e.g. 'fix-login', 'extract-urls'. " +
111
+ "REQUIRED for action:'start'. Short label (≤35 chars) for this subagent, e.g. 'fix-login', 'extract-urls'. " +
111
112
  "Shown in TUI to distinguish concurrent subagents.",
112
- maxLength: 20,
113
+ maxLength: SLUG_MAX_LENGTH,
113
114
  }),
114
115
  agent: Type.Optional(Type.String({
115
116
  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. Custom agents configurable.',
@@ -175,6 +176,16 @@ function hasStartParam(a: unknown): a is { startParam?: unknown } {
175
176
  return typeof a === "object" && a !== null && "startParam" in a;
176
177
  }
177
178
 
179
+ /** action:'start' 入参是否把 task/slug 平铺到顶层(弱模型常见误用:缺 startParam 嵌套)。 */
180
+ /**
181
+ * action:'start' 入参是否把 task/slug 平铺到顶层(弱模型常见误用:缺 startParam 嵌套)。
182
+ * export 供 behavioral 测试(trigger/no-trigger),不改变运行时行为。
183
+ */
184
+ export function hasFlattenedStartFields(a: unknown): boolean {
185
+ if (typeof a !== "object" || a === null) return false;
186
+ return "task" in a || "slug" in a;
187
+ }
188
+
178
189
  /** 从 unknown args 安全提取 model/thinkingLevel override(传给 resolveModel)。 */
179
190
  function extractModelOverride(args: unknown): { model?: string; thinkingLevel?: string } | undefined {
180
191
  if (!isModelOverrideObj(args)) return undefined;
@@ -207,6 +218,15 @@ Delegate when the task needs a distinct role (researcher/worker), context isolat
207
218
  - action:"list" — list subagents. Pass listParam: { includeFinished?, limit? } (all optional). Read an item's sessionFile for full detail.
208
219
  - action:"cancel" — cancel a background subagent. REQUIRED cancelParam: { subagentId }.
209
220
 
221
+ ## Examples
222
+
223
+ \`\`\`
224
+ {"action":"start","startParam":{"task":"<your task>","slug":"<kebab-case>"}}
225
+ {"action":"start","startParam":{"task":"...","slug":"fix-login","agent":"worker","model":"anthropic/claude-3.5-sonnet","fork":true}}
226
+ {"action":"list","listParam":{"includeFinished":false,"limit":20}}
227
+ {"action":"cancel","cancelParam":{"subagentId":"sa_abc123"}}
228
+ \`\`\`
229
+
210
230
  ## After launching — do NOT wait
211
231
 
212
232
  Completion auto-notifies you (steer wakes next turn, even mid-poll). So:
@@ -217,6 +237,7 @@ Completion auto-notifies you (steer wakes next turn, even mid-poll). So:
217
237
 
218
238
  ## Anti-patterns
219
239
 
240
+ - Putting task/slug at the top level instead of inside startParam — the tool reads startParam.task, not a top-level task.
220
241
  - Launching background, then sleeping/polling instead of working or stopping.
221
242
  - Treating subagent results as authoritative without verification.
222
243
  - Delegating trivial tasks you could do faster yourself.
@@ -311,6 +332,17 @@ const executeSubagent: SubagentExecuteCb = async (
311
332
  const service = getSubagentService();
312
333
  if (!service) throw new Error("subagents runtime not initialized");
313
334
 
335
+ // 弱模型常见误用:action:'start' 时把 task/slug 平铺到顶层(缺 startParam 嵌套层)。
336
+ // schema 用 Type.Optional 表达条件必填(flat JSON Schema 无法表达),弱模型信任
337
+ // 结构信号 > 文本信号,倾向省略嵌套层。这里在进 startHandler 之前拦截平铺形态,
338
+ // throw 带 Correct 正例,让弱模型撞错后第二次能直接照抄。
339
+ if (params.action === "start" && !params.startParam && hasFlattenedStartFields(params)) {
340
+ throw new Error(
341
+ "startParam is required for action:'start' — wrap task/slug inside startParam. " +
342
+ "Correct: {\"action\":\"start\",\"startParam\":{\"task\":\"<your task>\",\"slug\":\"<kebab-case>\"}}",
343
+ );
344
+ }
345
+
314
346
  switch (params.action) {
315
347
  case "start":
316
348
  return adapter({ action: "start", domain: await startHandler(service, params.startParam, signal, _ctx?.model) }, toGuiCtx(_ctx));
@@ -22,13 +22,6 @@
22
22
  import { StringEnum } from "@mariozechner/pi-ai";
23
23
  import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
24
24
  import { Text } from "@mariozechner/pi-tui";
25
- import { type Static, Type } from "typebox";
26
-
27
- import type { LauncherDeps } from "../orchestration/launcher.ts";
28
- import { abortRun, pauseRun, resumeRun, runWorkflow } from "../orchestration/lifecycle.ts";
29
- import type { RunStore } from "../orchestration/models/ports.ts";
30
- import type { WorkflowRun } from "../orchestration/models/workflow-run.ts";
31
- import { retryNode, skipNode } from "../orchestration/node-ops.ts";
32
25
  import {
33
26
  guiComponent,
34
27
  type GuiContext,
@@ -36,6 +29,14 @@ import {
36
29
  guiResult,
37
30
  isGuiCapable,
38
31
  } from "@xyz-agent/extension-protocol";
32
+ import { type Static, Type } from "typebox";
33
+
34
+ import { SLUG_MAX_LENGTH } from "../execution/execute-options-mapper.ts";
35
+ import type { LauncherDeps } from "../orchestration/launcher.ts";
36
+ import { abortRun, pauseRun, resumeRun, runWorkflow } from "../orchestration/lifecycle.ts";
37
+ import type { RunStore } from "../orchestration/models/ports.ts";
38
+ import type { WorkflowRun } from "../orchestration/models/workflow-run.ts";
39
+ import { retryNode, skipNode } from "../orchestration/node-ops.ts";
39
40
  import { mapRunIcon, mapRunStatus, toGuiCtx } from "./gui-mappers.ts";
40
41
  import {
41
42
  acquireReentryGuard,
@@ -75,9 +76,9 @@ const WorkflowParams = Type.Object({
75
76
  slug: Type.Optional(
76
77
  Type.String({
77
78
  description:
78
- "Short label (max 20 chars) for this run, shown in the TUI to distinguish concurrent runs. " +
79
+ "Short label (max 35 chars) for this run, shown in the TUI to distinguish concurrent runs. " +
79
80
  "If omitted, defaults to the script name.",
80
- maxLength: 20,
81
+ maxLength: SLUG_MAX_LENGTH,
81
82
  }),
82
83
  ),
83
84
  runId: Type.Optional(
@@ -105,6 +106,23 @@ type WorkflowToolParams = Static<typeof WorkflowParams>;
105
106
  /** runId 截断长度(显示用)。 */
106
107
  const RUNID_SHORT = 8;
107
108
 
109
+ /** 已知 workflow args 子字段——run action 的 args 顶层键。弱模型常把 task/items 等
110
+ * 平铺到 workflow params 顶层(缺 args 嵌套),actionRun 静默 args={} 启动缺参 run(P0)。
111
+ * 用此清单检测平铺形态,报错带 Correct 正例纠正。 */
112
+ const KNOWN_ARG_KEYS = ["task", "target", "perspectives", "items", "itemsJson", "operation"];
113
+
114
+ /**
115
+ * 检测弱模型把 args 子字段平铺到 workflow params 顶层(P0 静默失败防护)。
116
+ * 返回被平铺的键名列表(空 = 未平铺)。export 供 behavioral 测试(trigger/no-trigger/edge)。
117
+ * 参数取 unknown 以便测试构造任意对象、并解耦 WorkflowToolParams 的 index-signature 限制。
118
+ */
119
+ export function findFlattenedArgKeys(params: unknown): string[] {
120
+ if (typeof params !== "object" || params === null) return [];
121
+ const p = params as Record<string, unknown>;
122
+ const args = typeof p.args === "object" && p.args !== null ? p.args : undefined;
123
+ return KNOWN_ARG_KEYS.filter((k) => k in p && !(args !== undefined && k in args));
124
+ }
125
+
108
126
  // ── Types ────────────────────────────────────────────────────
109
127
 
110
128
  interface RunSummary {
@@ -170,7 +188,7 @@ export function buildWorkflowGui(details: WorkflowToolDetails) {
170
188
  const statusStr = details.status;
171
189
  return guiComponent("list-tree", {
172
190
  items: [{
173
- label: [details.name, details.slug, details.runId.slice(0, 8)].filter(Boolean).join(" "),
191
+ label: [details.name, details.slug, details.runId.slice(0, RUNID_SHORT)].filter(Boolean).join(" "),
174
192
  status: mapRunStatus(statusStr),
175
193
  icon: mapRunIcon(statusStr),
176
194
  }],
@@ -181,7 +199,7 @@ export function buildWorkflowGui(details: WorkflowToolDetails) {
181
199
  items: details.runs.map((r) => {
182
200
  const statusStr = r.reason ? `${r.status} (${r.reason})` : r.status;
183
201
  return {
184
- label: [r.name, r.slug, r.runId.slice(0, 8)].filter(Boolean).join(" "),
202
+ label: [r.name, r.slug, r.runId.slice(0, RUNID_SHORT)].filter(Boolean).join(" "),
185
203
  status: mapRunStatus(statusStr),
186
204
  icon: mapRunIcon(statusStr),
187
205
  };
@@ -194,7 +212,7 @@ export function buildWorkflowGui(details: WorkflowToolDetails) {
194
212
  return guiComponent("stats-line", {
195
213
  items: [{
196
214
  label: details.action,
197
- value: details.runId.slice(0, 8),
215
+ value: details.runId.slice(0, RUNID_SHORT),
198
216
  severity,
199
217
  }],
200
218
  });
@@ -245,6 +263,12 @@ export function registerWorkflowTool(
245
263
  "retry-node only re-runs the call and refreshes the trace — the workflow script has " +
246
264
  "already moved past the failed call, so the new result does NOT feed back into the " +
247
265
  "script flow. Use retry-node for diagnostics, not to resume the workflow.",
266
+ "Call shapes (JSON): " +
267
+ "- run: {\"action\":\"run\",\"name\":\"<script>\",\"args\":{...},\"tokens\":N,\"time\":N}. " +
268
+ "- status: {\"action\":\"status\"}. " +
269
+ "- pause/resume/abort: {\"action\":\"pause\",\"runId\":\"<id>\"} (abort optional: ,\"error\":\"<reason>\"}). " +
270
+ "- retry-node/skip-node: {\"action\":\"retry-node\",\"runId\":\"<id>\",\"callId\":N}.",
271
+ "Anti-patterns: Flattening args sub-fields (task/items/...) to the top level — they belong inside args. Calling {\"action\":\"run\"} without name.",
248
272
  ],
249
273
  parameters: WorkflowParams,
250
274
 
@@ -340,7 +364,25 @@ async function actionRun(
340
364
  ): Promise<ToolResult> {
341
365
  const name = params.name;
342
366
  if (!name) {
343
- return textResult("run requires 'name' parameter", true);
367
+ return textResult("run requires 'name' parameter. Correct: {\"action\":\"run\",\"name\":\"<script>\",\"args\":{...}}", true);
368
+ }
369
+ // 弱模型常见误用(P0 静默失败):把 task/items 等 args 子字段平铺到 workflow params
370
+ // 顶层(缺 args 嵌套)。下面 args ?? {} 会静默 args={},启动缺参 run 不报错——比 subagent
371
+ // 平铺事故更严重。这里检测顶层平铺,报错带 Correct 正例纠正。
372
+ const flattened = findFlattenedArgKeys(params);
373
+ if (flattened.length > 0) {
374
+ return textResult(
375
+ `Detected ${flattened.join(", ")} at top level — they belong inside 'args'. ` +
376
+ `Correct: {"action":"run","name":"${name}","args":{${flattened.map((k) => `"${k}": "<value>"`).join(", ")}}}`,
377
+ true,
378
+ );
379
+ }
380
+ // slug 运行时护栏(与 subagent startHandler 对称的纵深防御;schema maxLength 是第一道关卡)
381
+ if (params.slug !== undefined && params.slug.length > SLUG_MAX_LENGTH) {
382
+ return textResult(
383
+ `slug exceeds ${SLUG_MAX_LENGTH} chars (got ${params.slug.length}). Shorten to a kebab-case label, e.g. "fix-login", "extract-urls".`,
384
+ true,
385
+ );
344
386
  }
345
387
  const args = params.args ?? {};
346
388
  const tokens = params.tokens;
@@ -426,7 +468,7 @@ async function actionLifecycle(
426
468
  ): Promise<ToolResult> {
427
469
  const runId = params.runId;
428
470
  if (!runId) {
429
- return textResult(`'runId' is required for ${action}`, true);
471
+ return textResult(`'runId' is required for ${action}. Correct: {"action":"${action}","runId":"<id>"} (use action:"status" to find runId)`, true);
430
472
  }
431
473
  const run = deps.runs.get(runId);
432
474
  if (!run) {
@@ -467,7 +509,7 @@ async function actionRetryNode(params: WorkflowToolParams, deps: LauncherDeps):
467
509
  const runId = params.runId;
468
510
  const callId = params.callId;
469
511
  if (!runId || callId === undefined) {
470
- return textResult("retry-node requires 'runId' and 'callId'", true);
512
+ return textResult("retry-node requires 'runId' and 'callId'. Correct: {\"action\":\"retry-node\",\"runId\":\"<id>\",\"callId\":<number>}", true);
471
513
  }
472
514
  const run = deps.runs.get(runId);
473
515
  if (!run) {
@@ -494,7 +536,7 @@ async function actionSkipNode(params: WorkflowToolParams, deps: LauncherDeps): P
494
536
  const runId = params.runId;
495
537
  const callId = params.callId;
496
538
  if (!runId || callId === undefined) {
497
- return textResult("skip-node requires 'runId' and 'callId'", true);
539
+ return textResult("skip-node requires 'runId' and 'callId'. Correct: {\"action\":\"skip-node\",\"runId\":\"<id>\",\"callId\":<number>}", true);
498
540
  }
499
541
  const run = deps.runs.get(runId);
500
542
  if (!run) {