@fyeeme/pi-review 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -11
- package/index.ts +15 -3
- package/package.json +10 -5
- package/skills/code-review/SKILL.md +207 -99
- package/skills/simplify/SKILL.md +184 -61
- package/src/commands/code-simplify.ts +43 -9
- package/src/tools/review_report.ts +326 -0
- package/src/tools/subagent.ts +50 -7
- package/src/agent/dispatch.ts +0 -353
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/review_report.ts — the `review_report` LLM tool.
|
|
3
|
+
*
|
|
4
|
+
* Structured findings sink for the code-review skill — Pi's counterpart to
|
|
5
|
+
* CC's native `ReportFindings` tool (verified in CC v2.1.226 binary: "Report
|
|
6
|
+
* code-review findings as a typed list so the host UI can render them"). Pi has
|
|
7
|
+
* no host finding-renderer, so this tool does double duty: it renders a tidy
|
|
8
|
+
* Chinese Markdown report (table + details) back to the conversation AND writes
|
|
9
|
+
* a machine-readable JSON (findings + level + outcome) to
|
|
10
|
+
* `<cwd>/.pi/review/<id>.json` so CI / --fix / --comment can consume it.
|
|
11
|
+
*
|
|
12
|
+
* `verdict` (CONFIRMED/PLAUSIBLE) and `outcome` (fixed/skipped/no_change_needed)
|
|
13
|
+
* enums follow the CC ReportFindings shape — values verified against the CC
|
|
14
|
+
* v2.1.227 binary (consistent across 2.1.223/226/227). REFUTED is deliberately
|
|
15
|
+
* absent: the verify flow drops it before reporting. The tool entry also
|
|
16
|
+
* normalizes stray invalid values (drop the finding / coerce to skipped) rather
|
|
17
|
+
* than failing the whole call.
|
|
18
|
+
*/
|
|
19
|
+
import { defineTool, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Markdown } from "@earendil-works/pi-tui";
|
|
21
|
+
import { type Static, Type } from "typebox";
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
|
|
25
|
+
// --- enums following the CC ReportFindings shape ----------------------------
|
|
26
|
+
// 值域实证来源:CC v2.1.227 bin/claude.exe(ReportFindings 工具 schema)。
|
|
27
|
+
// verdict 两值与 outcome 三档在 2.1.223/226/227 三版本中一致。
|
|
28
|
+
|
|
29
|
+
const VERDICT_VALUES = ["CONFIRMED", "PLAUSIBLE"] as const;
|
|
30
|
+
const Verdict = Type.Union(VERDICT_VALUES.map((v) => Type.Literal(v)));
|
|
31
|
+
|
|
32
|
+
const OUTCOME_VALUES = ["fixed", "skipped", "no_change_needed"] as const;
|
|
33
|
+
/** CC ReportFindings `outcome` 三档(2.1.227 二进制实证)。fixed-later 再上报时更新。 */
|
|
34
|
+
const Outcome = Type.Union(OUTCOME_VALUES.map((v) => Type.Literal(v)));
|
|
35
|
+
|
|
36
|
+
// 供 SKILL-schema 同步测试引用(防漂移:SKILL 流程契约不得与常量脱节)。
|
|
37
|
+
export { OUTCOME_VALUES, VERDICT_VALUES };
|
|
38
|
+
|
|
39
|
+
const Level = Type.Union([
|
|
40
|
+
Type.Literal("low"),
|
|
41
|
+
Type.Literal("medium"),
|
|
42
|
+
Type.Literal("high"),
|
|
43
|
+
Type.Literal("xhigh"),
|
|
44
|
+
Type.Literal("max"),
|
|
45
|
+
// simplify reuses this tool for structured apply-outcome reporting
|
|
46
|
+
// (harden-code-simplify). Not a review effort level — carries no verdict.
|
|
47
|
+
Type.Literal("simplify"),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// --- schema -----------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const FindingParams = Type.Object({
|
|
53
|
+
file: Type.String({ description: "相对仓库根的文件路径。" }),
|
|
54
|
+
line: Type.Optional(Type.Number({ description: "行号(1-based)。省略表示文件级。" })),
|
|
55
|
+
category: Type.String({
|
|
56
|
+
description:
|
|
57
|
+
"产生该发现的角度 slug:correctness / reuse / simplification / efficiency / altitude / conventions(或更具体如 test-coverage)。",
|
|
58
|
+
}),
|
|
59
|
+
verdict: Type.Optional(Verdict),
|
|
60
|
+
short_summary: Type.Optional(
|
|
61
|
+
Type.String({
|
|
62
|
+
description:
|
|
63
|
+
"≤60 字符的纯声明标签(去掉理由与后果)。汇总表概述列优先使用它;详情块仍显示完整 summary。流程层面必填(CC 输出模板契约),schema 层面 optional(与 CC tool schema 一致)。中文。",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
summary: Type.String({ description: "一句话说明(≤80字),同时作紧凑标签。中文。" }),
|
|
67
|
+
failure_scenario: Type.String({
|
|
68
|
+
description:
|
|
69
|
+
"具体场景:输入/状态 → 错误输出/崩溃;清理类发现写明具体代价(重复/浪费/更难维护/违反哪条规则)。中文。",
|
|
70
|
+
}),
|
|
71
|
+
outcome: Type.Optional(Outcome),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const ReviewReportParams = Type.Object({
|
|
75
|
+
level: Level,
|
|
76
|
+
target: Type.Optional(Type.String({ description: "审查目标(diff 命令/范围,或 PR/分支/路径),用于报告表头。" })),
|
|
77
|
+
files_changed: Type.Optional(Type.Number({ description: "改动文件数,用于报告表头。" })),
|
|
78
|
+
fanned_out: Type.Optional(
|
|
79
|
+
Type.Boolean({ description: "是否真的多智能体并发(Single-pass honesty)。false/省略表示单遍自审。" }),
|
|
80
|
+
),
|
|
81
|
+
findings: Type.Array(FindingParams, {
|
|
82
|
+
description: "已验证、去重、按严重度从高到低排序的发现列表(most-severe first)。空数组表示无发现存活。",
|
|
83
|
+
}),
|
|
84
|
+
report_id: Type.Optional(
|
|
85
|
+
Type.String({
|
|
86
|
+
description:
|
|
87
|
+
"报告标识(如 review-<ts>)。首次上报生成;fixed-later 再上报传同一 id,消费方按 id 归并,同 id 最新 generatedAt 为最终状态。",
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
interface ReviewReportDetails {
|
|
93
|
+
level: string;
|
|
94
|
+
findingsCount: number;
|
|
95
|
+
/** 结构化 JSON 落盘路径;落盘失败时为 null(仍返回渲染报告)。 */
|
|
96
|
+
outFile: string | null;
|
|
97
|
+
reportId: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* execute 入口的宽松 finding 形态——绕过 schema 校验的直接调用(如单测)
|
|
102
|
+
* 可能携带旧五档 outcome 或已废弃的 REFUTED verdict。
|
|
103
|
+
*/
|
|
104
|
+
type LooseFinding = {
|
|
105
|
+
file: string;
|
|
106
|
+
line?: number;
|
|
107
|
+
category: string;
|
|
108
|
+
verdict?: string;
|
|
109
|
+
short_summary?: string;
|
|
110
|
+
summary: string;
|
|
111
|
+
failure_scenario: string;
|
|
112
|
+
outcome?: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 单条清洗:非法 verdict(含已废弃的 REFUTED)返回 null(剔除);非法 outcome
|
|
117
|
+
* 归一化为 skipped 并附注原始值。schema 保持严格,清洗在 schema 校验之前。
|
|
118
|
+
*/
|
|
119
|
+
function sanitizeFinding(f: LooseFinding): { f: LooseFinding; note?: string } | null {
|
|
120
|
+
if (f.verdict !== undefined && !(VERDICT_VALUES as readonly string[]).includes(f.verdict)) return null;
|
|
121
|
+
let outcome = f.outcome;
|
|
122
|
+
let note: string | undefined;
|
|
123
|
+
if (outcome !== undefined && !(OUTCOME_VALUES as readonly string[]).includes(outcome)) {
|
|
124
|
+
note = `(outcome "${outcome}" 非法,已归一化为 skipped)`;
|
|
125
|
+
outcome = "skipped";
|
|
126
|
+
}
|
|
127
|
+
return { f: { ...f, outcome }, note };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 批量清洗,note 按清洗后数组索引记录(渲染层附注用)。
|
|
132
|
+
* `prepareArguments`(主防御,schema 校验前)与 `execute` 入口(双保险,
|
|
133
|
+
* 防绕过 prepareArguments 的直接调用)共用——模型路径的非法值在进入
|
|
134
|
+
* execute 前已被清洗,validateToolArguments 不会因边缘值 throw 掉整份报告。
|
|
135
|
+
*/
|
|
136
|
+
function normalizeFindings(findings: LooseFinding[]): { findings: LooseFinding[]; notes: Map<number, string> } {
|
|
137
|
+
const out: LooseFinding[] = [];
|
|
138
|
+
const notes = new Map<number, string>();
|
|
139
|
+
for (const raw of findings) {
|
|
140
|
+
const s = sanitizeFinding(raw);
|
|
141
|
+
if (!s) continue;
|
|
142
|
+
const idx = out.length;
|
|
143
|
+
out.push(s.f);
|
|
144
|
+
if (s.note) notes.set(idx, s.note);
|
|
145
|
+
}
|
|
146
|
+
return { findings: out, notes };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- render -----------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
interface FindingInput {
|
|
152
|
+
file: string;
|
|
153
|
+
line?: number;
|
|
154
|
+
category: string;
|
|
155
|
+
verdict?: string;
|
|
156
|
+
short_summary?: string;
|
|
157
|
+
summary: string;
|
|
158
|
+
failure_scenario: string;
|
|
159
|
+
outcome?: string;
|
|
160
|
+
/** normalize 附注(如非法 outcome 归一化说明),仅渲染进详情块。 */
|
|
161
|
+
note?: string;
|
|
162
|
+
}
|
|
163
|
+
interface ReportInput {
|
|
164
|
+
level: string;
|
|
165
|
+
target?: string;
|
|
166
|
+
files_changed?: number;
|
|
167
|
+
fanned_out?: boolean;
|
|
168
|
+
reportId?: string;
|
|
169
|
+
findings: FindingInput[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function fmtLoc(f: { file: string; line?: number }): string {
|
|
173
|
+
return f.line != null ? `${f.file}:${f.line}` : f.file;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Escape a value for a GFM table cell: backslash-escape pipes and collapse
|
|
177
|
+
* newlines. Free-text fields (summary/category/verdict/loc) are LLM-provided
|
|
178
|
+
* and routinely contain `||`, `|`, regex, or shell pipes that would otherwise
|
|
179
|
+
* split the row into extra columns and break the whole summary table. */
|
|
180
|
+
function escapeCell(v: string): string {
|
|
181
|
+
return v.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 渲染中文 Markdown 报告(表头行 + 汇总表 + 详情块),格式与原 SKILL.md 教的一致。 */
|
|
185
|
+
function renderReport(p: ReportInput): string {
|
|
186
|
+
const lines: string[] = [];
|
|
187
|
+
const fanLabel = p.fanned_out === true ? "多智能体" : p.fanned_out === false ? "单遍自审" : "未标注";
|
|
188
|
+
const targetStr = (p.target ?? "(whole diff)").replace(/`/g, "\\`"); // backtick inside the inline-code cell would close it early
|
|
189
|
+
const filesStr = p.files_changed != null ? `${p.files_changed} 个文件` : "文件数未标注";
|
|
190
|
+
const idStr = p.reportId ? ` · 报告 \`${escapeCell(p.reportId)}\`` : "";
|
|
191
|
+
lines.push(`\`${p.level}\` · \`${targetStr}\` · ${filesStr} · ${p.findings.length} 条发现 · ${fanLabel}${idStr}`);
|
|
192
|
+
lines.push("");
|
|
193
|
+
|
|
194
|
+
if (p.findings.length === 0) {
|
|
195
|
+
lines.push("(无发现存活验证。)");
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
lines.push("| # | 判定 | 类别 | 位置 | 概述 |");
|
|
200
|
+
lines.push("|---|------|------|------|------|");
|
|
201
|
+
for (let i = 0; i < p.findings.length; i++) {
|
|
202
|
+
const f = p.findings[i]!;
|
|
203
|
+
lines.push(`| ${i + 1} | ${escapeCell(f.verdict ?? "")} | ${escapeCell(f.category)} | ${escapeCell(fmtLoc(f))} | ${escapeCell(f.short_summary ?? f.summary)} |`);
|
|
204
|
+
}
|
|
205
|
+
lines.push("");
|
|
206
|
+
lines.push("**详情**");
|
|
207
|
+
lines.push("");
|
|
208
|
+
p.findings.forEach((f, i) => {
|
|
209
|
+
const v = f.verdict ? ` *(${f.verdict})*` : "";
|
|
210
|
+
const out = f.outcome ? `\n修复结果:\`${f.outcome}\`` : "";
|
|
211
|
+
const note = f.note ? `\n${f.note}` : "";
|
|
212
|
+
lines.push(`**${i + 1}. ${fmtLoc(f)} — ${f.category}**${v}`);
|
|
213
|
+
lines.push(`概述:${f.summary}`);
|
|
214
|
+
lines.push(`场景:${f.failure_scenario}${out}${note}`);
|
|
215
|
+
lines.push("");
|
|
216
|
+
});
|
|
217
|
+
return lines.join("\n").trimEnd();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- tool -------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
export const reviewReportTool = defineTool<typeof ReviewReportParams, ReviewReportDetails>({
|
|
223
|
+
name: "review_report",
|
|
224
|
+
label: "Report review findings",
|
|
225
|
+
description:
|
|
226
|
+
"Report code-review findings as a typed list — Pi's counterpart to CC's ReportFindings. Use this only when the active code-review instructions tell you to report findings with this tool. Call it once with the verified findings ranked most-severe first (empty array if nothing survived verification) and do not also print the findings as text — the tool renders a tidy Chinese Markdown report back to the conversation AND writes a machine-readable JSON to <cwd>/.pi/review/ for CI / --fix / --comment. When re-reporting after applying fixes, set `outcome` on each finding. 上报结构化 code-review 发现(CC ReportFindings 的 Pi 对等物)。",
|
|
227
|
+
promptSnippet: "review_report — report structured code-review findings (renders Markdown + writes JSON for CI)",
|
|
228
|
+
promptGuidelines: [
|
|
229
|
+
"After verify + dedup, call `review_report` once with { level, findings } (most-severe first; empty array if none survived). Do not also hand-write the Markdown table — this tool renders it.",
|
|
230
|
+
"On re-report after --fix, set each finding's `outcome` (fixed / skipped / no_change_needed).",
|
|
231
|
+
"Use this tool only when the code-review skill instructs reporting findings; otherwise follow the active output format.",
|
|
232
|
+
],
|
|
233
|
+
parameters: ReviewReportParams,
|
|
234
|
+
|
|
235
|
+
// 主防御:schema 校验之前清洗非法值(模型路径下校验失败即 throw、工具不执行,
|
|
236
|
+
// 因此 execute 内的防御对模型不可达)。返回符合 schema 的对象——非法 verdict
|
|
237
|
+
// 的 finding 剔除、非法 outcome 归一化为 skipped;note 附注由 execute 层基于
|
|
238
|
+
// 清洗结果生成(schema 对象不含 note 字段)。
|
|
239
|
+
prepareArguments(args) {
|
|
240
|
+
if (typeof args !== "object" || args === null) return args as Static<typeof ReviewReportParams>;
|
|
241
|
+
const raw = args as { findings?: unknown };
|
|
242
|
+
if (!Array.isArray(raw.findings)) return args as Static<typeof ReviewReportParams>;
|
|
243
|
+
const { findings } = normalizeFindings(raw.findings as unknown as LooseFinding[]);
|
|
244
|
+
return { ...raw, findings } as Static<typeof ReviewReportParams>;
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
248
|
+
// normalize(双保险)——绕过 prepareArguments 的直接调用(如单测)可能携带
|
|
249
|
+
// 旧五档 outcome 或已废弃的 REFUTED verdict。归一化而非整单拒绝:非法
|
|
250
|
+
// verdict 的 finding 剔除,非法 outcome 归一化为 skipped 并附注;渲染与落盘
|
|
251
|
+
// 永远只含合法值(spec: 消费方永远看到合法值)。
|
|
252
|
+
const { findings: cleaned, notes } = normalizeFindings(
|
|
253
|
+
(params.findings ?? []) as unknown as LooseFinding[],
|
|
254
|
+
);
|
|
255
|
+
const findings: FindingInput[] = cleaned.map((f, i) => ({ ...f, note: notes.get(i) }));
|
|
256
|
+
|
|
257
|
+
const report = renderReport({
|
|
258
|
+
level: params.level,
|
|
259
|
+
target: params.target,
|
|
260
|
+
files_changed: params.files_changed,
|
|
261
|
+
fanned_out: params.fanned_out,
|
|
262
|
+
reportId: params.report_id,
|
|
263
|
+
findings,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
let outFile: string | null = null;
|
|
267
|
+
let writeError: string | null = null;
|
|
268
|
+
const now = new Date();
|
|
269
|
+
try {
|
|
270
|
+
const dir = path.join(ctx.cwd, ".pi", "review");
|
|
271
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
272
|
+
const safeId = toolCallId.replace(/[^\w.-]+/g, "_");
|
|
273
|
+
const ts = now.toISOString().replace(/[:.]/g, "-");
|
|
274
|
+
const fp = path.join(dir, `${ts}-${safeId}.json`);
|
|
275
|
+
await fs.promises.writeFile(
|
|
276
|
+
fp,
|
|
277
|
+
JSON.stringify(
|
|
278
|
+
{
|
|
279
|
+
level: params.level,
|
|
280
|
+
reportId: params.report_id ?? null,
|
|
281
|
+
target: params.target ?? null,
|
|
282
|
+
filesChanged: params.files_changed ?? null,
|
|
283
|
+
fannedOut: params.fanned_out ?? null,
|
|
284
|
+
generatedAt: now.toISOString(),
|
|
285
|
+
findings,
|
|
286
|
+
},
|
|
287
|
+
null,
|
|
288
|
+
2,
|
|
289
|
+
),
|
|
290
|
+
{ encoding: "utf-8", mode: 0o600 },
|
|
291
|
+
);
|
|
292
|
+
outFile = fp;
|
|
293
|
+
} catch (err) {
|
|
294
|
+
/* 落盘失败不阻塞:仍返回渲染报告,但带上错误信息便于 CI/--fix 排障。 */
|
|
295
|
+
writeError = err instanceof Error ? err.message : String(err);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const tail = outFile
|
|
299
|
+
? `\n\n[结构化发现已写入 \`${outFile}\`]`
|
|
300
|
+
: `\n\n[结构化落盘失败(${writeError ?? "未知原因"}),仅渲染报告]`;
|
|
301
|
+
const details: ReviewReportDetails = {
|
|
302
|
+
level: params.level,
|
|
303
|
+
findingsCount: findings.length,
|
|
304
|
+
outFile,
|
|
305
|
+
reportId: params.report_id ?? null,
|
|
306
|
+
};
|
|
307
|
+
return {
|
|
308
|
+
content: [{ type: "text" as const, text: report + tail }],
|
|
309
|
+
details,
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
// Render the returned Markdown report through pi's width-aware Markdown component
|
|
314
|
+
// (the same path assistant text takes), not the plain-Text tool-result fallback that
|
|
315
|
+
// renderer-less extension tools get. Without this, the GFM table is shown as raw
|
|
316
|
+
// `|`/`|---|` wrapped to terminal width — no borders, no alignment.
|
|
317
|
+
// tool-execution.ts wraps renderResult in try/catch and falls back to plain Text on
|
|
318
|
+
// throw, so a failure here degrades to the pre-change behavior rather than erroring.
|
|
319
|
+
renderResult(result, _options, _theme, _context) {
|
|
320
|
+
const text = result.content
|
|
321
|
+
.filter((c): c is Extract<(typeof result.content)[number], { type: "text" }> => c.type === "text")
|
|
322
|
+
.map((c) => c.text)
|
|
323
|
+
.join("\n");
|
|
324
|
+
return new Markdown(text, 0, 0, getMarkdownTheme());
|
|
325
|
+
},
|
|
326
|
+
});
|
package/src/tools/subagent.ts
CHANGED
|
@@ -21,14 +21,35 @@ import {
|
|
|
21
21
|
abortAgent,
|
|
22
22
|
createSpawnRegistry,
|
|
23
23
|
mapWithConcurrencyLimit,
|
|
24
|
+
parsePositiveInt,
|
|
24
25
|
spawnAgent,
|
|
25
26
|
type AgentSpawnRegistry,
|
|
26
27
|
type AgentSpawnOptions,
|
|
27
28
|
type AgentSpawnResult,
|
|
28
|
-
} from "
|
|
29
|
+
} from "@fyeeme/pi-subagent-core";
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
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;
|
|
32
53
|
|
|
33
54
|
// Module-level registry so abortAgent can reach in-flight calls. callIds are
|
|
34
55
|
// unique per tool call (toolCallId#index), so a single registry is safe.
|
|
@@ -42,13 +63,21 @@ const SubagentParams = Type.Object({
|
|
|
42
63
|
description: "Prompt(s) for the sub-agent(s). single/chain use order; parallel runs all.",
|
|
43
64
|
}),
|
|
44
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
|
+
),
|
|
45
72
|
systemPrompt: Type.Optional(Type.String({ description: "Appended to the sub-agent's system prompt." })),
|
|
46
73
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Tool whitelist for the sub-agent. Omit for default tools." })),
|
|
47
74
|
parallelism: Type.Optional(
|
|
48
|
-
Type.Number({
|
|
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
|
+
}),
|
|
49
78
|
),
|
|
50
79
|
maxTurns: Type.Optional(
|
|
51
|
-
Type.Number({ description: "Max assistant turns per sub-agent. When reached, the subprocess is aborted. Omit for
|
|
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." }),
|
|
52
81
|
),
|
|
53
82
|
cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the session cwd." })),
|
|
54
83
|
});
|
|
@@ -221,13 +250,22 @@ export const subagentTool = defineTool<typeof SubagentParams, SubagentDetails>({
|
|
|
221
250
|
|
|
222
251
|
const cwd = params.cwd ?? ctx.cwd;
|
|
223
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;
|
|
224
258
|
// Per-call-agnostic subset of AgentSpawnOptions; callId/task are added per spawn.
|
|
225
259
|
const baseOpts: Omit<AgentSpawnOptions, "callId" | "task"> = {
|
|
226
260
|
cwd,
|
|
227
261
|
model: params.model,
|
|
262
|
+
thinking: params.thinking,
|
|
228
263
|
tools: params.tools,
|
|
229
264
|
signal,
|
|
230
|
-
|
|
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,
|
|
231
269
|
};
|
|
232
270
|
|
|
233
271
|
const partial: SubagentDetails = {
|
|
@@ -287,7 +325,12 @@ export const subagentTool = defineTool<typeof SubagentParams, SubagentDetails>({
|
|
|
287
325
|
};
|
|
288
326
|
|
|
289
327
|
if (params.mode === "parallel") {
|
|
290
|
-
const
|
|
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);
|
|
291
334
|
await mapWithConcurrencyLimit(prompts, conc, (p, i) => runOne(p, i));
|
|
292
335
|
} else {
|
|
293
336
|
// single or chain
|