@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
@@ -1,11 +1,52 @@
1
1
  // workflow-list-injector 单测
2
2
  //
3
- // 覆盖纯函数:summarizeDescription(截断)+ parseWorkflowMeta(meta 块解析)+
4
- // formatWorkflowList(B2 注入段格式 + 引导语)。discoverAllWorkflows 依赖文件系统
5
- // + resource-discovery,属集成层,此处聚焦可快速回归的格式化契约。
3
+ // 两类覆盖(与 subagent-list-injector.test.ts 对称):
4
+ // 1. 纯函数:summarizeDescription(截断)+ parseWorkflowMeta(meta 块解析)+
5
+ // formatWorkflowList(B2 注入段格式 + 引导语)。discoverAllWorkflows 依赖文件系统
6
+ // + resource-discovery,属集成层,此处聚焦可快速回归的格式化契约(TC5 回归保护)。
7
+ // 2. session 级缓存行为(TC1-TC4):与 subagent 对称,mock shared/resource-discovery
8
+ // 的 discoverResources + getCachedFileContent,mock pi.on 捕获三 handler 手动触发;
9
+ // 模块级缓存靠 vi.resetModules + 动态 import 重置。
6
10
 
7
- import { describe, expect, it } from "vitest";
11
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
12
 
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+
15
+ import type { DiscoveredResource } from "../../shared/resource-discovery.ts";
16
+
17
+ // ── 稳定 spy(vi.hoisted 保证 resetModules 后引用不变,见 subagent 测试同款注释) ──
18
+ const spies = vi.hoisted(() => ({
19
+ discoverResources: vi.fn(),
20
+ getCachedFileContent: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("../../shared/resource-discovery.ts", () => ({
24
+ discoverResources: spies.discoverResources,
25
+ findWorkspaceRoot: () => "/ws",
26
+ getCachedFileContent: spies.getCachedFileContent,
27
+ }));
28
+
29
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
30
+ getLogger: () => ({
31
+ debug: () => {
32
+ /* no-op */
33
+ },
34
+ info: () => {
35
+ /* no-op */
36
+ },
37
+ warn: () => {
38
+ /* no-op */
39
+ },
40
+ error: () => {
41
+ /* no-op */
42
+ },
43
+ }),
44
+ setPiHandle: () => {
45
+ /* no-op */
46
+ },
47
+ }));
48
+
49
+ // ── 纯函数测试:静态 import(模块级缓存状态不影响纯函数) ──
9
50
  import {
10
51
  formatWorkflowList,
11
52
  parseWorkflowMeta,
@@ -34,28 +75,31 @@ describe("summarizeDescription", () => {
34
75
  });
35
76
 
36
77
  describe("parseWorkflowMeta", () => {
37
- it("从 meta 块解析 name + description", () => {
78
+ it("从 @pi-meta 块解析 name + description", () => {
38
79
  const src = `// header comment
39
- const meta = {
40
- name: "chain",
41
- description: "通用编排:三步链",
42
- phases: ["a", "b"],
43
- };
80
+ /* @pi-meta
81
+ name: chain
82
+ description: 通用编排:三步链
83
+ phases: [a, b]
84
+ */
44
85
  rest of code`;
45
86
  expect(parseWorkflowMeta(src)).toEqual({
46
87
  name: "chain",
47
88
  description: "通用编排:三步链",
89
+ path: "",
48
90
  });
49
91
  });
50
92
 
51
- it("单引号包裹的值也能解析", () => {
52
- const src = `const meta = {
53
- name: 'parallel',
54
- description: '多视角并行',
55
- };`;
93
+ it("双引号包裹的值也能解析", () => {
94
+ const src = `/* @pi-meta
95
+ name: "parallel"
96
+ description: "多视角并行"
97
+ phases: []
98
+ */`;
56
99
  expect(parseWorkflowMeta(src)).toEqual({
57
100
  name: "parallel",
58
101
  description: "多视角并行",
102
+ path: "",
59
103
  });
60
104
  });
61
105
 
@@ -63,14 +107,14 @@ rest of code`;
63
107
  expect(parseWorkflowMeta("// no meta here")).toBeNull();
64
108
  });
65
109
 
66
- it("meta 块缺 name 或 description 返回 null", () => {
67
- expect(parseWorkflowMeta('const meta = { name: "x" };')).toBeNull();
68
- expect(parseWorkflowMeta('const meta = { description: "x" };')).toBeNull();
110
+ it("@pi-meta name 或 description 返回 null", () => {
111
+ expect(parseWorkflowMeta("/* @pi-meta\ndescription: x\nphases: []\n*/")).toBeNull();
112
+ expect(parseWorkflowMeta("/* @pi-meta\nname: x\nphases: []\n*/")).toBeNull();
69
113
  });
70
114
 
71
115
  it("超长 description 被截断为摘要", () => {
72
116
  const longDesc = "详".repeat(300);
73
- const src = `const meta = {\n name: "rfl",\n description: "${longDesc}",\n};`;
117
+ const src = `/* @pi-meta\nname: rfl\ndescription: ${longDesc}\nphases: []\n*/`;
74
118
  const r = parseWorkflowMeta(src);
75
119
  expect(r).not.toBeNull();
76
120
  expect(r!.name).toBe("rfl");
@@ -78,14 +122,14 @@ rest of code`;
78
122
  });
79
123
 
80
124
  it("review-fix-loop 风格的 meta(长 description 含关键 args)被合理截断", () => {
81
- const src = `const meta = {
82
- name: "review-fix-loop",
83
- description: "审查-修复循环:多批串行(批内并行 review → aggregate → fix → 重审直到 clean)。必填 targetType(git-diff/file/dir/text)+ target。批次由必填参数 batch1..batchN 控制(无默认,至少传一个;agents 为单批简写;如 batch1=fallow-scan batch2=reviewer)。更多细节省略。",
84
- };`;
125
+ const src = `/* @pi-meta
126
+ name: review-fix-loop
127
+ description: 审查-修复循环:多批串行(批内并行 review → aggregate → fix → 重审直到 clean)。必填 targetType(git-diff/file/dir/text)+ target。批次由必填参数 batch1..batchN 控制(无默认,至少传一个;agents 为单批简写;如 batch1=fallow-scan batch2=reviewer)。更多细节省略。
128
+ phases: [Review, Fix]
129
+ */`;
85
130
  const r = parseWorkflowMeta(src);
86
131
  expect(r).not.toBeNull();
87
132
  expect(r!.name).toBe("review-fix-loop");
88
- // 截断后仍含关键 args 信息(targetType)
89
133
  expect(r!.description).toContain("targetType");
90
134
  expect(r!.description.length).toBeLessThanOrEqual(161);
91
135
  });
@@ -98,31 +142,191 @@ describe("formatWorkflowList", () => {
98
142
 
99
143
  it("用 <available_workflows> 标签包裹并列出每个 workflow", () => {
100
144
  const out = formatWorkflowList([
101
- { name: "chain", description: "三步链" },
102
- { name: "parallel", description: "并行分析" },
145
+ { name: "chain", description: "三步链", path: "/workflows/chain.js" },
146
+ { name: "parallel", description: "并行分析", path: "/workflows/parallel.js" },
103
147
  ]);
104
148
  expect(out).toContain("<available_workflows>");
105
149
  expect(out).toContain("</available_workflows>");
106
150
  expect(out).toContain("<name>chain</name>");
107
151
  expect(out).toContain("<description>三步链</description>");
108
152
  expect(out).toContain("<name>parallel</name>");
153
+ // S1:每项含 <location> 完整路径(agentRef,模型直接引用)
154
+ expect(out).toContain("<location>/workflows/chain.js</location>");
155
+ expect(out).toContain("<location>/workflows/parallel.js</location>");
109
156
  });
110
157
 
111
158
  it("包含 'Do NOT call list to discover available workflows' 引导语", () => {
112
- const out = formatWorkflowList([{ name: "chain", description: "d" }]);
159
+ const out = formatWorkflowList([{ name: "chain", description: "d", path: "/workflows/chain.js" }]);
113
160
  expect(out).toContain("Do NOT call list to discover available workflows");
114
161
  expect(out).toContain("use list only for running state");
115
162
  });
116
163
 
117
- it("点名 builtin workflow 可直接 run", () => {
118
- const out = formatWorkflowList([{ name: "chain", description: "d" }]);
119
- expect(out).toContain("run directly");
120
- expect(out).toContain("review-fix-loop");
164
+ it("引导语通用化:不写死内置 workflow 名,含 info 回收指引", () => {
165
+ const out = formatWorkflowList([{ name: "chain", description: "d", path: "/workflows/chain.js" }]);
166
+ expect(out).toContain("All listed workflows run directly via action:run");
167
+ expect(out).toContain("read the <location> script file");
168
+ // 通用化约束:引导语不点名具体 workflow(名字由 @pi-meta 动态注入,
169
+ // 写死内置名会在新增/移除 workflow 时与列表漂移)
170
+ expect(out).not.toMatch(/review-fix-loop|scatter-gather|map-reduce/);
121
171
  });
122
172
 
123
173
  it("转义 XML 特殊字符", () => {
124
- const out = formatWorkflowList([{ name: "a&b", description: "<x>" }]);
174
+ const out = formatWorkflowList([{ name: "a&b", description: "<x>", path: "/workflows/a&b.js" }]);
125
175
  expect(out).toContain("<name>a&amp;b</name>");
126
176
  expect(out).toContain("&lt;x&gt;");
127
177
  });
128
178
  });
179
+
180
+ // ──────────────────────────────────────────────────────────────
181
+ // session 级缓存行为(TC1-TC4,与 subagent 对称)
182
+ // ──────────────────────────────────────────────────────────────
183
+
184
+ /** before_agent_start handler 的返回结构(取 systemPrompt 断言)。 */
185
+ interface BeforeAgentResult {
186
+ systemPrompt: string;
187
+ }
188
+
189
+ /** 三 handler 捕获引用(setupWorkflowListInjector 注册后填充)。 */
190
+ interface CapturedHandlers {
191
+ sessionStart?: (event: unknown, ctx: unknown) => Promise<void> | void;
192
+ beforeAgentStart?: (event: { systemPrompt: string }, ctx: unknown) => Promise<BeforeAgentResult | void> | BeforeAgentResult | void;
193
+ sessionShutdown?: (event: unknown, ctx: unknown) => void;
194
+ }
195
+
196
+ /** 构造 mock pi:捕获三 handler 引用,其余 prop 走 noop(仅 on 被调用)。 */
197
+ function createMockPi(handlers: CapturedHandlers): ExtensionAPI {
198
+ const on = (event: string, handler: (...args: unknown[]) => unknown): void => {
199
+ if (event === "session_start") {
200
+ handlers.sessionStart = handler as CapturedHandlers["sessionStart"];
201
+ } else if (event === "before_agent_start") {
202
+ handlers.beforeAgentStart = handler as CapturedHandlers["beforeAgentStart"];
203
+ } else if (event === "session_shutdown") {
204
+ handlers.sessionShutdown = handler as CapturedHandlers["sessionShutdown"];
205
+ }
206
+ };
207
+ const noop = (): void => {
208
+ /* mock */
209
+ };
210
+ return { on, appendEntry: noop, registerTool: noop, registerCommand: noop, registerMessageRenderer: noop, events: { emit: noop, on: noop } } as unknown as ExtensionAPI;
211
+ }
212
+
213
+ /** 最小 ctx mock(注入器只读 ctx.cwd)。 */
214
+ function createMockCtx(): Record<string, unknown> {
215
+ return { cwd: "/ws", mode: "tui" };
216
+ }
217
+
218
+ /** fixture:单个 workflow 的 DiscoveredResource。 */
219
+ function workflowResource(path: string): DiscoveredResource {
220
+ return { path, source: "project-pi-tmp", available: true };
221
+ }
222
+
223
+ /** fixture:workflow .js @pi-meta 内容。 */
224
+ function workflowJs(name: string, description: string): string {
225
+ return `/* @pi-meta\nname: ${name}\ndescription: ${description}\nphases: [a]\n*/\nrest`;
226
+ }
227
+
228
+ describe("workflow-list-injector session 级缓存", () => {
229
+ let setupWorkflowListInjector: typeof import("../workflow-list-injector").setupWorkflowListInjector;
230
+ let handlers: CapturedHandlers;
231
+
232
+ beforeEach(async () => {
233
+ // resetModules + 动态 import:拿 fresh 模块实例 → 模块级 workflowCache 重置为 null
234
+ vi.resetModules();
235
+ spies.discoverResources.mockReset();
236
+ spies.getCachedFileContent.mockReset();
237
+ spies.discoverResources.mockResolvedValue([]);
238
+ spies.getCachedFileContent.mockReturnValue(null);
239
+ handlers = {};
240
+ const mod = await import("../workflow-list-injector");
241
+ setupWorkflowListInjector = mod.setupWorkflowListInjector;
242
+ setupWorkflowListInjector(createMockPi(handlers));
243
+ });
244
+
245
+ it("TC1: session_start 发现+缓存后,两次 before_agent_start 命中缓存(discoverResources 只调 1 次)", async () => {
246
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/chain.js")]);
247
+ spies.getCachedFileContent.mockReturnValue(workflowJs("chain", "三步链"));
248
+
249
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
250
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
251
+
252
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
253
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
254
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
255
+
256
+ expect(r1?.systemPrompt).toContain("<available_workflows>");
257
+ expect(r1?.systemPrompt).toContain("<name>chain</name>");
258
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
259
+ });
260
+
261
+ it("TC2: session_shutdown 清缓存后 before_agent_start miss → fallback 重新发现+缓存", async () => {
262
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/chain.js")]);
263
+ spies.getCachedFileContent.mockReturnValue(workflowJs("chain", "三步链"));
264
+
265
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
266
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
267
+
268
+ handlers.sessionShutdown!({ type: "session_shutdown", reason: "quit" }, createMockCtx());
269
+
270
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
271
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
272
+ expect(r1?.systemPrompt).toContain("<name>chain</name>");
273
+
274
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
275
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
276
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
277
+ });
278
+
279
+ it("TC3: 无 session_start 直接 before_agent_start(miss fallback)→ 发现+缓存", async () => {
280
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/chain.js")]);
281
+ spies.getCachedFileContent.mockReturnValue(workflowJs("chain", "三步链"));
282
+
283
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
284
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
285
+
286
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
287
+ expect(r1?.systemPrompt).toContain("<name>chain</name>");
288
+ expect(r2?.systemPrompt).toBe(r1?.systemPrompt);
289
+ });
290
+
291
+ it("TC4: session_start(reload) 覆盖缓存(新资源生效)", async () => {
292
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/chain.js")]);
293
+ spies.getCachedFileContent.mockReturnValue(workflowJs("chain", "三步链"));
294
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
295
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
296
+
297
+ // 资源变化:改返回 parallel
298
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/parallel.js")]);
299
+ spies.getCachedFileContent.mockReturnValue(workflowJs("parallel", "并行分析"));
300
+
301
+ await handlers.sessionStart!({ type: "session_start", reason: "reload" }, createMockCtx());
302
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
303
+
304
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
305
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
306
+ expect(r?.systemPrompt).toContain("<name>parallel</name>");
307
+ expect(r?.systemPrompt).not.toContain("chain");
308
+ });
309
+
310
+ it("session_start 发现空列表缓存后 before_agent_start 命中(不重扫,不注入)", async () => {
311
+ spies.discoverResources.mockResolvedValue([]);
312
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
313
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
314
+
315
+ const r1 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
316
+ const r2 = await handlers.beforeAgentStart!({ systemPrompt: "base" }, createMockCtx());
317
+ expect(spies.discoverResources).toHaveBeenCalledTimes(1);
318
+ expect(r1).toBeUndefined();
319
+ expect(r2).toBeUndefined();
320
+ });
321
+
322
+ it("session_start 发现异常不阻断(fail-safe,缓存保持 null,before_agent_start fallback)", async () => {
323
+ spies.discoverResources.mockRejectedValueOnce(new Error("disk io"));
324
+ await handlers.sessionStart!({ type: "session_start", reason: "new" }, createMockCtx());
325
+
326
+ spies.discoverResources.mockResolvedValue([workflowResource("/ws/.pi/workflows/chain.js")]);
327
+ spies.getCachedFileContent.mockReturnValue(workflowJs("chain", "三步链"));
328
+ const r = await handlers.beforeAgentStart!({ systemPrompt: "" }, createMockCtx());
329
+ expect(spies.discoverResources).toHaveBeenCalledTimes(2);
330
+ expect(r?.systemPrompt).toContain("<name>chain</name>");
331
+ });
332
+ });
@@ -18,13 +18,15 @@
18
18
  * 与同包 resource-discovery 同包后可直接 import,消除跨包依赖。
19
19
  */
20
20
 
21
- import * as fs from "node:fs";
21
+
22
22
 
23
23
  import type {
24
24
  BeforeAgentStartEvent,
25
25
  BeforeAgentStartEventResult,
26
26
  ExtensionAPI,
27
27
  ExtensionContext,
28
+ SessionShutdownEvent,
29
+ SessionStartEvent,
28
30
  } from "@earendil-works/pi-coding-agent";
29
31
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
30
32
  import { getLogger } from "@zhushanwen/pi-extension-logger";
@@ -32,54 +34,50 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
32
34
  import {
33
35
  discoverResources,
34
36
  findWorkspaceRoot,
37
+ getCachedFileContent,
35
38
  } from "../shared/resource-discovery.ts";
39
+ import { parseResourceMeta } from "../shared/meta-parser.ts";
36
40
 
37
41
  const logger = getLogger("injector");
38
42
 
39
- /** 从 .md frontmatter 提取的最小 agent 信息 */
43
+ /**
44
+ * Session 级 agent 列表缓存(per-process = per-session)。
45
+ *
46
+ * xyz-agent session-pool 模型下每 pi 子进程 = 一 session = 独立扩展实例,模块级缓存
47
+ * 天然 per-session 隔离(split mode 多 session 各自独立进程)。session_start(含
48
+ * reload)触发发现覆盖缓存(刷新节奏对齐 pi skill);session_shutdown 清空;
49
+ * before_agent_start 读缓存,miss(session_start 未触发/缓存被清)则 fallback
50
+ * 重新发现——保证鲁棒。
51
+ */
52
+ let agentCache: AgentEntry[] | null = null;
53
+
54
+ /** 从 .md frontmatter 提取的最小 agent 信息(m5:+ when/examples 路由样本;S1:+ path) */
40
55
  export interface AgentEntry {
41
56
  name: string;
42
57
  description: string;
58
+ when?: string;
59
+ examples?: Array<{ match: string; action: string; positive: boolean }>;
60
+ /** agentRef:agent .md 文件的绝对路径(注入段 <location>,模型直接引用) */
61
+ path: string;
43
62
  }
44
63
 
45
64
  /**
46
- * 解析 markdown 文件的 YAML frontmatter
47
- * 无有效 frontmatter 或缺 name/description 时返回 null。
65
+ * 解析 markdown 文件的 YAML frontmatter(name + description)。
48
66
  *
49
- * 沿用 unified-hooks 原实现:仅支持单行 key:value(builtin pi-subagents 用单行带引号
50
- * description)。block scalar(`>-` / `|`)不在支持范围——保持与原行为一致。
67
+ * m2 收敛:删本地单行 key:value parser,改调 shared/meta-parser.ts parseResourceMeta
68
+ * (IF1 统一 parser,支持 block scalar)。无有效 frontmatter 或缺 name/description 返 null。
69
+ * 投影 {name, description} 注入用(examples 注入留 m5)。
51
70
  */
52
71
  export function parseAgentFrontmatter(content: string): AgentEntry | null {
53
- if (!content.startsWith("---")) return null;
54
-
55
- const FRONTMATTER_OPEN_LEN = 3;
56
- const endIndex = content.indexOf("\n---", FRONTMATTER_OPEN_LEN);
57
- if (endIndex === -1) return null;
58
-
59
- const block = content.slice(FRONTMATTER_OPEN_LEN, endIndex);
60
- let name = "";
61
- let description = "";
62
-
63
- for (const line of block.split("\n")) {
64
- const match = line.match(/^([\w-]+):\s*(.*)$/);
65
- if (!match) continue;
66
-
67
- const key = match[1]!;
68
- let value = match[2]!.trim();
69
- // 去除包裹引号
70
- if (
71
- (value.startsWith('"') && value.endsWith('"')) ||
72
- (value.startsWith("'") && value.endsWith("'"))
73
- ) {
74
- value = value.slice(1, -1);
75
- }
76
-
77
- if (key === "name") name = value;
78
- if (key === "description") description = value;
79
- }
80
-
81
- if (!name || !description) return null;
82
- return { name, description };
72
+ const meta = parseResourceMeta(content, "agent");
73
+ if (!meta || meta.kind !== "agent") return null;
74
+ return {
75
+ name: meta.name,
76
+ description: meta.description,
77
+ when: meta.when,
78
+ examples: meta.examples,
79
+ path: "", // 由 discoverAllAgents 从 DiscoveredResource.path 填充
80
+ };
83
81
  }
84
82
 
85
83
  /**
@@ -106,10 +104,17 @@ export async function discoverAllAgents(
106
104
  for (const resource of resources) {
107
105
  if (!resource.available) continue;
108
106
  try {
109
- const content = fs.readFileSync(resource.path, "utf8");
107
+ const content = getCachedFileContent(resource.path) ?? "";
110
108
  const agent = parseAgentFrontmatter(content);
111
109
  if (agent) {
112
- agentMap.set(agent.name, agent);
110
+ agentMap.set(agent.name, { ...agent, path: resource.path });
111
+ } else if (content.trimStart().startsWith("---")) {
112
+ // m5(评审 M3/F2 + minor-5):仅「有 frontmatter 但解析失败」才 warn
113
+ // (缺 name/description/examples 单条非法致整体 reject)——README 等
114
+ // 无 frontmatter 的 .md 不刷 warn(每 turn 扫描)。
115
+ logger.warn(
116
+ `[subagent-list-injector] ${resource.path}: agent frontmatter 解析失败(IF1 校验不通过)——agent 未注入`,
117
+ );
113
118
  }
114
119
  } catch (err) {
115
120
  // 单个文件读失败不阻断整条 agent 列表注入
@@ -146,21 +151,57 @@ export function formatAgentList(agents: AgentEntry[]): string {
146
151
  "The following subagents are available. PRIORITY: when a task involves reading 3+ files, writing 100+ lines, parallel research, or specialized review, delegate to a matching subagent FIRST instead of doing it yourself — this keeps your context focused on orchestration. Do NOT call list to discover available subagents; use list only for running state. When using the subagent tool, ONLY use agent names from this list. If no agent matches your task, pass systemPrompt alongside the agent name to create a dynamic agent.",
147
152
  ];
148
153
  for (const agent of agents) {
149
- lines.push(
150
- ` <agent><name>${escapeXml(agent.name)}</name><description>${escapeXml(agent.description)}</description></agent>`,
151
- );
154
+ let block = ` <agent><name>${escapeXml(agent.name)}</name><description>${escapeXml(agent.description)}</description>`;
155
+ // m5:路由样本(when + examples 正反原样渲染——negative 的 action 由作者写
156
+ // 「不调用(原因)」,渲染器不硬编码;全部内容 escapeXml 防 XML 注入段破坏)
157
+ if (agent.when) {
158
+ block += `<when>${escapeXml(agent.when)}</when>`;
159
+ }
160
+ if (agent.examples && agent.examples.length > 0) {
161
+ // 两极性原样渲染——negative 的 action 由作者写「不调用(原因)」,
162
+ // 渲染器不硬编码后缀(exec-review major-1:曾追加「(不调用)」致双后缀)
163
+ const exampleLines = agent.examples.map(
164
+ (e) => ` - "${escapeXml(e.match)}" → ${escapeXml(e.action)}`,
165
+ );
166
+ block += `\n <examples>\n${exampleLines.join("\n")}\n </examples>`;
167
+ }
168
+ block += `<location>${escapeXml(agent.path)}</location></agent>`;
169
+ lines.push(block);
152
170
  }
153
171
  lines.push("</available_subagents>");
154
172
  return lines.join("\n");
155
173
  }
156
174
 
157
175
  /**
158
- * 注册 before_agent_start handler,注入 `<available_subagents>` 段。
176
+ * 注册 session 生命周期 handler,注入 `<available_subagents>` 段。
177
+ *
178
+ * 三 handler 自管缓存生命周期(不耦合 index.ts session 逻辑):
179
+ * - session_start:发现+缓存赋值(fail-safe,异常不阻断,缓存保持 null)
180
+ * - before_agent_start:读缓存渲染注入;miss 则 fallback 发现+赋值;空列表/空注入不返回
181
+ * systemPrompt;任何异常被吞掉(记日志),不阻断 agent turn
182
+ * - session_shutdown:清缓存
159
183
  *
160
184
  * pi 支持 async handler,且同一 event 多 handler 链式(前者返回的 systemPrompt 作
161
- * 后者输入)。本 handler 异步发现 + 注入;任何异常被吞掉(记日志),不阻断 agent turn。
185
+ * 后者输入)。
162
186
  */
163
187
  export function setupSubagentListInjector(pi: ExtensionAPI): void {
188
+ pi.on(
189
+ "session_start",
190
+ async (_event: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
191
+ try {
192
+ agentCache = await discoverAllAgents(
193
+ findWorkspaceRoot(ctx.cwd),
194
+ getAgentDir(),
195
+ );
196
+ } catch (err) {
197
+ // fail-safe:发现异常不阻断 session,缓存保持 null(before_agent_start 会 fallback)
198
+ logger.error("[subagent-list-injector] session_start discover failed", {
199
+ reason: err instanceof Error ? err.message : String(err),
200
+ });
201
+ }
202
+ },
203
+ );
204
+
164
205
  pi.on(
165
206
  "before_agent_start",
166
207
  async (
@@ -168,11 +209,14 @@ export function setupSubagentListInjector(pi: ExtensionAPI): void {
168
209
  ctx: ExtensionContext,
169
210
  ): Promise<BeforeAgentStartEventResult | void> => {
170
211
  try {
171
- const agents = await discoverAllAgents(
172
- findWorkspaceRoot(ctx.cwd),
173
- getAgentDir(),
174
- );
175
- const injection = formatAgentList(agents);
212
+ // 读缓存;miss(session_start 未触发/缓存被清)则 fallback 重新发现+赋值
213
+ if (agentCache === null) {
214
+ agentCache = await discoverAllAgents(
215
+ findWorkspaceRoot(ctx.cwd),
216
+ getAgentDir(),
217
+ );
218
+ }
219
+ const injection = formatAgentList(agentCache);
176
220
  if (!injection) return;
177
221
  return { systemPrompt: event.systemPrompt + injection };
178
222
  } catch (err) {
@@ -182,4 +226,11 @@ export function setupSubagentListInjector(pi: ExtensionAPI): void {
182
226
  }
183
227
  },
184
228
  );
229
+
230
+ pi.on(
231
+ "session_shutdown",
232
+ (_event: SessionShutdownEvent, _ctx: ExtensionContext): void => {
233
+ agentCache = null;
234
+ },
235
+ );
185
236
  }