killeros 1.3.0 → 1.4.1
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/CHANGELOG.md +28 -0
- package/Killeros.ts +2 -0
- package/README.md +44 -10
- package/agents/debugger.md +52 -0
- package/agents/documenter.md +51 -0
- package/agents/planner.md +55 -0
- package/agents/reviewer.md +60 -0
- package/agents/scout.md +58 -0
- package/agents/security.md +56 -0
- package/agents/tester.md +52 -0
- package/agents/worker.md +56 -0
- package/package.json +5 -1
- package/subagents.ts +1080 -0
package/subagents.ts
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { getSupportedThinkingLevels, StringEnum, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
9
|
+
import {
|
|
10
|
+
CONFIG_DIR_NAME,
|
|
11
|
+
getAgentDir,
|
|
12
|
+
getMarkdownTheme,
|
|
13
|
+
parseFrontmatter,
|
|
14
|
+
type ExtensionAPI,
|
|
15
|
+
type ExtensionContext,
|
|
16
|
+
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
|
|
20
|
+
export const SUBAGENT_LIMITS = {
|
|
21
|
+
maxTasks: 8,
|
|
22
|
+
maxReadConcurrency: 4,
|
|
23
|
+
defaultTurns: 8,
|
|
24
|
+
maxTurns: 12,
|
|
25
|
+
defaultTimeoutMs: 300_000,
|
|
26
|
+
maxTimeoutMs: 600_000,
|
|
27
|
+
jsonlLineBytes: 32 * 1024 * 1024,
|
|
28
|
+
traceBytes: 2 * 1024 * 1024,
|
|
29
|
+
stderrBytes: 64 * 1024,
|
|
30
|
+
taskOutputBytes: 50 * 1024,
|
|
31
|
+
toolOutputBytes: 50 * 1024,
|
|
32
|
+
roleFileBytes: 64 * 1024,
|
|
33
|
+
taskCharacters: 20_000,
|
|
34
|
+
killGraceMs: 5_000,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
const WEB_TOOLS = new Set(["web_search", "source_check", "fetch_content", "get_search_content"]);
|
|
38
|
+
const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
|
|
39
|
+
const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
40
|
+
const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
41
|
+
const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
|
|
42
|
+
const INHERIT_SETTING = "inherit";
|
|
43
|
+
const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "maxTurns", "timeoutMs"]);
|
|
44
|
+
|
|
45
|
+
type ThinkingLevel = ModelThinkingLevel;
|
|
46
|
+
export type AgentAccess = "read" | "write";
|
|
47
|
+
export type AgentSource = "bundled" | "personal" | "project";
|
|
48
|
+
export type AgentScope = "user" | "project" | "both";
|
|
49
|
+
export type SubagentStatus = "queued" | "running" | "complete" | "failed" | "cancelled" | "limited";
|
|
50
|
+
|
|
51
|
+
export interface AgentRole {
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
access: AgentAccess;
|
|
55
|
+
tools: string[];
|
|
56
|
+
model?: string;
|
|
57
|
+
thinking?: string;
|
|
58
|
+
maxTurns: number;
|
|
59
|
+
timeoutMs: number;
|
|
60
|
+
prompt: string;
|
|
61
|
+
source: AgentSource;
|
|
62
|
+
filePath: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AgentDiscoveryResult {
|
|
66
|
+
agents: AgentRole[];
|
|
67
|
+
projectAgentsDir: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SubagentUsage {
|
|
71
|
+
input: number;
|
|
72
|
+
output: number;
|
|
73
|
+
cacheRead: number;
|
|
74
|
+
cacheWrite: number;
|
|
75
|
+
totalTokens: number;
|
|
76
|
+
cost: {
|
|
77
|
+
input: number;
|
|
78
|
+
output: number;
|
|
79
|
+
cacheRead: number;
|
|
80
|
+
cacheWrite: number;
|
|
81
|
+
total: number;
|
|
82
|
+
};
|
|
83
|
+
turns: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SubagentTaskResult {
|
|
87
|
+
id: string;
|
|
88
|
+
agent: string;
|
|
89
|
+
agentSource: AgentSource | "unknown";
|
|
90
|
+
sourcePath?: string;
|
|
91
|
+
task: string;
|
|
92
|
+
access?: AgentAccess;
|
|
93
|
+
status: SubagentStatus;
|
|
94
|
+
model?: string;
|
|
95
|
+
thinking?: ThinkingLevel;
|
|
96
|
+
tools: string[];
|
|
97
|
+
trace: string[];
|
|
98
|
+
traceBytes: number;
|
|
99
|
+
traceTruncatedBytes: number;
|
|
100
|
+
stderr: string;
|
|
101
|
+
stderrTruncatedBytes: number;
|
|
102
|
+
output: string;
|
|
103
|
+
outputTruncatedBytes: number;
|
|
104
|
+
usage: SubagentUsage;
|
|
105
|
+
durationMs: number;
|
|
106
|
+
exitCode: number | null;
|
|
107
|
+
terminationReason?: string;
|
|
108
|
+
errorMessage?: string;
|
|
109
|
+
step?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface SubagentDetails {
|
|
113
|
+
mode: "single" | "parallel" | "chain";
|
|
114
|
+
agentScope: AgentScope;
|
|
115
|
+
projectAgentsDir: string | null;
|
|
116
|
+
results: SubagentTaskResult[];
|
|
117
|
+
aggregateUsage: SubagentUsage;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface ModelContext {
|
|
121
|
+
model?: Model<any>;
|
|
122
|
+
thinkingLevel?: ThinkingLevel;
|
|
123
|
+
modelRegistry: {
|
|
124
|
+
getAvailable(): Model<any>[];
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface ResolvedModel {
|
|
129
|
+
model: string;
|
|
130
|
+
thinking: ThinkingLevel;
|
|
131
|
+
definition: Model<any>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface SpawnedProcess {
|
|
135
|
+
stdout: NodeJS.ReadableStream;
|
|
136
|
+
stderr: NodeJS.ReadableStream;
|
|
137
|
+
pid?: number;
|
|
138
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
139
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
140
|
+
once(event: "close", listener: (code: number | null) => void): this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number };
|
|
144
|
+
|
|
145
|
+
export interface SubagentRuntimeOptions {
|
|
146
|
+
bundledAgentsDir?: string;
|
|
147
|
+
userAgentsDir?: string;
|
|
148
|
+
webExtension?: string;
|
|
149
|
+
spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
|
|
150
|
+
createTaskId?: (index: number) => string;
|
|
151
|
+
limits?: Partial<SubagentLimits>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class AgentConfigurationError extends Error {
|
|
155
|
+
constructor(filePath: string, field: string, message: string) {
|
|
156
|
+
super(`${filePath} [${field}]: ${message}`);
|
|
157
|
+
this.name = "AgentConfigurationError";
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function emptyUsage(): SubagentUsage {
|
|
162
|
+
return {
|
|
163
|
+
input: 0,
|
|
164
|
+
output: 0,
|
|
165
|
+
cacheRead: 0,
|
|
166
|
+
cacheWrite: 0,
|
|
167
|
+
totalTokens: 0,
|
|
168
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
169
|
+
turns: 0,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function addUsage(target: SubagentUsage, source: Partial<SubagentUsage> | undefined): void {
|
|
174
|
+
if (!source) return;
|
|
175
|
+
target.input += source.input ?? 0;
|
|
176
|
+
target.output += source.output ?? 0;
|
|
177
|
+
target.cacheRead += source.cacheRead ?? 0;
|
|
178
|
+
target.cacheWrite += source.cacheWrite ?? 0;
|
|
179
|
+
target.totalTokens += source.totalTokens ?? 0;
|
|
180
|
+
target.cost.input += source.cost?.input ?? 0;
|
|
181
|
+
target.cost.output += source.cost?.output ?? 0;
|
|
182
|
+
target.cost.cacheRead += source.cost?.cacheRead ?? 0;
|
|
183
|
+
target.cost.cacheWrite += source.cost?.cacheWrite ?? 0;
|
|
184
|
+
target.cost.total += source.cost?.total ?? 0;
|
|
185
|
+
target.turns += source.turns ?? 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
|
|
189
|
+
const total = emptyUsage();
|
|
190
|
+
for (const result of results) addUsage(total, result.usage);
|
|
191
|
+
return total;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function readBoundedFile(filePath: string, maxBytes: number): string {
|
|
195
|
+
let descriptor: number | undefined;
|
|
196
|
+
try {
|
|
197
|
+
descriptor = openSync(filePath, "r");
|
|
198
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
199
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
200
|
+
if (bytesRead > maxBytes) throw new AgentConfigurationError(filePath, "file", `exceeds ${maxBytes} bytes`);
|
|
201
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
202
|
+
} finally {
|
|
203
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function requiredString(frontmatter: Record<string, unknown>, filePath: string, field: string): string {
|
|
208
|
+
const value = frontmatter[field];
|
|
209
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
210
|
+
throw new AgentConfigurationError(filePath, field, "must be a non-empty string");
|
|
211
|
+
}
|
|
212
|
+
return value.trim();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function optionalPositiveInteger(
|
|
216
|
+
frontmatter: Record<string, unknown>,
|
|
217
|
+
filePath: string,
|
|
218
|
+
field: string,
|
|
219
|
+
fallback: number,
|
|
220
|
+
maximum: number,
|
|
221
|
+
): number {
|
|
222
|
+
const value = frontmatter[field];
|
|
223
|
+
if (value === undefined || value === "") return fallback;
|
|
224
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
225
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maximum) {
|
|
226
|
+
throw new AgentConfigurationError(filePath, field, `must be a positive integer no greater than ${maximum}`);
|
|
227
|
+
}
|
|
228
|
+
return parsed;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentLimits): AgentRole {
|
|
232
|
+
const content = readBoundedFile(filePath, limits.roleFileBytes);
|
|
233
|
+
let parsed: { frontmatter: Record<string, unknown>; body: string };
|
|
234
|
+
try {
|
|
235
|
+
parsed = parseFrontmatter<Record<string, unknown>>(content);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new AgentConfigurationError(filePath, "frontmatter", error instanceof Error ? error.message : String(error));
|
|
238
|
+
}
|
|
239
|
+
const frontmatter = parsed.frontmatter;
|
|
240
|
+
for (const field of Object.keys(frontmatter)) {
|
|
241
|
+
if (!ROLE_FIELDS.has(field)) throw new AgentConfigurationError(filePath, field, "unknown role field");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const name = requiredString(frontmatter, filePath, "name");
|
|
245
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(name)) {
|
|
246
|
+
throw new AgentConfigurationError(filePath, "name", "must use 1-64 letters, numbers, dots, underscores, or hyphens");
|
|
247
|
+
}
|
|
248
|
+
const description = requiredString(frontmatter, filePath, "description");
|
|
249
|
+
if (description.length > 500) throw new AgentConfigurationError(filePath, "description", "must not exceed 500 characters");
|
|
250
|
+
|
|
251
|
+
const accessValue = requiredString(frontmatter, filePath, "access");
|
|
252
|
+
if (accessValue !== "read" && accessValue !== "write") {
|
|
253
|
+
throw new AgentConfigurationError(filePath, "access", 'must be "read" or "write"');
|
|
254
|
+
}
|
|
255
|
+
const toolsValue = requiredString(frontmatter, filePath, "tools");
|
|
256
|
+
const tools = [...new Set(toolsValue.split(",").map((tool) => tool.trim()).filter(Boolean))];
|
|
257
|
+
if (tools.length === 0) throw new AgentConfigurationError(filePath, "tools", "must contain at least one tool");
|
|
258
|
+
for (const tool of tools) {
|
|
259
|
+
if (!KNOWN_TOOLS.has(tool)) throw new AgentConfigurationError(filePath, "tools", `unknown child tool ${JSON.stringify(tool)}`);
|
|
260
|
+
if (accessValue === "read" && WRITE_TOOLS.has(tool)) {
|
|
261
|
+
throw new AgentConfigurationError(filePath, "tools", `read-only roles cannot use ${tool}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const prompt = parsed.body.trim();
|
|
266
|
+
if (!prompt) throw new AgentConfigurationError(filePath, "prompt", "Markdown body must be non-empty");
|
|
267
|
+
const modelValue = frontmatter.model;
|
|
268
|
+
if (modelValue !== undefined && (typeof modelValue !== "string" || !modelValue.trim())) {
|
|
269
|
+
throw new AgentConfigurationError(filePath, "model", "must be a non-empty string when provided");
|
|
270
|
+
}
|
|
271
|
+
const thinkingValue = frontmatter.thinking;
|
|
272
|
+
if (thinkingValue !== undefined && (typeof thinkingValue !== "string" || !thinkingValue.trim())) {
|
|
273
|
+
throw new AgentConfigurationError(filePath, "thinking", "must be a non-empty string when provided");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
name,
|
|
278
|
+
description,
|
|
279
|
+
access: accessValue,
|
|
280
|
+
tools,
|
|
281
|
+
model: typeof modelValue === "string" ? modelValue.trim() : undefined,
|
|
282
|
+
thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
|
|
283
|
+
maxTurns: optionalPositiveInteger(frontmatter, filePath, "maxTurns", limits.defaultTurns, limits.maxTurns),
|
|
284
|
+
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
|
|
285
|
+
prompt,
|
|
286
|
+
source,
|
|
287
|
+
filePath,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function loadAgentDirectory(dir: string, source: AgentSource, limits: SubagentLimits): AgentRole[] {
|
|
292
|
+
let entries;
|
|
293
|
+
try {
|
|
294
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
297
|
+
throw new Error(`Could not read ${source} agent directory ${dir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
298
|
+
}
|
|
299
|
+
const agents: AgentRole[] = [];
|
|
300
|
+
const names = new Set<string>();
|
|
301
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
302
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
303
|
+
const agent = parseAgentFile(path.join(dir, entry.name), source, limits);
|
|
304
|
+
if (names.has(agent.name)) throw new AgentConfigurationError(agent.filePath, "name", `duplicate ${source} role ${JSON.stringify(agent.name)}`);
|
|
305
|
+
names.add(agent.name);
|
|
306
|
+
agents.push(agent);
|
|
307
|
+
}
|
|
308
|
+
return agents;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isDirectory(candidate: string): boolean {
|
|
312
|
+
try {
|
|
313
|
+
return statSync(candidate).isDirectory();
|
|
314
|
+
} catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function findProjectAgentsDir(cwd: string): string | null {
|
|
320
|
+
let current = path.resolve(cwd);
|
|
321
|
+
while (true) {
|
|
322
|
+
const candidate = path.join(current, CONFIG_DIR_NAME, "agents");
|
|
323
|
+
if (isDirectory(candidate)) return candidate;
|
|
324
|
+
const parent = path.dirname(current);
|
|
325
|
+
if (parent === current) return null;
|
|
326
|
+
current = parent;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function discoverAgentRoles(
|
|
331
|
+
cwd: string,
|
|
332
|
+
scope: AgentScope,
|
|
333
|
+
projectTrusted: boolean,
|
|
334
|
+
options: Pick<SubagentRuntimeOptions, "bundledAgentsDir" | "userAgentsDir" | "limits"> = {},
|
|
335
|
+
): AgentDiscoveryResult {
|
|
336
|
+
const limits = { ...SUBAGENT_LIMITS, ...options.limits };
|
|
337
|
+
const bundledDir = options.bundledAgentsDir ?? fileURLToPath(new URL("./agents/", import.meta.url));
|
|
338
|
+
const userDir = options.userAgentsDir ?? path.join(getAgentDir(), "agents");
|
|
339
|
+
const wantsProject = scope === "project" || scope === "both";
|
|
340
|
+
if (wantsProject && !projectTrusted) throw new Error("Project agents require a trusted project");
|
|
341
|
+
const projectAgentsDir = wantsProject ? findProjectAgentsDir(cwd) : null;
|
|
342
|
+
|
|
343
|
+
const layers: Array<{ dir: string; source: AgentSource }> = [{ dir: bundledDir, source: "bundled" }];
|
|
344
|
+
if (scope === "user" || scope === "both") layers.push({ dir: userDir, source: "personal" });
|
|
345
|
+
if (wantsProject && projectAgentsDir) layers.push({ dir: projectAgentsDir, source: "project" });
|
|
346
|
+
|
|
347
|
+
const byName = new Map<string, AgentRole>();
|
|
348
|
+
for (const layer of layers) {
|
|
349
|
+
for (const agent of loadAgentDirectory(layer.dir, layer.source, limits)) byName.set(agent.name, agent);
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
agents: [...byName.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
|
353
|
+
projectAgentsDir,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function matchingModels(value: string, available: Model<any>[]): Model<any>[] {
|
|
358
|
+
const slash = value.indexOf("/");
|
|
359
|
+
if (slash >= 1 && slash < value.length - 1) {
|
|
360
|
+
const provider = value.slice(0, slash);
|
|
361
|
+
const id = value.slice(slash + 1);
|
|
362
|
+
return available.filter((model) => model.provider === provider && model.id === id);
|
|
363
|
+
}
|
|
364
|
+
return available.filter((model) => model.id === value);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function splitModelAndThinking(value: string, filePath: string, available: Model<any>[]): { model: string; thinking?: string } {
|
|
368
|
+
if (matchingModels(value, available).length > 0) return { model: value };
|
|
369
|
+
const colon = value.lastIndexOf(":");
|
|
370
|
+
if (colon < 0) return { model: value };
|
|
371
|
+
const model = value.slice(0, colon);
|
|
372
|
+
if (!model) throw new AgentConfigurationError(filePath, "model", "model identifier is missing");
|
|
373
|
+
if (matchingModels(model, available).length > 0) return { model, thinking: value.slice(colon + 1) };
|
|
374
|
+
return { model: value };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveAvailableModel(value: string, filePath: string, available: Model<any>[]): Model<any> {
|
|
378
|
+
const matches = matchingModels(value, available);
|
|
379
|
+
if (matches.length === 0) throw new AgentConfigurationError(filePath, "model", `unavailable model ${JSON.stringify(value)}`);
|
|
380
|
+
if (matches.length > 1) throw new AgentConfigurationError(filePath, "model", `ambiguous model ${JSON.stringify(value)}; use provider/model`);
|
|
381
|
+
return matches[0]!;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function configuredSetting(override: string | undefined, roleSetting: string | undefined): string | undefined {
|
|
385
|
+
const overrideValue = override?.trim();
|
|
386
|
+
const roleValue = roleSetting?.trim();
|
|
387
|
+
const selected = overrideValue && overrideValue !== INHERIT_SETTING ? overrideValue : roleValue;
|
|
388
|
+
return selected && selected !== INHERIT_SETTING ? selected : undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function resolveAgentModel(
|
|
392
|
+
agent: AgentRole,
|
|
393
|
+
ctx: ModelContext,
|
|
394
|
+
modelOverride?: string,
|
|
395
|
+
thinkingOverride?: string,
|
|
396
|
+
): ResolvedModel {
|
|
397
|
+
const inheritedThinking = ctx.thinkingLevel ?? "off";
|
|
398
|
+
const configuredModel = configuredSetting(modelOverride, agent.model);
|
|
399
|
+
const configuredThinking = configuredSetting(thinkingOverride, agent.thinking);
|
|
400
|
+
let definition: Model<any> | undefined;
|
|
401
|
+
let thinking: string = configuredThinking ?? inheritedThinking;
|
|
402
|
+
|
|
403
|
+
if (configuredModel) {
|
|
404
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
405
|
+
const requested = splitModelAndThinking(configuredModel, agent.filePath, available);
|
|
406
|
+
thinking = configuredThinking ?? requested.thinking ?? inheritedThinking;
|
|
407
|
+
definition = resolveAvailableModel(requested.model, agent.filePath, available);
|
|
408
|
+
} else {
|
|
409
|
+
definition = ctx.model;
|
|
410
|
+
if (!definition) throw new AgentConfigurationError(agent.filePath, "model", "no active parent model is available to inherit");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const supportedThinking = getSupportedThinkingLevels(definition) as readonly string[];
|
|
414
|
+
if (!supportedThinking.includes(thinking)) {
|
|
415
|
+
throw new AgentConfigurationError(agent.filePath, "thinking", `${definition.provider}/${definition.id} does not support thinking level ${thinking}`);
|
|
416
|
+
}
|
|
417
|
+
return { model: `${definition.provider}/${definition.id}`, thinking: thinking as ThinkingLevel, definition };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
421
|
+
const currentScript = process.argv[1];
|
|
422
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
423
|
+
if (currentScript && !isBunVirtualScript) {
|
|
424
|
+
try {
|
|
425
|
+
if (statSync(currentScript).isFile()) return { command: process.execPath, args: [currentScript, ...args] };
|
|
426
|
+
} catch {
|
|
427
|
+
// Fall through to the installed pi command.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const executable = path.basename(process.execPath).toLocaleLowerCase();
|
|
431
|
+
return /^(node|bun)(\.exe)?$/u.test(executable)
|
|
432
|
+
? { command: "pi", args }
|
|
433
|
+
: { command: process.execPath, args };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function childProcessEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
437
|
+
const childEnvironment = { ...environment };
|
|
438
|
+
delete childEnvironment.PI_SESSION_FILE;
|
|
439
|
+
delete childEnvironment.PI_SESSION_ID;
|
|
440
|
+
return childEnvironment;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
|
|
444
|
+
const invocation = getPiInvocation(args);
|
|
445
|
+
return spawn(invocation.command, invocation.args, {
|
|
446
|
+
cwd,
|
|
447
|
+
detached: process.platform !== "win32",
|
|
448
|
+
env: childProcessEnvironment(),
|
|
449
|
+
shell: false,
|
|
450
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
451
|
+
windowsHide: true,
|
|
452
|
+
}) as unknown as SpawnedProcess;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function terminateProcess(child: SpawnedProcess, force: boolean): void {
|
|
456
|
+
if (process.platform === "win32" && force && child.pid) {
|
|
457
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
458
|
+
shell: false,
|
|
459
|
+
stdio: "ignore",
|
|
460
|
+
windowsHide: true,
|
|
461
|
+
});
|
|
462
|
+
killer.unref();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (process.platform !== "win32" && child.pid) {
|
|
466
|
+
try {
|
|
467
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
468
|
+
return;
|
|
469
|
+
} catch {
|
|
470
|
+
// Fall back to the direct child when process-group signaling is unavailable.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
475
|
+
} catch {
|
|
476
|
+
// The process may already have exited.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
|
|
481
|
+
const bytes = Buffer.from(text, "utf8");
|
|
482
|
+
if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
|
|
483
|
+
let truncated = bytes.subarray(0, maxBytes).toString("utf8");
|
|
484
|
+
if (truncated.endsWith("�")) truncated = truncated.slice(0, -1);
|
|
485
|
+
return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function textContent(message: any): string {
|
|
489
|
+
if (!Array.isArray(message?.content)) return "";
|
|
490
|
+
return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function traceMessage(message: any): string[] {
|
|
494
|
+
if (!Array.isArray(message?.content)) return [];
|
|
495
|
+
const entries: string[] = [];
|
|
496
|
+
for (const part of message.content) {
|
|
497
|
+
if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
|
|
498
|
+
const args = truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text;
|
|
499
|
+
entries.push(`${part.name} ${args}`);
|
|
500
|
+
}
|
|
501
|
+
return entries;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function appendTrace(result: SubagentTaskResult, entries: string[], maxBytes: number): void {
|
|
505
|
+
for (const entry of entries) {
|
|
506
|
+
const entryBytes = Buffer.byteLength(entry, "utf8");
|
|
507
|
+
const remaining = Math.max(0, maxBytes - result.traceBytes);
|
|
508
|
+
const retained = truncateUtf8(entry, remaining).text;
|
|
509
|
+
const retainedBytes = Buffer.byteLength(retained, "utf8");
|
|
510
|
+
if (retained) result.trace.push(retained);
|
|
511
|
+
result.traceBytes += retainedBytes;
|
|
512
|
+
result.traceTruncatedBytes += entryBytes - retainedBytes;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
|
|
517
|
+
return {
|
|
518
|
+
id,
|
|
519
|
+
agent,
|
|
520
|
+
agentSource: "unknown",
|
|
521
|
+
task,
|
|
522
|
+
status: "queued",
|
|
523
|
+
tools: [],
|
|
524
|
+
trace: [],
|
|
525
|
+
traceBytes: 0,
|
|
526
|
+
traceTruncatedBytes: 0,
|
|
527
|
+
stderr: "",
|
|
528
|
+
stderrTruncatedBytes: 0,
|
|
529
|
+
output: "",
|
|
530
|
+
outputTruncatedBytes: 0,
|
|
531
|
+
usage: emptyUsage(),
|
|
532
|
+
durationMs: 0,
|
|
533
|
+
exitCode: null,
|
|
534
|
+
step,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
|
|
539
|
+
return {
|
|
540
|
+
...result,
|
|
541
|
+
tools: [...result.tools],
|
|
542
|
+
trace: [...result.trace],
|
|
543
|
+
usage: { ...result.usage, cost: { ...result.usage.cost } },
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
|
|
548
|
+
const cloned = results.map(cloneResult);
|
|
549
|
+
return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
|
|
553
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
|
|
554
|
+
const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
|
|
555
|
+
await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
|
|
556
|
+
return { directory, filePath };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
interface RunTaskOptions {
|
|
560
|
+
cwd: string;
|
|
561
|
+
agent: AgentRole;
|
|
562
|
+
task: string;
|
|
563
|
+
id: string;
|
|
564
|
+
step?: number;
|
|
565
|
+
model: ResolvedModel;
|
|
566
|
+
signal?: AbortSignal;
|
|
567
|
+
spawnProcess: (args: string[], cwd: string) => SpawnedProcess;
|
|
568
|
+
webExtension?: string;
|
|
569
|
+
projectTrusted: boolean;
|
|
570
|
+
limits: SubagentLimits;
|
|
571
|
+
onChange: (result: SubagentTaskResult) => void;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
575
|
+
const { agent, limits } = options;
|
|
576
|
+
const result = makeQueuedResult(options.id, agent.name, options.task, options.step);
|
|
577
|
+
result.agentSource = agent.source;
|
|
578
|
+
result.sourcePath = agent.filePath;
|
|
579
|
+
result.access = agent.access;
|
|
580
|
+
result.tools = [...agent.tools];
|
|
581
|
+
result.model = options.model.model;
|
|
582
|
+
result.thinking = options.model.thinking;
|
|
583
|
+
result.status = "running";
|
|
584
|
+
const startedAt = Date.now();
|
|
585
|
+
options.onChange(result);
|
|
586
|
+
|
|
587
|
+
if (options.signal?.aborted) {
|
|
588
|
+
result.status = "cancelled";
|
|
589
|
+
result.terminationReason = "abort";
|
|
590
|
+
result.durationMs = Date.now() - startedAt;
|
|
591
|
+
options.onChange(result);
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
let promptDirectory: string | undefined;
|
|
596
|
+
let child: SpawnedProcess | undefined;
|
|
597
|
+
try {
|
|
598
|
+
const prompt = await writeRolePrompt(agent);
|
|
599
|
+
promptDirectory = prompt.directory;
|
|
600
|
+
if (options.signal?.aborted) {
|
|
601
|
+
result.status = "cancelled";
|
|
602
|
+
result.terminationReason = "abort";
|
|
603
|
+
result.durationMs = Date.now() - startedAt;
|
|
604
|
+
options.onChange(result);
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
const args = [
|
|
608
|
+
"--mode", "json",
|
|
609
|
+
"-p",
|
|
610
|
+
"--no-session",
|
|
611
|
+
"--no-extensions",
|
|
612
|
+
"--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
|
|
613
|
+
"--no-prompt-templates",
|
|
614
|
+
options.projectTrusted ? "--approve" : "--no-approve",
|
|
615
|
+
"--model", options.model.model,
|
|
616
|
+
"--thinking", options.model.thinking,
|
|
617
|
+
"--tools", agent.tools.join(","),
|
|
618
|
+
"--append-system-prompt", prompt.filePath,
|
|
619
|
+
`Task: ${options.task}`,
|
|
620
|
+
];
|
|
621
|
+
child = options.spawnProcess(args, options.cwd);
|
|
622
|
+
|
|
623
|
+
let stdoutLineBuffer = Buffer.alloc(0);
|
|
624
|
+
let stdoutLineBytes = 0;
|
|
625
|
+
let rawStderrBytes = 0;
|
|
626
|
+
let requestedStatus: SubagentStatus | undefined;
|
|
627
|
+
let requestedReason: string | undefined;
|
|
628
|
+
let closed = false;
|
|
629
|
+
let malformedError: string | undefined;
|
|
630
|
+
let forceTimer: NodeJS.Timeout | undefined;
|
|
631
|
+
let settleTimer: NodeJS.Timeout | undefined;
|
|
632
|
+
|
|
633
|
+
const requestTermination = (status: "failed" | "cancelled" | "limited", reason: string, errorMessage?: string): void => {
|
|
634
|
+
if (requestedStatus) return;
|
|
635
|
+
requestedStatus = status;
|
|
636
|
+
requestedReason = reason;
|
|
637
|
+
if (errorMessage) result.errorMessage = errorMessage;
|
|
638
|
+
terminateProcess(child!, false);
|
|
639
|
+
forceTimer = setTimeout(() => {
|
|
640
|
+
if (closed) return;
|
|
641
|
+
terminateProcess(child!, true);
|
|
642
|
+
settleTimer = setTimeout(() => {
|
|
643
|
+
if (!closed) finish(null);
|
|
644
|
+
}, 1_000);
|
|
645
|
+
}, limits.killGraceMs);
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const processLine = (line: string): void => {
|
|
649
|
+
if (!line.trim() || requestedStatus) return;
|
|
650
|
+
let event: any;
|
|
651
|
+
try {
|
|
652
|
+
event = JSON.parse(line);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
malformedError = error instanceof Error ? error.message : String(error);
|
|
655
|
+
requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${malformedError}`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (event?.type === "message_end" && event.message?.role === "assistant") {
|
|
659
|
+
const message = event.message;
|
|
660
|
+
result.usage.turns += 1;
|
|
661
|
+
addUsage(result.usage, { ...message.usage, turns: 0 });
|
|
662
|
+
appendTrace(result, traceMessage(message), limits.traceBytes);
|
|
663
|
+
const output = textContent(message);
|
|
664
|
+
if (output) {
|
|
665
|
+
const capped = truncateUtf8(output, limits.taskOutputBytes);
|
|
666
|
+
result.output = capped.text;
|
|
667
|
+
result.outputTruncatedBytes = capped.omittedBytes;
|
|
668
|
+
}
|
|
669
|
+
if (typeof message.model === "string") result.model = message.provider ? `${message.provider}/${message.model}` : message.model;
|
|
670
|
+
const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined;
|
|
671
|
+
if (stopReason === "stop" || stopReason === "toolUse") {
|
|
672
|
+
result.terminationReason = undefined;
|
|
673
|
+
result.errorMessage = undefined;
|
|
674
|
+
} else if (stopReason) {
|
|
675
|
+
result.terminationReason = stopReason;
|
|
676
|
+
}
|
|
677
|
+
if (typeof message.errorMessage === "string") result.errorMessage = message.errorMessage;
|
|
678
|
+
if (stopReason === "length") requestTermination("limited", "model_output_limit");
|
|
679
|
+
if (result.usage.turns > agent.maxTurns || result.usage.turns >= agent.maxTurns && stopReason === "toolUse") {
|
|
680
|
+
requestTermination("limited", "turn_limit");
|
|
681
|
+
}
|
|
682
|
+
options.onChange(result);
|
|
683
|
+
} else if (event?.type === "agent_end" && event.willRetry === true) {
|
|
684
|
+
if (result.usage.turns >= agent.maxTurns) {
|
|
685
|
+
requestTermination("limited", "turn_limit");
|
|
686
|
+
options.onChange(result);
|
|
687
|
+
}
|
|
688
|
+
} else if (event?.type === "tool_result_end" && event.message) {
|
|
689
|
+
const toolName = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
|
|
690
|
+
appendTrace(result, [`${toolName} result${event.message.isError ? " (error)" : ""}`], limits.traceBytes);
|
|
691
|
+
options.onChange(result);
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const appendStdoutLine = (fragment: Buffer): boolean => {
|
|
696
|
+
const nextBytes = stdoutLineBytes + fragment.length;
|
|
697
|
+
if (nextBytes > limits.jsonlLineBytes) {
|
|
698
|
+
requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
if (nextBytes > stdoutLineBuffer.length) {
|
|
702
|
+
const nextCapacity = Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLineBuffer.length * 2, 4_096));
|
|
703
|
+
const expanded = Buffer.allocUnsafe(nextCapacity);
|
|
704
|
+
stdoutLineBuffer.copy(expanded, 0, 0, stdoutLineBytes);
|
|
705
|
+
stdoutLineBuffer = expanded;
|
|
706
|
+
}
|
|
707
|
+
fragment.copy(stdoutLineBuffer, stdoutLineBytes);
|
|
708
|
+
stdoutLineBytes = nextBytes;
|
|
709
|
+
return true;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
const processStdoutLine = (): void => {
|
|
713
|
+
const line = stdoutLineBuffer.toString("utf8", 0, stdoutLineBytes);
|
|
714
|
+
stdoutLineBuffer = Buffer.alloc(0);
|
|
715
|
+
stdoutLineBytes = 0;
|
|
716
|
+
processLine(line);
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
let finish!: (code: number | null) => void;
|
|
720
|
+
const closedPromise = new Promise<void>((resolve) => {
|
|
721
|
+
finish = (code: number | null): void => {
|
|
722
|
+
if (closed) return;
|
|
723
|
+
closed = true;
|
|
724
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
725
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
726
|
+
if (stdoutLineBytes > 0 && !requestedStatus) processStdoutLine();
|
|
727
|
+
result.exitCode = code;
|
|
728
|
+
if (requestedStatus) {
|
|
729
|
+
result.status = requestedStatus;
|
|
730
|
+
result.terminationReason = requestedReason;
|
|
731
|
+
} else if (result.terminationReason === "length") {
|
|
732
|
+
result.status = "limited";
|
|
733
|
+
result.terminationReason = "model_output_limit";
|
|
734
|
+
} else if (code !== 0 || result.errorMessage || result.terminationReason) {
|
|
735
|
+
result.status = "failed";
|
|
736
|
+
result.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
|
|
737
|
+
} else if (result.usage.turns === 0) {
|
|
738
|
+
result.status = "failed";
|
|
739
|
+
result.terminationReason = "missing_assistant_message";
|
|
740
|
+
result.errorMessage = "Child exited without an assistant response";
|
|
741
|
+
} else {
|
|
742
|
+
result.status = "complete";
|
|
743
|
+
result.terminationReason = "completed";
|
|
744
|
+
}
|
|
745
|
+
result.durationMs = Date.now() - startedAt;
|
|
746
|
+
options.onChange(result);
|
|
747
|
+
resolve();
|
|
748
|
+
};
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
752
|
+
if (requestedStatus) return;
|
|
753
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
754
|
+
let offset = 0;
|
|
755
|
+
while (offset < buffer.length && !requestedStatus) {
|
|
756
|
+
const newline = buffer.indexOf(0x0a, offset);
|
|
757
|
+
const end = newline < 0 ? buffer.length : newline;
|
|
758
|
+
if (!appendStdoutLine(buffer.subarray(offset, end))) return;
|
|
759
|
+
if (newline < 0) return;
|
|
760
|
+
processStdoutLine();
|
|
761
|
+
offset = newline + 1;
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
765
|
+
if (requestedStatus) return;
|
|
766
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
767
|
+
const remaining = Math.max(0, limits.stderrBytes - rawStderrBytes);
|
|
768
|
+
rawStderrBytes += buffer.length;
|
|
769
|
+
if (remaining > 0) result.stderr += buffer.subarray(0, remaining).toString("utf8");
|
|
770
|
+
result.stderrTruncatedBytes = Math.max(0, rawStderrBytes - limits.stderrBytes);
|
|
771
|
+
if (rawStderrBytes > limits.stderrBytes) requestTermination("limited", "stderr_limit");
|
|
772
|
+
});
|
|
773
|
+
child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
|
|
774
|
+
child.once("close", finish);
|
|
775
|
+
|
|
776
|
+
const timeoutTimer = setTimeout(() => requestTermination("limited", "timeout"), agent.timeoutMs);
|
|
777
|
+
const abortHandler = (): void => requestTermination("cancelled", "abort");
|
|
778
|
+
if (options.signal?.aborted) abortHandler();
|
|
779
|
+
else options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
780
|
+
|
|
781
|
+
try {
|
|
782
|
+
await closedPromise;
|
|
783
|
+
} finally {
|
|
784
|
+
clearTimeout(timeoutTimer);
|
|
785
|
+
options.signal?.removeEventListener("abort", abortHandler);
|
|
786
|
+
}
|
|
787
|
+
if (!result.output && result.stderr && (result.status as SubagentStatus) !== "complete") {
|
|
788
|
+
result.errorMessage ??= result.stderr.trim();
|
|
789
|
+
}
|
|
790
|
+
return result;
|
|
791
|
+
} catch (error) {
|
|
792
|
+
result.status = options.signal?.aborted ? "cancelled" : "failed";
|
|
793
|
+
result.terminationReason = options.signal?.aborted ? "abort" : "spawn_error";
|
|
794
|
+
result.errorMessage = error instanceof Error ? error.message : String(error);
|
|
795
|
+
result.durationMs = Date.now() - startedAt;
|
|
796
|
+
options.onChange(result);
|
|
797
|
+
return result;
|
|
798
|
+
} finally {
|
|
799
|
+
if (promptDirectory) {
|
|
800
|
+
try {
|
|
801
|
+
await rm(promptDirectory, { recursive: true, force: true });
|
|
802
|
+
} catch {
|
|
803
|
+
// Temporary prompt cleanup is best effort after child termination.
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function mapReadTasks<T>(items: T[], concurrency: number, run: (item: T, index: number) => Promise<void>): Promise<void> {
|
|
810
|
+
let next = 0;
|
|
811
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
812
|
+
while (true) {
|
|
813
|
+
const index = next;
|
|
814
|
+
next += 1;
|
|
815
|
+
if (index >= items.length) return;
|
|
816
|
+
await run(items[index]!, index);
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
await Promise.all(workers);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const TaskSchema = Type.Object({
|
|
823
|
+
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
824
|
+
task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Bounded task for the role" }),
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const ChainTaskSchema = Type.Object({
|
|
828
|
+
agent: Type.String({ minLength: 1, maxLength: 64, description: "Agent role name" }),
|
|
829
|
+
task: Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task with optional {previous} handoff placeholder" }),
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const SubagentParams = Type.Object({
|
|
833
|
+
agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
|
|
834
|
+
task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
|
|
835
|
+
tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
|
|
836
|
+
chain: Type.Optional(Type.Array(ChainTaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
|
|
837
|
+
model: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "Model for every task as provider/model; inherit uses each role setting or the active parent" })),
|
|
838
|
+
thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
|
|
839
|
+
agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
|
|
840
|
+
default: "user",
|
|
841
|
+
description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
|
|
842
|
+
})),
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
type TaskInput = { agent: string; task: string };
|
|
846
|
+
|
|
847
|
+
type ToolUpdate = (partial: { content: Array<{ type: "text"; text: string }>; details: SubagentDetails }) => void;
|
|
848
|
+
|
|
849
|
+
function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?: TaskInput[] }): string[] {
|
|
850
|
+
if (params.agent) return [params.agent];
|
|
851
|
+
return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function formatUsage(usage: SubagentUsage): string {
|
|
855
|
+
const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
|
|
856
|
+
if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
|
|
857
|
+
return parts.join(" · ");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function taskSummary(result: SubagentTaskResult): string {
|
|
861
|
+
return `${result.id} · ${result.agent} · ${result.status} · ${formatUsage(result.usage)}`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
|
|
865
|
+
const sections = results.map((result) => {
|
|
866
|
+
const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
|
|
867
|
+
const reason = result.terminationReason && result.terminationReason !== "completed" ? `\nReason: ${result.terminationReason}` : "";
|
|
868
|
+
const body = result.output || result.errorMessage || result.stderr.trim() || "(no output)";
|
|
869
|
+
const truncation = result.outputTruncatedBytes ? `\n\n[Task output truncated: ${result.outputTruncatedBytes} bytes omitted; bounded detail is available when expanded.]` : "";
|
|
870
|
+
return `${heading}${reason}\n\n${body}${truncation}`;
|
|
871
|
+
});
|
|
872
|
+
const complete = results.filter((result) => result.status === "complete").length;
|
|
873
|
+
const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
|
|
874
|
+
const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
|
|
875
|
+
const capped = truncateUtf8(text, Math.max(0, maxBytes - Buffer.byteLength(marker)));
|
|
876
|
+
return capped.omittedBytes ? `${capped.text}${marker}` : text;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
|
|
880
|
+
if (status === "running") return "accent";
|
|
881
|
+
if (status === "complete") return "success";
|
|
882
|
+
if (status === "failed") return "error";
|
|
883
|
+
if (status === "limited" || status === "cancelled") return "warning";
|
|
884
|
+
return "muted";
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function statusIcon(status: SubagentStatus): string {
|
|
888
|
+
if (status === "running") return "✻";
|
|
889
|
+
if (status === "complete") return "✓";
|
|
890
|
+
if (status === "failed") return "✗";
|
|
891
|
+
if (status === "queued") return "○";
|
|
892
|
+
return "!";
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
|
|
896
|
+
const limits = { ...SUBAGENT_LIMITS, ...options.limits };
|
|
897
|
+
const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
|
|
898
|
+
|
|
899
|
+
pi.registerTool({
|
|
900
|
+
name: "subagent",
|
|
901
|
+
label: "Subagents",
|
|
902
|
+
description: "Delegate one task, up to eight parallel tasks, or a sequential chain to isolated Pi child roles. Set model as provider/model and thinking as a separate supported effort level; both apply to every task in the call. Bundled and personal roles are available by default; trusted project roles require project/both scope and confirmation. Children have explicit local and web tools, load pi-web-access explicitly, discover skills, keep arbitrary extensions and prompt templates disabled, and enforce at most 12 turns, ten minutes, a 32 MiB JSONL line, 2 MiB retained trace, 64 KiB stderr, and 50 KiB returned output per task.",
|
|
903
|
+
promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
|
|
904
|
+
promptGuidelines: [
|
|
905
|
+
"Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
|
|
906
|
+
"Do not request multiple write-capable subagents in one parallel batch.",
|
|
907
|
+
"Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
|
|
908
|
+
"When the user names a model or thinking effort, pass model and thinking separately; use inherit when the active parent or role setting should decide.",
|
|
909
|
+
],
|
|
910
|
+
parameters: SubagentParams,
|
|
911
|
+
executionMode: "sequential",
|
|
912
|
+
|
|
913
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
914
|
+
const scope: AgentScope = params.agentScope ?? "user";
|
|
915
|
+
const hasSingleFields = params.agent !== undefined || params.task !== undefined;
|
|
916
|
+
const hasParallel = params.tasks !== undefined;
|
|
917
|
+
const hasChain = params.chain !== undefined;
|
|
918
|
+
const hasSingle = hasSingleFields && Boolean(params.agent && params.task);
|
|
919
|
+
if (Number(hasSingleFields) + Number(hasParallel) + Number(hasChain) !== 1
|
|
920
|
+
|| hasSingleFields && !hasSingle
|
|
921
|
+
|| hasParallel && params.tasks!.length === 0
|
|
922
|
+
|| hasChain && params.chain!.length === 0) {
|
|
923
|
+
throw new Error("Provide exactly one subagent mode: agent + task, tasks, or chain");
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const discovery = discoverAgentRoles(ctx.cwd, scope, ctx.isProjectTrusted(), options);
|
|
927
|
+
const roles = new Map(discovery.agents.map((agent) => [agent.name, agent]));
|
|
928
|
+
const requested = requestedAgents(params);
|
|
929
|
+
for (const name of requested) {
|
|
930
|
+
if (!roles.has(name)) {
|
|
931
|
+
const available = discovery.agents.map((agent) => `${agent.name} (${agent.source})`).join(", ") || "none";
|
|
932
|
+
throw new Error(`Unknown subagent ${JSON.stringify(name)}. Available: ${available}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const projectRoles = [...new Set(requested.map((name) => roles.get(name)!).filter((role) => role.source === "project"))];
|
|
937
|
+
if (projectRoles.length) {
|
|
938
|
+
if (!ctx.hasUI) throw new Error("Project-local subagents require interactive confirmation");
|
|
939
|
+
const approved = await ctx.ui.confirm(
|
|
940
|
+
"Run project-local subagents?",
|
|
941
|
+
`Roles: ${projectRoles.map((role) => role.name).join(", ")}\nSources:\n${projectRoles.map((role) => role.filePath).join("\n")}\n\nThese trusted repository files control child prompts and tools.`,
|
|
942
|
+
);
|
|
943
|
+
if (!approved) throw new Error("Project-local subagents were not approved");
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const resolvedModels = new Map<string, ResolvedModel>();
|
|
947
|
+
for (const name of new Set(requested)) {
|
|
948
|
+
resolvedModels.set(name, resolveAgentModel(roles.get(name)!, ctx, params.model, params.thinking));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
|
|
952
|
+
const inputs: TaskInput[] = hasSingle
|
|
953
|
+
? [{ agent: params.agent!, task: params.task! }]
|
|
954
|
+
: hasParallel ? params.tasks! : params.chain!;
|
|
955
|
+
if (inputs.length > limits.maxTasks) throw new Error(`At most ${limits.maxTasks} subagent tasks are allowed`);
|
|
956
|
+
if (hasParallel) {
|
|
957
|
+
const writers = inputs.filter((input) => roles.get(input.agent)!.access === "write");
|
|
958
|
+
if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const invocationPrefix = randomUUID().slice(0, 8);
|
|
962
|
+
const createTaskId = options.createTaskId ?? ((index: number) => `${invocationPrefix}-${index + 1}`);
|
|
963
|
+
const results = inputs.map((input, index) => makeQueuedResult(createTaskId(index), input.agent, input.task, hasChain ? index + 1 : undefined));
|
|
964
|
+
const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
|
|
965
|
+
(onUpdate as ToolUpdate | undefined)?.({
|
|
966
|
+
content: [{ type: "text", text: message }],
|
|
967
|
+
details: cloneDetails(mode, scope, discovery.projectAgentsDir, results),
|
|
968
|
+
});
|
|
969
|
+
};
|
|
970
|
+
const runAt = async (index: number, task: string): Promise<void> => {
|
|
971
|
+
if (signal?.aborted) {
|
|
972
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
|
|
973
|
+
emit();
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const input = inputs[index]!;
|
|
977
|
+
if ([...task].length > limits.taskCharacters) {
|
|
978
|
+
results[index] = {
|
|
979
|
+
...results[index]!,
|
|
980
|
+
status: "failed",
|
|
981
|
+
terminationReason: "task_limit",
|
|
982
|
+
errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
|
|
983
|
+
};
|
|
984
|
+
emit();
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
results[index] = await runTask({
|
|
988
|
+
cwd: ctx.cwd,
|
|
989
|
+
agent: roles.get(input.agent)!,
|
|
990
|
+
task,
|
|
991
|
+
id: results[index]!.id,
|
|
992
|
+
step: results[index]!.step,
|
|
993
|
+
model: resolvedModels.get(input.agent)!,
|
|
994
|
+
signal,
|
|
995
|
+
webExtension: options.webExtension,
|
|
996
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
997
|
+
spawnProcess,
|
|
998
|
+
limits,
|
|
999
|
+
onChange: (next) => {
|
|
1000
|
+
results[index] = cloneResult(next);
|
|
1001
|
+
emit();
|
|
1002
|
+
},
|
|
1003
|
+
});
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
emit(`${mode}: ${results.length} queued`);
|
|
1007
|
+
if (hasChain) {
|
|
1008
|
+
let previous = "";
|
|
1009
|
+
for (let index = 0; index < inputs.length; index += 1) {
|
|
1010
|
+
const task = inputs[index]!.task.replaceAll("{previous}", previous);
|
|
1011
|
+
await runAt(index, task);
|
|
1012
|
+
if (results[index]!.status !== "complete") break;
|
|
1013
|
+
previous = results[index]!.output;
|
|
1014
|
+
}
|
|
1015
|
+
for (const result of results) {
|
|
1016
|
+
if (result.status === "queued") {
|
|
1017
|
+
result.status = signal?.aborted ? "cancelled" : "failed";
|
|
1018
|
+
result.terminationReason = signal?.aborted ? "abort" : "chain_stopped";
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
} else if (hasParallel) {
|
|
1022
|
+
const readIndexes = inputs.map((input, index) => ({ input, index })).filter(({ input }) => roles.get(input.agent)!.access === "read");
|
|
1023
|
+
const writerIndex = inputs.findIndex((input) => roles.get(input.agent)!.access === "write");
|
|
1024
|
+
await mapReadTasks(readIndexes, limits.maxReadConcurrency, async ({ index }) => runAt(index, inputs[index]!.task));
|
|
1025
|
+
if (writerIndex >= 0) await runAt(writerIndex, inputs[writerIndex]!.task);
|
|
1026
|
+
} else {
|
|
1027
|
+
await runAt(0, inputs[0]!.task);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const details = cloneDetails(mode, scope, discovery.projectAgentsDir, results);
|
|
1031
|
+
return {
|
|
1032
|
+
content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
|
|
1033
|
+
details,
|
|
1034
|
+
usage: details.aggregateUsage,
|
|
1035
|
+
};
|
|
1036
|
+
},
|
|
1037
|
+
|
|
1038
|
+
renderCall(args, theme) {
|
|
1039
|
+
const scope = args.agentScope ?? "user";
|
|
1040
|
+
if (args.tasks?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `parallel ${args.tasks.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1041
|
+
if (args.chain?.length) return new Text(`${theme.fg("toolTitle", theme.bold("subagents "))}${theme.fg("accent", `chain ${args.chain.length}`)}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1042
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
1043
|
+
},
|
|
1044
|
+
|
|
1045
|
+
renderResult(result, { expanded }, theme) {
|
|
1046
|
+
const details = result.details as SubagentDetails | undefined;
|
|
1047
|
+
if (!details?.results.length) {
|
|
1048
|
+
const first = result.content[0];
|
|
1049
|
+
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
1050
|
+
}
|
|
1051
|
+
if (!expanded) {
|
|
1052
|
+
const lines = details.results.map((task) => {
|
|
1053
|
+
const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
|
|
1054
|
+
return `${status} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${formatUsage(task.usage)}`)}`;
|
|
1055
|
+
});
|
|
1056
|
+
lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
|
|
1057
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const container = new Container();
|
|
1061
|
+
container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
|
|
1062
|
+
for (const task of details.results) {
|
|
1063
|
+
container.addChild(new Spacer(1));
|
|
1064
|
+
const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
|
|
1065
|
+
container.addChild(new Text(`${status} ${theme.fg("accent", task.agent)}${theme.fg("dim", ` · ${task.id} · ${task.agentSource}`)}`, 0, 0));
|
|
1066
|
+
container.addChild(new Text(theme.fg("dim", `${task.model ?? "no model"} · ${task.thinking ?? "off"} · ${task.tools.join(", ")} · ${formatUsage(task.usage)} · ${task.durationMs}ms`), 0, 0));
|
|
1067
|
+
container.addChild(new Text(theme.fg("muted", `Task: ${task.task}`), 0, 0));
|
|
1068
|
+
for (const entry of task.trace) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
|
|
1069
|
+
if (task.traceTruncatedBytes || task.stderrTruncatedBytes || task.outputTruncatedBytes) {
|
|
1070
|
+
container.addChild(new Text(theme.fg("warning", `Truncated · trace ${task.traceTruncatedBytes} B · stderr ${task.stderrTruncatedBytes} B · output ${task.outputTruncatedBytes} B`), 0, 0));
|
|
1071
|
+
}
|
|
1072
|
+
if (task.output) container.addChild(new Markdown(task.output, 0, 0, getMarkdownTheme()));
|
|
1073
|
+
else if (task.errorMessage || task.stderr) container.addChild(new Text(theme.fg("error", task.errorMessage || task.stderr), 0, 0));
|
|
1074
|
+
}
|
|
1075
|
+
container.addChild(new Spacer(1));
|
|
1076
|
+
container.addChild(new Text(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)}`), 0, 0));
|
|
1077
|
+
return container;
|
|
1078
|
+
},
|
|
1079
|
+
});
|
|
1080
|
+
}
|