@zhushanwen/pi-subagent-workflow 5.0.2 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/{reviewer.md → code-reviewer.md} +20 -3
- package/agents/context-builder.md +5 -0
- package/agents/doc-reviewer.md +9 -2
- package/agents/explorer.md +5 -0
- package/agents/general-purpose.md +5 -0
- package/agents/oracle.md +20 -4
- package/agents/orchestrator.md +6 -1
- package/agents/planner.md +5 -0
- package/agents/researcher.md +5 -0
- package/agents/worker.md +5 -0
- package/package.json +6 -4
- package/src/execution/__tests__/agent-registry.test.ts +189 -119
- package/src/execution/__tests__/crash-recovery.test.ts +0 -1
- package/src/execution/__tests__/execute-options-mapper.test.ts +4 -4
- package/src/execution/__tests__/index-session-start.test.ts +0 -1
- package/src/execution/__tests__/model-resolver.test.ts +20 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +0 -2
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +1 -1
- package/src/execution/agent-registry.ts +92 -169
- package/src/execution/execute-options-mapper.ts +2 -2
- package/src/execution/model-config-service.ts +13 -34
- package/src/execution/model-resolver.ts +5 -3
- package/src/execution/subagent-service.ts +9 -6
- package/src/execution/subprocess-agent-runner.ts +3 -2
- package/src/index.ts +4 -25
- package/src/injectors/__tests__/subagent-list-injector.test.ts +266 -14
- package/src/injectors/__tests__/workflow-list-injector.test.ts +236 -32
- package/src/injectors/subagent-list-injector.ts +99 -48
- package/src/injectors/workflow-list-injector.ts +65 -50
- package/src/interface/__tests__/detectors.test.ts +100 -43
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +8 -12
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +163 -0
- package/src/interface/__tests__/workflow-tool-prompt.test.ts +55 -9
- package/src/interface/subagent-tool.ts +7 -4
- package/src/interface/tool-workflow-script.ts +27 -10
- package/src/interface/tool-workflow.ts +174 -81
- package/src/orchestration/__tests__/args-validator.test.ts +143 -0
- package/src/orchestration/__tests__/config-loader.test.ts +124 -40
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +33 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +59 -2
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +116 -51
- package/src/orchestration/__tests__/script-lint.test.ts +167 -1
- package/src/orchestration/__tests__/worker-host.test.ts +120 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +69 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +51 -1
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +2 -2
- package/src/orchestration/__tests__/workflows-e2e.test.ts +177 -24
- package/src/orchestration/agent-opts-resolver.ts +51 -101
- package/src/orchestration/args-validator.ts +127 -0
- package/src/orchestration/config-loader.ts +63 -94
- package/src/orchestration/error-recovery.ts +6 -9
- package/src/orchestration/launcher.ts +77 -41
- package/src/orchestration/lifecycle.ts +7 -0
- package/src/orchestration/models/ports.ts +0 -14
- package/src/orchestration/models/run-spec.ts +24 -1
- package/src/orchestration/models/types.ts +15 -8
- package/src/orchestration/models/workflow-script-registry.ts +3 -0
- package/src/orchestration/models/workflow-script.ts +10 -14
- package/src/orchestration/script-lint.ts +159 -0
- package/src/orchestration/worker-host.ts +5 -0
- package/src/orchestration/worker-script-builder.ts +15 -4
- package/src/orchestration/workflow-script-registry-impl.ts +34 -29
- package/src/shared/__tests__/meta-parser.test.ts +304 -0
- package/src/shared/__tests__/resource-discovery.test.ts +167 -7
- package/src/shared/__tests__/resource-meta.test.ts +51 -0
- package/src/shared/agent-ref.ts +36 -0
- package/src/shared/meta-parser.ts +257 -0
- package/src/shared/resource-discovery.ts +88 -2
- package/src/shared/resource-meta.ts +60 -0
- package/workflows/README.md +2 -2
- package/workflows/_shared/agent-refs.cjs +40 -0
- package/workflows/chain.js +30 -6
- package/workflows/map-reduce.js +33 -5
- package/workflows/parallel.js +34 -7
- package/workflows/review-fix-loop-utils.cjs +23 -103
- package/workflows/review-fix-loop.js +121 -58
- package/workflows/scatter-gather.js +30 -6
|
@@ -1,11 +1,52 @@
|
|
|
1
1
|
// workflow-list-injector 单测
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// +
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 =
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
67
|
-
expect(parseWorkflowMeta(
|
|
68
|
-
expect(parseWorkflowMeta(
|
|
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 =
|
|
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 =
|
|
82
|
-
|
|
83
|
-
|
|
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("
|
|
118
|
-
const out = formatWorkflowList([{ name: "chain", description: "d" }]);
|
|
119
|
-
expect(out).toContain("run directly");
|
|
120
|
-
expect(out).toContain("
|
|
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&b</name>");
|
|
126
176
|
expect(out).toContain("<x>");
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 =
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
* 注册
|
|
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
|
-
*
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
}
|