@zhushanwen/pi-subagent-workflow 0.3.2 → 0.4.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 (33) hide show
  1. package/agents/context-builder.md +1 -0
  2. package/agents/explorer.md +2 -2
  3. package/agents/oracle.md +1 -0
  4. package/agents/orchestrator.md +7 -2
  5. package/agents/researcher.md +6 -3
  6. package/agents/reviewer.md +1 -0
  7. package/agents/worker.md +1 -0
  8. package/package.json +1 -1
  9. package/src/execution/__tests__/agent-registry.test.ts +19 -2
  10. package/src/execution/__tests__/format.test.ts +15 -1
  11. package/src/execution/__tests__/notifier-flush.test.ts +109 -1
  12. package/src/execution/__tests__/sdk-contract.test.ts +9 -11
  13. package/src/execution/__tests__/spawn-args.test.ts +18 -1
  14. package/src/execution/__tests__/subagent-service.test.ts +4 -1
  15. package/src/execution/__tests__/tool-action.test.ts +10 -5
  16. package/src/execution/model-resolver.ts +1 -1
  17. package/src/execution/notifier.ts +79 -8
  18. package/src/execution/subagent-service.ts +10 -1
  19. package/src/index.ts +3 -0
  20. package/src/interface/__tests__/detectors.test.ts +3 -28
  21. package/src/interface/__tests__/subagent-tool-prompt.test.ts +54 -11
  22. package/src/interface/__tests__/tool-render.test.ts +122 -0
  23. package/src/interface/__tests__/workflow-tool-prompt.test.ts +1 -1
  24. package/src/interface/format.ts +11 -7
  25. package/src/interface/subagent-actions.ts +16 -5
  26. package/src/interface/subagent-tool.ts +81 -98
  27. package/src/interface/tool-render.ts +9 -11
  28. package/src/interface/tool-workflow.ts +10 -90
  29. package/src/orchestration/error-recovery.ts +2 -2
  30. package/src/orchestration/models/ports.ts +3 -3
  31. package/src/orchestration/models/workflow-run.ts +3 -3
  32. package/src/orchestration/worker-script-builder.ts +1 -1
  33. package/src/orchestration/node-ops.ts +0 -194
@@ -7,40 +7,15 @@
7
7
  // literal string alive.
8
8
  //
9
9
  // Covers the detectors added in the weak-model-robustness PR:
10
- // - subagent hasFlattenedStartFields (startParam envelope missing)
11
10
  // - workflow findFlattenedArgKeys (args sub-fields flattened to top level — P0)
11
+ //
12
+ // NOTE: subagent hasFlattenedStartFields detector 已随 wave 3 拍平删除——
13
+ // startParam envelope 不再存在,task/slug 平铺到顶层是合法形态,原 detector 无意义。
12
14
 
13
15
  import { describe, expect, it } from "vitest";
14
16
 
15
- import { hasFlattenedStartFields } from "../subagent-tool";
16
17
  import { findFlattenedArgKeys } from "../tool-workflow";
17
18
 
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
19
  describe("findFlattenedArgKeys (workflow args flatten detector — P0)", () => {
45
20
  it("triggers when args sub-fields flattened to top level", () => {
46
21
  expect(findFlattenedArgKeys({ action: "run", name: "chain", task: "x" })).toEqual(["task"]);
@@ -85,23 +85,66 @@ describe("subagent tool description — 行为约束器(非功能说明书)"
85
85
  expect(DESCRIPTION).toMatch(/SAME message/i);
86
86
  });
87
87
 
88
- it("Examples 段含完整 JSON 正例(含 startParam 嵌套结构)", () => {
89
- // 弱模型信任 schema 结构信号 > 文本信号,容易把 task/slug 平铺到顶层。
90
- // description 必须有完整 JSON 正例,让模型能直接照抄 startParam 嵌套结构。
91
- expect(DESCRIPTION).toContain('{"action":"start","startParam"');
88
+ it("Examples 段含平铺 JSON 正例(task/slug 在顶层,无 startParam envelope)", () => {
89
+ // 弱模型信任 schema 结构信号 > 文本信号,原本嵌套 startParam 容器经常被省略。
90
+ // 现已拍平:task/slug 等 13 字段直接放在顶层。description 必须有完整平铺 JSON 正例,
91
+ // 让模型能直接照抄。强约束:startParam envelope 必须从 description 中彻底消失。
92
+ expect(DESCRIPTION).toContain('"action":"start","task"');
93
+ expect(DESCRIPTION).not.toContain('"startParam"');
92
94
  });
93
95
 
94
- it("Anti-patterns 段含参数结构反例(top level 平铺 task/slug)", () => {
95
- // 显式说明 task/slug 不能平铺到顶层,必须嵌在 startParam 里。
96
- expect(DESCRIPTION).toContain("top level");
96
+ it("cancel 示例 subagentId 用 sa- 连字符前缀(与 subagent-service.ts 实际生成格式一致)", () => {
97
+ // subagent-service.ts:600 生成 `sa-${crypto.randomUUID()}`(连字符)。
98
+ // description 示例必须与实际生成格式一致——弱模型会照抄示例,前缀错(如 sa_ 下划线)
99
+ // 会导致 subagentId 永远匹配不到真实 record。
100
+ expect(DESCRIPTION).toContain('"subagentId":"sa-');
101
+ expect(DESCRIPTION).not.toContain('"sa_');
102
+ });
103
+
104
+ it("agent 枚举(schema 字段 description)包含全部 9 个内置 agent(含 orchestrator,防漏)", () => {
105
+ // 包内有 9 个 agent .md(含 orchestrator)。schema 的 agent 字段 description 必须全部列出,
106
+ // 否则 LLM 无法选中未列出的 agent(功能回归)。cr-fix 防回归锁定。
107
+ // 注意:agent 列表在 schema field description 里,不在主 description: 模板字符串里——
108
+ // 断言源码全文(含 schema field description)而非 DESCRIPTION。
109
+ const expected = [
110
+ "general-purpose", "worker", "researcher", "explorer",
111
+ "planner", "reviewer", "oracle", "context-builder", "orchestrator",
112
+ ];
113
+ for (const name of expected) {
114
+ expect(SUBAGENT_TOOL_SRC).toContain(name);
115
+ }
116
+ });
117
+
118
+ it("Anti-patterns 段明确 list/cancel 仍 nested(防过度泛化 flatten)", () => {
119
+ // PR 只拍平 start,listParam/cancelParam 仍 nested。description 必须明确这一不对称性,
120
+ // 否则弱模型学了「subagent tool 现在平铺」会过度泛化发 {"action":"list","includeFinished":true}。
121
+ const apIdx = DESCRIPTION.indexOf("## Anti-patterns");
122
+ expect(apIdx).toBeGreaterThan(-1);
123
+ const afterAp = DESCRIPTION.slice(apIdx);
124
+ const nextSection = afterAp.indexOf("##", "## Anti-patterns".length);
125
+ const apSection = nextSection > -1 ? afterAp.slice(0, nextSection) : afterAp;
126
+ expect(apSection).toMatch(/list.*nested.*listParam|listParam.*nested/i);
97
127
  });
98
128
  });
99
129
 
100
130
  describe("subagent tool runtime handler — 错误文案含纠正正例", () => {
101
- // 读源码文本断言 executeSubagent 的平铺检测 throw 含 Correct 正例,
131
+ // 读源码文本断言 startHandler throw 含 Correct 正例,
102
132
  // 让弱模型撞错后第二次能直接照抄正确形态。
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");
133
+ // 拍平后:startParam envelope 删除,平铺 task/slug 是合法形态;
134
+ // 平铺检测 guard(hasFlattenedStartFields)已删除,源码不应再含此表达式。
135
+ it("subagent-actions.ts startHandler throw Correct 纠正正例(平铺形态)", () => {
136
+ const actionsSrc = readFileSync(
137
+ join(__dirname, "../subagent-actions.ts"),
138
+ "utf-8",
139
+ );
140
+ // 三处 throw(input 缺失 / task 空白 / slug 空白)都应含 Correct 正例。
141
+ // 用 occurrences 计数——至少 3 处。
142
+ const occurrences = (actionsSrc.match(/Correct: \{"action":"start"/g) ?? []).length;
143
+ expect(occurrences).toBeGreaterThanOrEqual(3);
144
+ });
145
+
146
+ it("平铺检测 guard(hasFlattenedStartFields)已从 subagent-tool.ts 删除", () => {
147
+ expect(SUBAGENT_TOOL_SRC).not.toContain('params.action === "start" && !params.startParam');
148
+ expect(SUBAGENT_TOOL_SRC).not.toContain("hasFlattenedStartFields");
106
149
  });
107
150
  });
@@ -0,0 +1,122 @@
1
+ // src/interface/__tests__/tool-render.test.ts
2
+ //
3
+ // renderSubagentCall 行为测试:拍平后从顶层 args 提取 agent/slug/task。
4
+ //
5
+ // 背景:wave 3 flatten 把 task/slug/agent 等 13 字段从 args.startParam 嵌套层
6
+ // 移到 args 顶层。renderSubagentCall 的提取逻辑跟着改了,但之前无行为测试覆盖
7
+ // (sdk-contract.test.ts 只断言 renderCall 是 function,不断言行为)。此测试
8
+ // 锁住「拍平形态的 args 能被 renderSubagentCall 正确提取」——若有人改回
9
+ // args.startParam 路径,测试立即红。
10
+ //
11
+ // 不走 registerSubagentTool 注册路径——renderSubagentCall 是纯函数,直接 import
12
+ // 测试,避免 mock pi-ai/typebox/pi-tui 整条链。
13
+
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import type { Component } from "@earendil-works/pi-tui";
17
+
18
+ import { type RenderContext, renderSubagentCall } from "../tool-render.ts";
19
+
20
+ // ── 最小 ThemeLike stub ──
21
+ // renderSubagentCall 只用 theme.fg/bold/dim(都是 (token, text) => string)。
22
+ // 不依赖真实 pi-tui 着色——我们只断言提取出的字符串出现在结果里。
23
+ function makeTheme(): {
24
+ fg(color: string, text: string): string;
25
+ bold(text: string): string;
26
+ } {
27
+ return {
28
+ // 把 token 作为 [token:...] 包裹器返回,便于断言时不依赖颜色映射。
29
+ fg: (_color, text) => `<${_color}>${text}</${_color}>`,
30
+ bold: (text) => `<b>${text}</b>`,
31
+ };
32
+ }
33
+
34
+ // Text.render() 是 pi-tui 的方法。tool-render 返回 new Text(parts.join(""), 0, 0)。
35
+ // 测试只关心 parts.join("") 的文本内容——用反射取构造时传入的字符串。
36
+ // Component 类型在 pi-tui 中是 opaque,这里用最小的反射 helper。
37
+ function renderText(component: Component): string {
38
+ // Text 实例在 pi-tui v0.x 把构造首参存为 .text 或私有字段;
39
+ // 通过遍历可枚举属性找到首个 string 字段(绕过具体字段名差异)。
40
+ const obj = component as unknown as Record<string, unknown>;
41
+ for (const key of Object.keys(obj)) {
42
+ const v = obj[key];
43
+ if (typeof v === "string" && v.length > 0) return v;
44
+ }
45
+ return "";
46
+ }
47
+
48
+ const CTX: RenderContext = {
49
+ state: {} as Record<string, never>,
50
+ invalidate: () => {},
51
+ };
52
+
53
+ describe("renderSubagentCall — 拍平形态提取(regression for wave 3 flatten)", () => {
54
+ it("从顶层 args 提取 agent(默认 general-purpose)", () => {
55
+ const out = renderText(renderSubagentCall(
56
+ { action: "start", task: "do stuff", slug: "x" },
57
+ makeTheme() as never,
58
+ CTX,
59
+ ));
60
+ // 默认 agent 名(DEFAULT_AGENT_NAME)出现在结果里
61
+ expect(out).toContain("general-purpose");
62
+ });
63
+
64
+ it("从顶层 args 提取显式 agent 名", () => {
65
+ const out = renderText(renderSubagentCall(
66
+ { action: "start", agent: "worker", task: "do stuff", slug: "x" },
67
+ makeTheme() as never,
68
+ CTX,
69
+ ));
70
+ expect(out).toContain("worker");
71
+ });
72
+
73
+ it("从顶层 args 提取 slug 并在 agent 后展示", () => {
74
+ const out = renderText(renderSubagentCall(
75
+ { action: "start", agent: "worker", task: "do stuff", slug: "fix-login" },
76
+ makeTheme() as never,
77
+ CTX,
78
+ ));
79
+ expect(out).toContain("worker");
80
+ expect(out).toContain("fix-login");
81
+ });
82
+
83
+ it("从顶层 args 提取 task 作为 preview 行(含换行)", () => {
84
+ const out = renderText(renderSubagentCall(
85
+ { action: "start", agent: "worker", task: "Analyze the bug in parser", slug: "fix-parser" },
86
+ makeTheme() as never,
87
+ CTX,
88
+ ));
89
+ // task preview 出现在结果里(首行非空,截断到 60 字符)
90
+ expect(out).toContain("Analyze the bug in parser");
91
+ });
92
+
93
+ it("task 含换行时只取首个非空行(不破坏单行渲染)", () => {
94
+ const out = renderText(renderSubagentCall(
95
+ { action: "start", task: "first line\nsecond line", slug: "x" },
96
+ makeTheme() as never,
97
+ CTX,
98
+ ));
99
+ expect(out).toContain("first line");
100
+ expect(out).not.toContain("second line");
101
+ });
102
+
103
+ // 关键回归:若有人把提取路径改回 args.startParam,这些顶层调用都会失败
104
+ // (agent/slug/task 取不到,全用默认值)。此测试用顶层数据形态锁住 flatten。
105
+ it("REGRESSION: 顶层 args 形态完整提取(防止回退到 startParam envelope)", () => {
106
+ const out = renderText(renderSubagentCall(
107
+ { action: "start", agent: "researcher", task: "search docs", slug: "search-docs" },
108
+ makeTheme() as never,
109
+ CTX,
110
+ ));
111
+ // 三个字段都应被提取(默认值 fallback 也能过单字段断言,但同时命中的
112
+ // 概率只有联合 fallback 才有——researcher/search-docs 都不是默认值)
113
+ expect(out).toContain("researcher");
114
+ expect(out).toContain("search-docs");
115
+ expect(out).toContain("search docs");
116
+ });
117
+
118
+ it("args 缺所有字段时不崩(最防御)", () => {
119
+ expect(() => renderSubagentCall({}, makeTheme() as never, CTX)).not.toThrow();
120
+ expect(() => renderSubagentCall(undefined, makeTheme() as never, CTX)).not.toThrow();
121
+ });
122
+ });
@@ -44,7 +44,7 @@ describe("U1: workflow tool prompt mentions built-in workflows", () => {
44
44
  expect(TOOL_WORKFLOW_SRC).toMatch(/workflow run .+--args/i);
45
45
  });
46
46
 
47
- it("promptGuidelines 含 JSON 调用正例(run/status/lifecycle/retry-node)", () => {
47
+ it("promptGuidelines 含 JSON 调用正例(run/status/lifecycle)", () => {
48
48
  // 弱模型信任 schema 结构信号 > 文本信号,容易把 args 子字段平铺到顶层。
49
49
  // promptGuidelines 必须有完整 JSON 调用正例,让模型能直接照抄 {"action":"run",...} 嵌套结构。
50
50
  expect(TOOL_WORKFLOW_SRC).toContain('{"action":"run"');
@@ -95,15 +95,19 @@ const SHORT_ID_BG_SEGMENTS = 3;
95
95
  /**
96
96
  * 从完整 record id 提取短编号用于列表展示.
97
97
  *
98
- * id 格式(subagent-service.ts:422 生成):
99
- * - sync: `run-${seq}` (如 run-1) → 原样(2 段)
100
- * - background: `bg-${tag}-${seq}-${ts}` (如 bg-f6f731-10-1719500000000)
101
- * 取前 3 段得 bg-f6f731-10(丢弃冗长时间戳)
102
- *
103
- * 按段数分支:sync2 段)原样返回;background(≥3 段)取前 3 段(bg/tag/seq)。
104
- * seq 进程内递增唯一,作为「编号」足够区分;完整 id(含时间戳)在右列预览给出供精确引用.
98
+ * id 格式:
99
+ * - subagent: `sa-<uuid>` (如 sa-550e8400-e29b-41d4-a716-446655440000)
100
+ * → sa- 前缀 + UUID 前 3 段(sa-550e8400-e29b-41d4)
101
+ * - workflow: `wf-<ts>-<rand>` (如 wf-1719500000000-a1b2c3,3 段)
102
+ * → 3 段 ≤ 2 不成立,取前 3 段 = 原样
103
+ * - sync: `run-${seq}` (如 run-1,2 段) 原样返回
104
+ * - 旧纯 UUID: `<uuid>` (5 ) → 取前 3 段(向后兼容)
105
105
  */
106
106
  export function shortId(id: string): string {
107
+ // sa- 前缀的 subagent ID:保留前缀 + UUID 前 3 段(与纯 UUID 的 3 段信息量等价)
108
+ if (id.startsWith("sa-")) {
109
+ return "sa-" + id.slice(3).split("-").slice(0, SHORT_ID_BG_SEGMENTS).join("-");
110
+ }
107
111
  const segments = id.split("-");
108
112
  if (segments.length <= SHORT_ID_SYNC_SEGMENTS) return id;
109
113
  return segments.slice(0, SHORT_ID_BG_SEGMENTS).join("-");
@@ -49,7 +49,9 @@ const SUBAGENT_ID_PREVIEW = 8;
49
49
  // 入参 / 出参类型
50
50
  // ============================================================
51
51
 
52
- /** start 入参(从 tool params.startParam 来,task + slug 必填)。 */
52
+ /** start 入参(拍平后从 tool params 顶层来,task + slug 必填)。
53
+ * StartHandlerInput 是 SubagentExecuteParams 的子集(13 字段全 optional);
54
+ * 调用方传整个 params(含 action/listParam/cancelParam),多余字段被忽略。 */
53
55
  export interface StartHandlerInput {
54
56
  task?: string;
55
57
  /** 短标签(≤35 字符,kebab-case),必填。 */
@@ -137,14 +139,23 @@ export async function startHandler(
137
139
  signal: AbortSignal | undefined,
138
140
  ctxModel?: ModelInfo,
139
141
  ): Promise<StartHandlerResult> {
140
- if (!input) throw new Error("startParam is required for action:'start'");
142
+ if (!input) throw new Error(
143
+ "action:'start' requires task and slug (top-level fields). " +
144
+ 'Correct: {"action":"start","task":"<your task>","slug":"<kebab-case>"}',
145
+ );
141
146
  // task 必填 + 空白校验(G-008)
142
147
  const task = input.task?.trim();
143
- if (!task) throw new Error("startParam.task is required (and must not be whitespace-only)");
148
+ if (!task) throw new Error(
149
+ "task is required for action:'start' (top-level field, must not be whitespace-only). " +
150
+ 'Correct: {"action":"start","task":"...","slug":"..."}',
151
+ );
144
152
  // slug 必填 + 空白校验 + 长度校验(≤ SLUG_MAX_LENGTH 字符)
145
153
  const slug = input.slug?.trim();
146
- if (!slug) throw new Error("startParam.slug is required (and must not be whitespace-only)");
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".`);
154
+ if (!slug) throw new Error(
155
+ "slug is required for action:'start' (top-level field, must not be whitespace-only). " +
156
+ 'Correct: {"action":"start","task":"...","slug":"<kebab-case>"}',
157
+ );
158
+ if (slug.length > SLUG_MAX_LENGTH) throw new Error(`slug must be ≤${SLUG_MAX_LENGTH} chars (got ${slug.length}). Shorten to a kebab-case label, e.g. "fix-login", "extract-urls".`);
148
159
 
149
160
  const handle = await service.execute({
150
161
  task,
@@ -31,23 +31,6 @@ import { type RenderContext,renderSubagentCall, renderSubagentResult } from "./t
31
31
  * 无法从 SubagentParams schema 反向推断参数类型)。
32
32
  * action 与对应 param 不匹配时 handler 内 throw。
33
33
  */
34
- interface StartParam {
35
- task: string;
36
- /** 短标签(≤35 字符,kebab-case),必填。展示在 TUI 标题行/列表。 */
37
- slug: string;
38
- agent?: string;
39
- model?: string;
40
- thinkingLevel?: string;
41
- skillPath?: string;
42
- appendSystemPrompt?: string[];
43
- schema?: Record<string, unknown>;
44
- maxTurns?: number;
45
- graceTurns?: number;
46
- fork?: boolean;
47
- worktree?: boolean;
48
- cwd?: string;
49
- }
50
-
51
34
  interface ListParam {
52
35
  includeFinished?: boolean;
53
36
  limit?: number;
@@ -59,7 +42,22 @@ interface CancelParam {
59
42
 
60
43
  interface SubagentExecuteParams {
61
44
  action: "start" | "list" | "cancel";
62
- startParam?: StartParam;
45
+ // action:"start" 的 13 字段拍平到顶层(弱模型常省略 startParam 嵌套层导致调用失败)。
46
+ // 拍平后这些字段直接在顶层(全部 optional——schema flat 无法表达「action 条件必填」,
47
+ // 由 startHandler runtime 校验 task/slug 必填)。
48
+ task?: string;
49
+ slug?: string;
50
+ agent?: string;
51
+ model?: string;
52
+ thinkingLevel?: string;
53
+ skillPath?: string;
54
+ appendSystemPrompt?: string[];
55
+ schema?: Record<string, unknown>;
56
+ maxTurns?: number;
57
+ graceTurns?: number;
58
+ fork?: boolean;
59
+ worktree?: boolean;
60
+ cwd?: string;
63
61
  listParam?: ListParam;
64
62
  cancelParam?: CancelParam;
65
63
  }
@@ -89,54 +87,56 @@ type SubagentRenderResultCb = (
89
87
 
90
88
  // Params schema(模块内消费,未导出)。
91
89
  //
92
- // TODO(long-term, option-A): startParam/listParam/cancelParam 全标 Optional 是 flat
93
- // JSON Schema 表达「action 分发的条件必填」的妥协——required[] 只能表达静态必填,
94
- // 无法表达「action:"start" startParam 必填、action:"list" 时不需要」。长期方案是
95
- // 拆成 3 个独立 tool(subagent_start / subagent_list / subagent_cancel),让每个 tool
96
- // schema 真实反映必填性,消除全新上下文下的字段误判。当前靠 description 强标记 +
97
- // runtime guard(subagent-actions.ts startHandler/cancelHandler throw)兜底。
98
- // 勿在此基础上继续堆 action 条件逻辑——要加就拆 tool。
90
+ // action:"start" 的 13 字段(task/slug/agent/model/...)拍平在顶层,不再用 startParam
91
+ // 嵌套容器包。原因:弱模型(GLM/DeepSeek)信任 schema 结构信号 > 文本信号,经常省略
92
+ // startParam 嵌套层把 task/slug 直接平铺到顶层导致调用失败。拍平后 schema 结构与模型
93
+ // 的自然倾向一致,消除这层误用。task/slug 必填性由 startHandler runtime 校验(flat
94
+ // JSON Schema 无法表达「action 条件必填」)。
95
+ //
96
+ // TODO(long-term, option-A): listParam/cancelParam 仍标 Optional 也是 flat JSON Schema
97
+ // 表达「action 分发条件必填」的妥协——长期方案是拆成 3 个独立 tool
98
+ // (subagent_start / subagent_list / subagent_cancel),让每个 tool 的 schema 真实
99
+ // 反映必填性。勿在此基础上继续堆 action 条件逻辑——要加就拆 tool。
99
100
  const SubagentParams = Type.Object({
100
101
  action: StringEnum(["start", "list", "cancel"], {
101
102
  description: "Operation: 'start' runs a subagent, 'list' shows running subagents (optional includeFinished), 'cancel' stops a background subagent by id.",
102
103
  }),
103
- // action:"start" startParam REQUIRED. Missing/empty task or slug throws at runtime.
104
+ // ── action:"start" fields (flattened to top level). task/slug REQUIRED for start. ──
105
+ // Missing/empty task or slug throws at runtime (startHandler).
104
106
  // (flat JSON Schema can't express conditional requirement — see file-level TODO.)
105
- startParam: Type.Optional(Type.Object({
106
- task: Type.String({
107
- description: "REQUIRED for action:'start'. The task for the subagent to execute. Throws if missing or whitespace-only.",
108
- }),
109
- slug: Type.String({
110
- description:
111
- "REQUIRED for action:'start'. Short label (≤35 chars) for this subagent, e.g. 'fix-login', 'extract-urls'. " +
112
- "Shown in TUI to distinguish concurrent subagents.",
113
- maxLength: SLUG_MAX_LENGTH,
114
- }),
115
- agent: Type.Optional(Type.String({
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.',
117
- })),
118
- model: Type.Optional(Type.String({
119
- 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.',
120
- })),
121
- thinkingLevel: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const)),
122
- skillPath: Type.Optional(Type.String()),
123
- appendSystemPrompt: Type.Optional(Type.Array(Type.String())),
124
- schema: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
125
- maxTurns: Type.Optional(Type.Number({
126
- description: "Turn limit. The subagent is terminated via SIGTERM after maxTurns turn_end events + graceTurns of slack. There is no graceful wrap-up message — the process is killed. 0 or omitted = unlimited.",
127
- })),
128
- graceTurns: Type.Optional(Type.Number({
129
- description: "Extra turns allowed after maxTurns is reached before SIGTERM (default 2). Only meaningful when maxTurns is set.",
130
- })),
131
- fork: Type.Optional(Type.Boolean({
132
- description: "Fork mode: inherit the parent's conversation context. When true, the subagent receives the parent's session file via --fork and builds a branched conversation (it sees prior turns/messages). The subagent still runs in a separate spawned child process (process isolation) — fork is about context inheritance, not process sharing. Use worktree:true (requires fork:true) for file-system isolation.",
133
- })),
134
- worktree: Type.Optional(Type.Boolean({
135
- description: "Worktree isolation (requires fork:true): run the subagent in a dedicated git worktree, providing file-system level isolation from the parent session. Prevents concurrent file-write conflicts between parent and subagent. Only takes effect when fork:true; passing worktree:true without fork:true throws an error.",
136
- })),
137
- cwd: Type.Optional(Type.String({
138
- description: 'Override the working directory for the subagent execution. Must be an absolute path. Defaults to the parent session\'s cwd.',
139
- })),
107
+ task: Type.Optional(Type.String({
108
+ description: "REQUIRED for action:'start'. The task for the subagent to execute. Throws if missing or whitespace-only.",
109
+ })),
110
+ slug: Type.Optional(Type.String({
111
+ description:
112
+ "REQUIRED for action:'start'. Short label (≤35 chars) for this subagent, e.g. 'fix-login', 'extract-urls'. " +
113
+ "Shown in TUI to distinguish concurrent subagents.",
114
+ maxLength: SLUG_MAX_LENGTH,
115
+ })),
116
+ agent: Type.Optional(Type.String({
117
+ 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.',
118
+ })),
119
+ model: Type.Optional(Type.String({
120
+ 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.',
121
+ })),
122
+ thinkingLevel: Type.Optional(StringEnum(["off", "minimal", "low", "medium", "high", "xhigh"] as const)),
123
+ skillPath: Type.Optional(Type.String()),
124
+ appendSystemPrompt: Type.Optional(Type.Array(Type.String())),
125
+ schema: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
126
+ maxTurns: Type.Optional(Type.Number({
127
+ description: "Turn limit. The subagent is terminated via SIGTERM after maxTurns turn_end events + graceTurns of slack. There is no graceful wrap-up message — the process is killed. 0 or omitted = unlimited.",
128
+ })),
129
+ graceTurns: Type.Optional(Type.Number({
130
+ description: "Extra turns allowed after maxTurns is reached before SIGTERM (default 2). Only meaningful when maxTurns is set.",
131
+ })),
132
+ fork: Type.Optional(Type.Boolean({
133
+ description: "Fork mode: inherit the parent's conversation context. When true, the subagent receives the parent's session file via --fork and builds a branched conversation (it sees prior turns/messages). The subagent still runs in a separate spawned child process (process isolation) — fork is about context inheritance, not process sharing. Use worktree:true (requires fork:true) for file-system isolation.",
134
+ })),
135
+ worktree: Type.Optional(Type.Boolean({
136
+ description: "Worktree isolation (requires fork:true): run the subagent in a dedicated git worktree, providing file-system level isolation from the parent session. Prevents concurrent file-write conflicts between parent and subagent. Only takes effect when fork:true; passing worktree:true without fork:true throws an error.",
137
+ })),
138
+ cwd: Type.Optional(Type.String({
139
+ description: 'Override the working directory for the subagent execution. Must be an absolute path. Defaults to the parent session\'s cwd.',
140
140
  })),
141
141
  // action:"list" → listParam OPTIONAL (all fields optional, defaults apply). Ignored by other actions.
142
142
  listParam: Type.Optional(Type.Object({
@@ -171,22 +171,8 @@ function isModelOverrideObj(a: unknown): a is { model?: unknown; thinkingLevel?:
171
171
  return typeof a === "object" && a !== null;
172
172
  }
173
173
 
174
- /** unknown args 是否含 startParam(类型守卫,替代 `in` 后的 `as`)。 */
175
- function hasStartParam(a: unknown): a is { startParam?: unknown } {
176
- return typeof a === "object" && a !== null && "startParam" in a;
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
-
189
- /** 从 unknown args 安全提取 model/thinkingLevel override(传给 resolveModel)。 */
174
+ /** unknown args 安全提取 model/thinkingLevel override(传给 resolveModel)。
175
+ * 拍平后 args 已是顶层平铺结构(model/thinkingLevel 直接在 args 上)。 */
190
176
  function extractModelOverride(args: unknown): { model?: string; thinkingLevel?: string } | undefined {
191
177
  if (!isModelOverrideObj(args)) return undefined;
192
178
  const override: { model?: string; thinkingLevel?: string } = {};
@@ -214,17 +200,17 @@ Delegate when the task needs a distinct role (researcher/worker), context isolat
214
200
 
215
201
  ## Actions
216
202
 
217
- - action:"start" — run a subagent. REQUIRED startParam: { task, slug, ... } (task and slug REQUIRED). Background only: returns a subagentId immediately, notifies on completion.
203
+ - action:"start" — run a subagent. Pass task and slug as top-level fields (REQUIRED). Optional: agent, model, thinkingLevel, skillPath, appendSystemPrompt, schema, maxTurns, graceTurns, fork, worktree, cwd. Background only: returns a subagentId immediately, notifies on completion.
218
204
  - action:"list" — list subagents. Pass listParam: { includeFinished?, limit? } (all optional). Read an item's sessionFile for full detail.
219
205
  - action:"cancel" — cancel a background subagent. REQUIRED cancelParam: { subagentId }.
220
206
 
221
207
  ## Examples
222
208
 
223
209
  \`\`\`
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}}
210
+ {"action":"start","task":"<your task>","slug":"<kebab-case>"}
211
+ {"action":"start","task":"...","slug":"fix-login","agent":"worker","model":"anthropic/claude-3.5-sonnet","fork":true}
226
212
  {"action":"list","listParam":{"includeFinished":false,"limit":20}}
227
- {"action":"cancel","cancelParam":{"subagentId":"sa_abc123"}}
213
+ {"action":"cancel","cancelParam":{"subagentId":"sa-550e8400"}}
228
214
  \`\`\`
229
215
 
230
216
  ## After launching — do NOT wait
@@ -237,7 +223,8 @@ Completion auto-notifies you (steer wakes next turn, even mid-poll). So:
237
223
 
238
224
  ## Anti-patterns
239
225
 
240
- - Putting task/slug at the top level instead of inside startParamthe tool reads startParam.task, not a top-level task.
226
+ - Forgetting the REQUIRED top-level task/slug fields for action:"start"both must be present at the top level (not nested).
227
+ - Over-generalizing the flatten: ONLY start fields are top-level. list and cancel params stay nested under listParam / cancelParam (e.g. {"action":"list","listParam":{"includeFinished":true}}, NOT {"action":"list","includeFinished":true}).
241
228
  - Launching background, then sleeping/polling instead of working or stopping.
242
229
  - Treating subagent results as authoritative without verification.
243
230
  - Delegating trivial tasks you could do faster yourself.
@@ -278,9 +265,10 @@ const subagentRenderCall: SubagentRenderCallCb = (args, theme, ctx) => {
278
265
  // 主 agent model 由 ModelConfigService 缓存(session_start 注入,model_select 刷新),
279
266
  // 补偿 renderCall 的 ToolRenderContext 不含 model 的 SDK 限制。
280
267
  // service 未就绪 / 缓存为空 / 解析失败 → 降级不显示 model。
281
- const startParam = hasStartParam(args) ? args.startParam : undefined;
282
- const agent = extractAgentName(startParam);
283
- const override = extractModelOverride(startParam);
268
+ // 拍平后 args 已是顶层平铺结构(agent/model/thinkingLevel 直接在 args 上),
269
+ // extractAgentName / extractModelOverride 都是 unknown-safe 顶层读取,对平铺形态天然兼容。
270
+ const agent = extractAgentName(args);
271
+ const override = extractModelOverride(args);
284
272
  let resolved: { model: string; thinkingLevel?: string } | undefined;
285
273
  try {
286
274
  const service = getSubagentService();
@@ -309,7 +297,7 @@ const subagentRenderResult: SubagentRenderResultCb = (result, options, theme, ct
309
297
  * ║ service = getSubagentService() —— 未初始化 throw ║
310
298
  * ║ ║
311
299
  * ║ switch(params.action): ║
312
- * ║ "start" → startHandler(service, params.startParam, signal) → 领域对象
300
+ * ║ "start" → startHandler(service, params, signal) → 领域对象
313
301
  * ║ "list" → listHandler(service, params.listParam) → 领域对象 ║
314
302
  * ║ "cancel" → cancelHandler(service, params.cancelParam) → 领域对象║
315
303
  * ║ ║
@@ -317,6 +305,10 @@ const subagentRenderResult: SubagentRenderResultCb = (result, options, theme, ct
317
305
  * ║ return { content: [{text: JSON.stringify(result)}], details: result }║
318
306
  * ╚══════════════════════════════════════════════════════════════════╝
319
307
  *
308
+ * 拍平后 startHandler 直接接收顶层 params(13 字段已在顶层)。startHandler 的入参
309
+ * 类型 StartHandlerInput 是 SubagentExecuteParams 的子集(13 字段全 optional),
310
+ * 结构兼容——SubagentExecuteParams 多出的 action/listParam/cancelParam 被忽略。
311
+ *
320
312
  * handler 返回纯领域对象(不碰 {content, details}),adapter 唯一包装。
321
313
  * content(JSON 字符串)给 LLM,details(领域对象 + action)给 renderResult,同源。
322
314
  */
@@ -332,20 +324,11 @@ const executeSubagent: SubagentExecuteCb = async (
332
324
  const service = getSubagentService();
333
325
  if (!service) throw new Error("subagents runtime not initialized");
334
326
 
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
-
346
327
  switch (params.action) {
347
328
  case "start":
348
- return adapter({ action: "start", domain: await startHandler(service, params.startParam, signal, _ctx?.model) }, toGuiCtx(_ctx));
329
+ // 拍平后直接传顶层 params(StartHandlerInput SubagentExecuteParams 子集,
330
+ // action/listParam/cancelParam 被忽略;task/slug 必填性由 startHandler 校验)。
331
+ return adapter({ action: "start", domain: await startHandler(service, params, signal, _ctx?.model) }, toGuiCtx(_ctx));
349
332
  case "list":
350
333
  return adapter({ action: "list", domain: listHandler(service, params.listParam) }, toGuiCtx(_ctx));
351
334
  case "cancel":
@@ -92,15 +92,13 @@ export function renderSubagentCall(
92
92
  resolved?: { model: string; thinkingLevel?: string },
93
93
  ): Component {
94
94
  const t = theme as ThemeLike;
95
- // args 结构:{ action:"start", startParam:{ agent, task, ... } }(见 subagent-tool.ts schema)。
96
- // startParam 提取 agent + task,对齐 nicobailon renderCall 多行布局。
97
- const startParam = typeof args === "object" && args !== null && "startParam" in args
98
- ? (args as { startParam?: unknown }).startParam
99
- : undefined;
100
- const agent = extractAgentName(startParam);
101
- // slug:从 startParam 提取(必填字段),非空时在 agent 后用 · 分隔展示。
102
- const slug = typeof startParam === "object" && startParam !== null && "slug" in startParam
103
- ? (startParam as { slug?: unknown }).slug
95
+ // args 结构(拍平后):{ action:"start", agent, task, slug, ... }(见 subagent-tool.ts schema)。
96
+ // 13 字段直接在顶层,extractAgentName / slug / task 都从 args 顶层提取,
97
+ // 对齐 nicobailon renderCall 多行布局。
98
+ const agent = extractAgentName(args);
99
+ // slug:从顶层 args 提取(必填字段),非空时在 agent 后用 · 分隔展示。
100
+ const slug = typeof args === "object" && args !== null && "slug" in args
101
+ ? (args as { slug?: unknown }).slug
104
102
  : undefined;
105
103
  const slugStr = typeof slug === "string" ? slug.trim() : "";
106
104
  const parts = slugStr
@@ -122,8 +120,8 @@ export function renderSubagentCall(
122
120
  // task preview 行——对齐 nicobailon:renderCall 输出多行(标题 + \n + task 预览)。
123
121
  // 实验假设:call 多行让首帧(无 result)与后续帧(有 result)的高度跳变模式
124
122
  // 与 nicobailon 一致,可能影响 pi diff 引擎的行对齐路径。preview 截断到 60 字符。
125
- const task = typeof startParam === "object" && startParam !== null && "task" in startParam
126
- ? (startParam as { task?: unknown }).task
123
+ const task = typeof args === "object" && args !== null && "task" in args
124
+ ? (args as { task?: unknown }).task
127
125
  : undefined;
128
126
  if (typeof task === "string" && task.length > 0) {
129
127
  // task 取首行——prompt 常含换行(多行指令),直接 slice 会保留 \n,