@fyeeme/pi-review 2.0.0 → 2.1.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.
@@ -1,367 +0,0 @@
1
- /**
2
- * src/tools/subagent.ts — the `subagent` LLM tool.
3
- *
4
- * A general-purpose fan-out tool: spawn one or more real pi subprocesses to
5
- * run prompts as independent agents. This is the capability the code-review
6
- * skill needs for its medium+ multi-agent flow (parallel finders, an
7
- * independent verify agent, a gap-hunter) — the skill calls this tool instead
8
- * of doing multi-perspective work inline.
9
- *
10
- * Modes:
11
- * - single run prompts[0] once
12
- * - parallel run all prompts concurrently (capped at MAX_CONCURRENCY)
13
- * - chain run sequentially; each later prompt receives prior output
14
- */
15
- import { defineTool, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
16
- import { Type } from "typebox";
17
- import * as fs from "node:fs";
18
- import * as os from "node:os";
19
- import * as path from "node:path";
20
- import {
21
- abortAgent,
22
- createSpawnRegistry,
23
- mapWithConcurrencyLimit,
24
- parsePositiveInt,
25
- spawnAgent,
26
- type AgentSpawnRegistry,
27
- type AgentSpawnOptions,
28
- type AgentSpawnResult,
29
- } from "@fyeeme/pi-subagent-core";
30
-
31
- /** Default concurrency ceiling when PI_MAX_CONCURRENT_SUBAGENTS is unset/invalid.
32
- * 20 = CC's CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS ?? 20 (2.1.227 binary empirical). */
33
- const DEFAULT_MAX_CONCURRENCY = 20;
34
-
35
- /**
36
- * Effective concurrency ceiling, configurable via PI_MAX_CONCURRENT_SUBAGENTS
37
- * (parity with CC's CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS). Unset, missing, or
38
- * non-positive/non-integer values fall back to the default. Read at call time
39
- * so a changed env takes effect without a reload.
40
- */
41
- function getMaxConcurrency(): number {
42
- return parsePositiveInt(process.env.PI_MAX_CONCURRENT_SUBAGENTS) ?? DEFAULT_MAX_CONCURRENCY;
43
- }
44
-
45
- /**
46
- * Default turn budget for a fan-out agent when the caller omits maxTurns. A
47
- * runaway agent cannot otherwise be bounded. Generous (well above the ~10–15
48
- * turns the 4-angle finder/verify agents need) so legitimate work is not
49
- * truncated; callers may override with a smaller or larger explicit value.
50
- * 50 = CC's FORKED_AGENT_DEFAULT_MAX_TURNS (2.1.227 binary empirical).
51
- */
52
- const DEFAULT_FANOUT_MAX_TURNS = 50;
53
-
54
- // Module-level registry so abortAgent can reach in-flight calls. callIds are
55
- // unique per tool call (toolCallId#index), so a single registry is safe.
56
- const registry: AgentSpawnRegistry = createSpawnRegistry();
57
-
58
- const SubagentParams = Type.Object({
59
- // single: run prompts[0] once. parallel: run all prompts concurrently.
60
- // chain: run sequentially, each later prompt sees prior output.
61
- mode: Type.Union([Type.Literal("single"), Type.Literal("parallel"), Type.Literal("chain")]),
62
- prompts: Type.Array(Type.String(), {
63
- description: "Prompt(s) for the sub-agent(s). single/chain use order; parallel runs all.",
64
- }),
65
- model: Type.Optional(Type.String({ description: "Full model id (e.g. claude-sonnet-5). Omit for the session default." })),
66
- thinking: Type.Optional(
67
- Type.Union(
68
- ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map((l) => Type.Literal(l)),
69
- { description: "Thinking level for the sub-agent(s), passed as --thinking. Omit for the model default." },
70
- ),
71
- ),
72
- systemPrompt: Type.Optional(Type.String({ description: "Appended to the sub-agent's system prompt." })),
73
- tools: Type.Optional(Type.Array(Type.String(), { description: "Tool whitelist for the sub-agent. Omit for default tools." })),
74
- parallelism: Type.Optional(
75
- Type.Number({
76
- description: `Max concurrent agents in parallel mode (integer ≥ 1; default min(prompts.length, ceiling)). The ceiling is PI_MAX_CONCURRENT_SUBAGENTS (default ${DEFAULT_MAX_CONCURRENCY}).`,
77
- }),
78
- ),
79
- maxTurns: Type.Optional(
80
- Type.Number({ description: "Max assistant turns per sub-agent. When reached, the subprocess is aborted. Omit for the default budget; 0 means abort after the first message." }),
81
- ),
82
- cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the session cwd." })),
83
- });
84
-
85
- interface SubagentEntry {
86
- index: number;
87
- exitCode: number;
88
- text: string;
89
- aborted: boolean;
90
- /** Killed because the maxTurns budget was reached (not an external cancel). */
91
- maxTurnsReached: boolean;
92
- errorMessage?: string;
93
- /**
94
- * 完整对话转录文件路径(总是写入,含 user/assistant/tool result 全量消息)。
95
- * 内联预览只展示最终文本,模型可随时用 read 读取该文件深挖完整过程。
96
- */
97
- transcriptFile?: string;
98
- }
99
-
100
- interface SubagentDetails {
101
- mode: string;
102
- results: SubagentEntry[];
103
- stats: { agents: number; turns: number; cost: number; aborted: number };
104
- }
105
-
106
- /** Pull the assistant text out of a spawn result's messages. Defensive about
107
- * the Message.content shape (string | content-block array). */
108
- function resultText(r: AgentSpawnResult): string {
109
- const texts: string[] = [];
110
- for (const m of r.messages) {
111
- if (m.role !== "assistant") continue;
112
- const content: unknown = (m as { content?: unknown }).content;
113
- if (typeof content === "string") {
114
- texts.push(content);
115
- continue;
116
- }
117
- if (Array.isArray(content)) {
118
- for (const block of content) {
119
- if (block && typeof block === "object" && "text" in block) {
120
- const text = (block as { text?: unknown }).text;
121
- if (typeof text === "string") texts.push(text);
122
- }
123
- }
124
- }
125
- }
126
- return texts.join("\n").trim();
127
- }
128
-
129
- /**
130
- * 提取单条消息的可读文本(string content 或 content block 数组)。
131
- *
132
- * 转录用:除 text 外也渲染 thinking 与 toolCall 块,否则转录会静默丢弃中间推理与
133
- * 工具调用——与“全量转录含 tool result”的声明不符。内联预览(resultText)仍只取
134
- * assistant 的 text,保持简短。
135
- */
136
- function messageText(m: { role?: string; content?: unknown }): string {
137
- const content = m.content;
138
- if (typeof content === "string") return content;
139
- if (!Array.isArray(content)) return "";
140
- const parts: string[] = [];
141
- for (const block of content) {
142
- if (!block || typeof block !== "object") continue;
143
- const b = block as { type?: string; text?: unknown; thinking?: unknown; name?: unknown; arguments?: unknown };
144
- switch (b.type) {
145
- case "text":
146
- if (typeof b.text === "string") parts.push(b.text);
147
- break;
148
- case "thinking":
149
- if (typeof b.thinking === "string") parts.push(`[thinking] ${b.thinking}`);
150
- break;
151
- case "toolCall":
152
- if (typeof b.name === "string") {
153
- const args = b.arguments === undefined ? "" : JSON.stringify(b.arguments);
154
- parts.push(`[tool_call] ${b.name}(${args})`);
155
- }
156
- break;
157
- default:
158
- break;
159
- }
160
- }
161
- return parts.join("\n");
162
- }
163
-
164
- /**
165
- * 将 agent 的完整对话(全部消息:user / assistant / tool result)渲染为可读转录文本。
166
- * 内联预览只含最终 assistant 文本;这里保留中间推理与工具调用过程,供模型深挖。
167
- */
168
- function transcriptText(messages: AgentSpawnResult["messages"]): string {
169
- const parts: string[] = [];
170
- for (const m of messages) {
171
- const body = messageText(m).trim();
172
- if (!body) continue;
173
- const role = m.role ?? "message";
174
- // toolResult 消息标出工具名,便于定位是哪个工具的返回。
175
- const label =
176
- role === "toolResult" && typeof (m as { toolName?: unknown }).toolName === "string"
177
- ? `${role}(${(m as { toolName: string }).toolName})`
178
- : role;
179
- parts.push(`--- ${label} ---\n${body}`);
180
- }
181
- return parts.join("\n\n");
182
- }
183
-
184
- /**
185
- * 将 agent 的完整对话转录写入临时文件。总是写入(借鉴 tintinweb/pi-subagents:
186
- * 完整转录落盘 + 路径随结果给出),不只在输出超限时才写。
187
- */
188
- async function writeTranscriptFile(tmpDir: string, callId: string, text: string): Promise<string> {
189
- const safeName = callId.replace(/[^\w.-]+/g, "_");
190
- const filePath = path.join(tmpDir, `agent-${safeName}.txt`);
191
- await fs.promises.writeFile(filePath, text, { encoding: "utf-8", mode: 0o600 });
192
- return filePath;
193
- }
194
-
195
- /**
196
- * 单个 agent 的展示预览:最终文本用 pi 内置截断(默认 50KB / 2000 行)呈现——
197
- * 正常体量输出直接完整内联;超限时保留截断内容并标注。无论是否超限,都附上
198
- * 完整对话转录文件路径,模型可随时 read 深挖。
199
- */
200
- function previewEntry(r: SubagentEntry): string {
201
- const trunc = truncateHead(r.text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
202
- let preview = trunc.content;
203
- const extras: string[] = [];
204
- if (trunc.truncated) {
205
- extras.push(`输出截断: ${trunc.outputLines}/${trunc.totalLines} 行, ${formatSize(trunc.outputBytes)}/${formatSize(trunc.totalBytes)}`);
206
- }
207
- if (r.transcriptFile) {
208
- extras.push(`完整转录: ${r.transcriptFile}`);
209
- }
210
- if (extras.length > 0) preview += `\n [${extras.join(" · ")}]`;
211
- return preview;
212
- }
213
-
214
- function summarize(d: SubagentDetails): string {
215
- const lines = [
216
- `subagent (${d.mode}) → ${d.results.length} agent(s), ${d.stats.turns} turn(s), $${d.stats.cost.toFixed(4)}`,
217
- ];
218
- for (const r of d.results) {
219
- const tag = r.maxTurnsReached
220
- ? "[max-turns]"
221
- : r.aborted
222
- ? "[aborted]"
223
- : r.errorMessage
224
- ? "[error]"
225
- : "[ok]";
226
- lines.push(` ${tag} #${r.index + 1}: ${previewEntry(r)}`);
227
- }
228
- return lines.join("\n");
229
- }
230
-
231
- export const subagentTool = defineTool<typeof SubagentParams, SubagentDetails>({
232
- name: "subagent",
233
- label: "Sub-agent",
234
- description:
235
- "Spawn one or more sub-agents (real pi subprocesses) to fan out work — e.g. parallel code-review finders, an independent verify agent, or a gap-hunter. Each sub-agent is a full pi run in --mode json. 每个 agent 的完整对话转录都会写入临时文件并在结果中附上路径;内联预览展示最终文本(超长时截断),需要完整过程时可用 read 工具读取转录文件。",
236
- promptSnippet: "subagent — spawn parallel/sequential sub-agents (fan-out finders, independent verify, gap-hunt)",
237
- promptGuidelines: [
238
- "Use `subagent` with mode:parallel to fan out multiple reviewers/finders at once (e.g. one per angle); collect their outputs and synthesize.",
239
- "Use mode:single for an independent second opinion (e.g. verify a candidate finding without your own confirmation bias).",
240
- "Do not fake fan-out by doing the work yourself inline — call `subagent` so the work genuinely runs in parallel subprocesses.",
241
- ],
242
- parameters: SubagentParams,
243
-
244
- async execute(toolCallId, params, signal, onUpdate, ctx) {
245
- const prompts = params.prompts;
246
- if (prompts.length === 0) throw new Error("subagent: prompts must be non-empty");
247
- if (params.mode === "single" && prompts.length > 1) {
248
- throw new Error(`subagent: mode "single" takes exactly one prompt (got ${prompts.length})`);
249
- }
250
-
251
- const cwd = params.cwd ?? ctx.cwd;
252
- const baseSystem = params.systemPrompt;
253
- // Recursion opt-in: a child may itself spawn sub-agents ONLY when the
254
- // caller explicitly listed the fan-out tool in the child's whitelist.
255
- // Default (omitted, or whitelist without it) → the child loads without
256
- // the subagent tool (see isFanoutToolAllowed in the extension entry).
257
- const allowChildRecursion = params.tools?.includes("subagent") ?? false;
258
- // Per-call-agnostic subset of AgentSpawnOptions; callId/task are added per spawn.
259
- const baseOpts: Omit<AgentSpawnOptions, "callId" | "task"> = {
260
- cwd,
261
- model: params.model,
262
- thinking: params.thinking,
263
- tools: params.tools,
264
- signal,
265
- // Default turn budget applies when omitted; an explicit 0 is honored by
266
- // spawnAgent (it uses `!= null`, not truthiness) rather than treated as unset.
267
- maxTurns: params.maxTurns ?? DEFAULT_FANOUT_MAX_TURNS,
268
- allowChildRecursion,
269
- };
270
-
271
- const partial: SubagentDetails = {
272
- mode: params.mode,
273
- results: [],
274
- stats: { agents: 0, turns: 0, cost: 0, aborted: 0 },
275
- };
276
- const emit = (): void => {
277
- onUpdate?.({ content: [{ type: "text" as const, text: summarize(partial) }], details: partial });
278
- };
279
-
280
- // 本次 tool call 内所有 agent 共享一个临时目录(N 个 agent → 1 个目录),
281
- // 取代每个 agent 各自 mkdtemp。转录需保留供模型稍后 read,故此处不清理。
282
- let sharedTmpDir: string | null = null;
283
- const getTmpDir = async (): Promise<string> => {
284
- if (!sharedTmpDir) {
285
- sharedTmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-cr-out-"));
286
- }
287
- return sharedTmpDir;
288
- };
289
-
290
- const runOne = async (prompt: string, index: number, extraSystem?: string): Promise<SubagentEntry> => {
291
- const callId = `${toolCallId}#${index}`;
292
- const systemPrompt = extraSystem
293
- ? [baseSystem, extraSystem].filter(Boolean).join("\n\n")
294
- : baseSystem;
295
- const r = await spawnAgent(registry, { callId, task: prompt, ...baseOpts, systemPrompt });
296
- const entry: SubagentEntry = {
297
- index,
298
- exitCode: r.exitCode,
299
- text: resultText(r),
300
- aborted: r.aborted,
301
- maxTurnsReached: r.maxTurnsReached,
302
- errorMessage:
303
- r.errorMessage ||
304
- (r.exitCode !== 0 && !r.aborted
305
- ? `exit ${r.exitCode}${r.stderr ? `: ${r.stderr.slice(0, 200)}` : ""}`
306
- : undefined),
307
- };
308
- // 总是写入完整对话转录文件并附路径(借鉴 tintinweb/pi-subagents)。写失败不阻塞结果。
309
- try {
310
- entry.transcriptFile = await writeTranscriptFile(await getTmpDir(), callId, transcriptText(r.messages));
311
- } catch {
312
- /* 转录写失败不阻塞:previewEntry 无路径时仅展示内联预览 */
313
- }
314
- partial.results.push(entry);
315
- partial.stats.agents++;
316
- partial.stats.turns += r.usage.turns;
317
- partial.stats.cost += r.usage.cost;
318
- // Only count genuine external cancels as aborted; a maxTurns budget
319
- // stop is a normal bounded completion, not a cancellation.
320
- if (r.aborted && !r.maxTurnsReached) partial.stats.aborted++;
321
- // Keep results sorted by index for stable output (parallel settles out of order).
322
- partial.results.sort((a, b) => a.index - b.index);
323
- emit();
324
- return entry;
325
- };
326
-
327
- if (params.mode === "parallel") {
328
- const ceiling = getMaxConcurrency();
329
- // Fractional / non-positive parallelism from the model would reach
330
- // mapWithConcurrencyLimit as `new Array(3.5)` → RangeError. Clamp to a
331
- // sane integer instead of failing the whole tool call.
332
- const requested = Math.floor(Math.max(1, params.parallelism ?? ceiling));
333
- const conc = Math.min(requested, ceiling, prompts.length);
334
- await mapWithConcurrencyLimit(prompts, conc, (p, i) => runOne(p, i));
335
- } else {
336
- // single or chain
337
- let chainContext = "";
338
- for (let i = 0; i < prompts.length; i++) {
339
- const extra = params.mode === "chain" && chainContext ? `Previous sub-agent output:\n${chainContext}` : undefined;
340
- const entry = await runOne(prompts[i], i, extra);
341
- // Stop the chain on a hard failure so downstream prompts don't run
342
- // on missing/garbage context (and don't waste subprocesses). An
343
- // aborted/maxTurns step may still carry useful text worth chaining.
344
- if (entry.errorMessage) break;
345
- // 始终赋值(即便是空串)以清空链上下文:否则空文本步骤会让后续步骤复用
346
- // 更早步骤的输出作为“Previous sub-agent output”,传递陈旧上下文。
347
- chainContext = entry.text;
348
- }
349
- }
350
-
351
- // Surface a hard failure (non-aborted, non-zero exit) by throwing so the
352
- // agent loop marks isError=true (pi tool contract: throw, don't return isError).
353
- const failed = partial.results.find((r) => r.errorMessage && !r.aborted);
354
- if (failed) throw new Error(`subagent: agent #${failed.index + 1} failed — ${failed.errorMessage}`);
355
-
356
- return {
357
- content: [{ type: "text" as const, text: summarize(partial) }],
358
- details: partial,
359
- };
360
- },
361
- });
362
-
363
- /** Abort one in-flight sub-agent by its callId. Exposed for future per-agent
364
- * abort UI; the caller-level `signal` already handles run-wide ESC. */
365
- export function abortSubagent(callId: string): boolean {
366
- return abortAgent(registry, callId);
367
- }