killeros 1.4.0 → 1.4.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/CHANGELOG.md +32 -0
- package/README.md +52 -9
- package/agents/debugger.md +51 -0
- package/agents/documenter.md +50 -0
- package/agents/planner.md +48 -4
- package/agents/reviewer.md +53 -4
- package/agents/scout.md +51 -4
- package/agents/security.md +55 -0
- package/agents/tester.md +51 -0
- package/agents/worker.md +49 -4
- package/package.json +5 -1
- package/subagent-lifecycle.ts +506 -0
- package/subagent-process.ts +474 -0
- package/subagent-ui.ts +210 -0
- package/subagents.ts +683 -286
package/subagents.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
2
|
import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
4
3
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
4
|
import os from "node:os";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import {
|
|
9
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import { getSupportedThinkingLevels, StringEnum, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
10
8
|
import {
|
|
11
9
|
CONFIG_DIR_NAME,
|
|
12
10
|
getAgentDir,
|
|
@@ -17,30 +15,37 @@ import {
|
|
|
17
15
|
} from "@earendil-works/pi-coding-agent";
|
|
18
16
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
19
17
|
import { Type } from "typebox";
|
|
18
|
+
import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
|
|
19
|
+
import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
|
|
20
|
+
import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
|
|
20
21
|
|
|
21
22
|
export const SUBAGENT_LIMITS = {
|
|
22
23
|
maxTasks: 8,
|
|
23
24
|
maxReadConcurrency: 4,
|
|
24
|
-
defaultTurns: 8,
|
|
25
|
-
maxTurns: 12,
|
|
26
25
|
defaultTimeoutMs: 300_000,
|
|
27
26
|
maxTimeoutMs: 600_000,
|
|
27
|
+
jsonlLineBytes: 32 * 1024 * 1024,
|
|
28
28
|
traceBytes: 2 * 1024 * 1024,
|
|
29
29
|
stderrBytes: 64 * 1024,
|
|
30
30
|
taskOutputBytes: 50 * 1024,
|
|
31
31
|
toolOutputBytes: 50 * 1024,
|
|
32
|
+
quotaTokens: 1_000_000,
|
|
33
|
+
quotaUsd: 10,
|
|
32
34
|
roleFileBytes: 64 * 1024,
|
|
33
35
|
taskCharacters: 20_000,
|
|
34
36
|
killGraceMs: 5_000,
|
|
35
37
|
} as const;
|
|
36
38
|
|
|
37
|
-
const
|
|
39
|
+
const WEB_TOOLS = new Set(["web_search", "source_check", "fetch_content", "get_search_content"]);
|
|
40
|
+
const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
|
|
38
41
|
const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
39
42
|
const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
40
|
-
const
|
|
41
|
-
const
|
|
43
|
+
const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
|
|
44
|
+
const INHERIT_SETTING = "inherit";
|
|
45
|
+
const MAX_RUNTIME_STEERING_MESSAGES = 20;
|
|
46
|
+
const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "timeoutMs"]);
|
|
42
47
|
|
|
43
|
-
type ThinkingLevel =
|
|
48
|
+
type ThinkingLevel = ModelThinkingLevel;
|
|
44
49
|
export type AgentAccess = "read" | "write";
|
|
45
50
|
export type AgentSource = "bundled" | "personal" | "project";
|
|
46
51
|
export type AgentScope = "user" | "project" | "both";
|
|
@@ -52,7 +57,7 @@ export interface AgentRole {
|
|
|
52
57
|
access: AgentAccess;
|
|
53
58
|
tools: string[];
|
|
54
59
|
model?: string;
|
|
55
|
-
|
|
60
|
+
thinking?: string;
|
|
56
61
|
timeoutMs: number;
|
|
57
62
|
prompt: string;
|
|
58
63
|
source: AgentSource;
|
|
@@ -95,8 +100,10 @@ export interface SubagentTaskResult {
|
|
|
95
100
|
traceBytes: number;
|
|
96
101
|
traceTruncatedBytes: number;
|
|
97
102
|
stderr: string;
|
|
103
|
+
stderrBytes: number;
|
|
98
104
|
stderrTruncatedBytes: number;
|
|
99
105
|
output: string;
|
|
106
|
+
outputBytes: number;
|
|
100
107
|
outputTruncatedBytes: number;
|
|
101
108
|
usage: SubagentUsage;
|
|
102
109
|
durationMs: number;
|
|
@@ -112,27 +119,25 @@ export interface SubagentDetails {
|
|
|
112
119
|
projectAgentsDir: string | null;
|
|
113
120
|
results: SubagentTaskResult[];
|
|
114
121
|
aggregateUsage: SubagentUsage;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
reasoning?: boolean;
|
|
121
|
-
thinkingLevelMap?: Partial<Record<ThinkingLevel, unknown>>;
|
|
122
|
+
parentId?: string;
|
|
123
|
+
threads?: SubagentThread[];
|
|
124
|
+
activeThreads?: SubagentThread[];
|
|
125
|
+
doneThreads?: SubagentThread[];
|
|
126
|
+
selectedThreadId?: string;
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
interface ModelContext {
|
|
125
|
-
model?:
|
|
130
|
+
model?: Model<any>;
|
|
126
131
|
thinkingLevel?: ThinkingLevel;
|
|
127
132
|
modelRegistry: {
|
|
128
|
-
getAvailable():
|
|
133
|
+
getAvailable(): Model<any>[];
|
|
129
134
|
};
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
interface ResolvedModel {
|
|
133
138
|
model: string;
|
|
134
139
|
thinking: ThinkingLevel;
|
|
135
|
-
definition:
|
|
140
|
+
definition: Model<any>;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
interface SpawnedProcess {
|
|
@@ -149,8 +154,8 @@ type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number };
|
|
|
149
154
|
export interface SubagentRuntimeOptions {
|
|
150
155
|
bundledAgentsDir?: string;
|
|
151
156
|
userAgentsDir?: string;
|
|
157
|
+
webExtension?: string;
|
|
152
158
|
spawnProcess?: (args: string[], cwd: string) => SpawnedProcess;
|
|
153
|
-
createTaskId?: (index: number) => string;
|
|
154
159
|
limits?: Partial<SubagentLimits>;
|
|
155
160
|
}
|
|
156
161
|
|
|
@@ -194,6 +199,14 @@ function aggregateUsage(results: SubagentTaskResult[]): SubagentUsage {
|
|
|
194
199
|
return total;
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
function boundedText(text: string, maxBytes: number, marker: string): string {
|
|
203
|
+
const capped = truncateUtf8(text, maxBytes);
|
|
204
|
+
if (!capped.omittedBytes) return text;
|
|
205
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
206
|
+
if (markerBytes >= maxBytes) return truncateUtf8(text, maxBytes).text;
|
|
207
|
+
return `${truncateUtf8(text, maxBytes - markerBytes).text}${marker}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
197
210
|
function readBoundedFile(filePath: string, maxBytes: number): string {
|
|
198
211
|
let descriptor: number | undefined;
|
|
199
212
|
try {
|
|
@@ -271,6 +284,10 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
|
|
|
271
284
|
if (modelValue !== undefined && (typeof modelValue !== "string" || !modelValue.trim())) {
|
|
272
285
|
throw new AgentConfigurationError(filePath, "model", "must be a non-empty string when provided");
|
|
273
286
|
}
|
|
287
|
+
const thinkingValue = frontmatter.thinking;
|
|
288
|
+
if (thinkingValue !== undefined && (typeof thinkingValue !== "string" || !thinkingValue.trim())) {
|
|
289
|
+
throw new AgentConfigurationError(filePath, "thinking", "must be a non-empty string when provided");
|
|
290
|
+
}
|
|
274
291
|
|
|
275
292
|
return {
|
|
276
293
|
name,
|
|
@@ -278,7 +295,7 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
|
|
|
278
295
|
access: accessValue,
|
|
279
296
|
tools,
|
|
280
297
|
model: typeof modelValue === "string" ? modelValue.trim() : undefined,
|
|
281
|
-
|
|
298
|
+
thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
|
|
282
299
|
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs", limits.defaultTimeoutMs, limits.maxTimeoutMs),
|
|
283
300
|
prompt,
|
|
284
301
|
source,
|
|
@@ -352,54 +369,67 @@ export function discoverAgentRoles(
|
|
|
352
369
|
};
|
|
353
370
|
}
|
|
354
371
|
|
|
355
|
-
function
|
|
372
|
+
function matchingModels(value: string, available: Model<any>[]): Model<any>[] {
|
|
373
|
+
const slash = value.indexOf("/");
|
|
374
|
+
if (slash >= 1 && slash < value.length - 1) {
|
|
375
|
+
const provider = value.slice(0, slash);
|
|
376
|
+
const id = value.slice(slash + 1);
|
|
377
|
+
return available.filter((model) => model.provider === provider && model.id === id);
|
|
378
|
+
}
|
|
379
|
+
return available.filter((model) => model.id === value);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function splitModelAndThinking(value: string, filePath: string, available: Model<any>[]): { model: string; thinking?: string } {
|
|
383
|
+
if (matchingModels(value, available).length > 0) return { model: value };
|
|
356
384
|
const colon = value.lastIndexOf(":");
|
|
357
385
|
if (colon < 0) return { model: value };
|
|
358
|
-
const suffix = value.slice(colon + 1);
|
|
359
|
-
if (!(THINKING_LEVELS as readonly string[]).includes(suffix)) {
|
|
360
|
-
throw new AgentConfigurationError(filePath, "model", `unknown thinking level ${JSON.stringify(suffix)}`);
|
|
361
|
-
}
|
|
362
386
|
const model = value.slice(0, colon);
|
|
363
387
|
if (!model) throw new AgentConfigurationError(filePath, "model", "model identifier is missing");
|
|
364
|
-
return { model, thinking:
|
|
388
|
+
if (matchingModels(model, available).length > 0) return { model, thinking: value.slice(colon + 1) };
|
|
389
|
+
return { model: value };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function resolveAvailableModel(value: string, filePath: string, available: Model<any>[]): Model<any> {
|
|
393
|
+
const matches = matchingModels(value, available);
|
|
394
|
+
if (matches.length === 0) throw new AgentConfigurationError(filePath, "model", `unavailable model ${JSON.stringify(value)}`);
|
|
395
|
+
if (matches.length > 1) throw new AgentConfigurationError(filePath, "model", `ambiguous model ${JSON.stringify(value)}; use provider/model`);
|
|
396
|
+
return matches[0]!;
|
|
365
397
|
}
|
|
366
398
|
|
|
367
|
-
function
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return true;
|
|
399
|
+
function configuredSetting(override: string | undefined, roleSetting: string | undefined): string | undefined {
|
|
400
|
+
const overrideValue = override?.trim();
|
|
401
|
+
const roleValue = roleSetting?.trim();
|
|
402
|
+
const selected = overrideValue && overrideValue !== INHERIT_SETTING ? overrideValue : roleValue;
|
|
403
|
+
return selected && selected !== INHERIT_SETTING ? selected : undefined;
|
|
373
404
|
}
|
|
374
405
|
|
|
375
|
-
export function resolveAgentModel(
|
|
406
|
+
export function resolveAgentModel(
|
|
407
|
+
agent: AgentRole,
|
|
408
|
+
ctx: ModelContext,
|
|
409
|
+
modelOverride?: string,
|
|
410
|
+
thinkingOverride?: string,
|
|
411
|
+
): ResolvedModel {
|
|
376
412
|
const inheritedThinking = ctx.thinkingLevel ?? "off";
|
|
377
|
-
|
|
378
|
-
|
|
413
|
+
const configuredModel = configuredSetting(modelOverride, agent.model);
|
|
414
|
+
const configuredThinking = configuredSetting(thinkingOverride, agent.thinking);
|
|
415
|
+
let definition: Model<any> | undefined;
|
|
416
|
+
let thinking: string = configuredThinking ?? inheritedThinking;
|
|
379
417
|
|
|
380
|
-
if (
|
|
381
|
-
const requested = splitModelAndThinking(agent.model, agent.filePath);
|
|
382
|
-
thinking = requested.thinking ?? inheritedThinking;
|
|
418
|
+
if (configuredModel) {
|
|
383
419
|
const available = ctx.modelRegistry.getAvailable();
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (!definition) throw new AgentConfigurationError(agent.filePath, "model", `unavailable model ${JSON.stringify(requested.model)}`);
|
|
388
|
-
} else {
|
|
389
|
-
const matches = available.filter((model) => model.id === requested.model);
|
|
390
|
-
if (matches.length === 0) throw new AgentConfigurationError(agent.filePath, "model", `unavailable model ${JSON.stringify(requested.model)}`);
|
|
391
|
-
if (matches.length > 1) throw new AgentConfigurationError(agent.filePath, "model", `ambiguous model ${JSON.stringify(requested.model)}; use provider/model`);
|
|
392
|
-
definition = matches[0];
|
|
393
|
-
}
|
|
420
|
+
const requested = splitModelAndThinking(configuredModel, agent.filePath, available);
|
|
421
|
+
thinking = configuredThinking ?? requested.thinking ?? inheritedThinking;
|
|
422
|
+
definition = resolveAvailableModel(requested.model, agent.filePath, available);
|
|
394
423
|
} else {
|
|
395
424
|
definition = ctx.model;
|
|
396
425
|
if (!definition) throw new AgentConfigurationError(agent.filePath, "model", "no active parent model is available to inherit");
|
|
397
426
|
}
|
|
398
427
|
|
|
399
|
-
|
|
400
|
-
|
|
428
|
+
const supportedThinking = getSupportedThinkingLevels(definition) as readonly string[];
|
|
429
|
+
if (!supportedThinking.includes(thinking)) {
|
|
430
|
+
throw new AgentConfigurationError(agent.filePath, "thinking", `${definition.provider}/${definition.id} does not support thinking level ${thinking}`);
|
|
401
431
|
}
|
|
402
|
-
return { model: `${definition.provider}/${definition.id}`, thinking, definition };
|
|
432
|
+
return { model: `${definition.provider}/${definition.id}`, thinking: thinking as ThinkingLevel, definition };
|
|
403
433
|
}
|
|
404
434
|
|
|
405
435
|
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
@@ -437,31 +467,6 @@ function defaultSpawnProcess(args: string[], cwd: string): SpawnedProcess {
|
|
|
437
467
|
}) as unknown as SpawnedProcess;
|
|
438
468
|
}
|
|
439
469
|
|
|
440
|
-
function terminateProcess(child: SpawnedProcess, force: boolean): void {
|
|
441
|
-
if (process.platform === "win32" && force && child.pid) {
|
|
442
|
-
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
443
|
-
shell: false,
|
|
444
|
-
stdio: "ignore",
|
|
445
|
-
windowsHide: true,
|
|
446
|
-
});
|
|
447
|
-
killer.unref();
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
if (process.platform !== "win32" && child.pid) {
|
|
451
|
-
try {
|
|
452
|
-
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
453
|
-
return;
|
|
454
|
-
} catch {
|
|
455
|
-
// Fall back to the direct child when process-group signaling is unavailable.
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
try {
|
|
459
|
-
child.kill(force ? "SIGKILL" : "SIGTERM");
|
|
460
|
-
} catch {
|
|
461
|
-
// The process may already have exited.
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
470
|
function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBytes: number } {
|
|
466
471
|
const bytes = Buffer.from(text, "utf8");
|
|
467
472
|
if (bytes.length <= maxBytes) return { text, omittedBytes: 0 };
|
|
@@ -470,22 +475,6 @@ function truncateUtf8(text: string, maxBytes: number): { text: string; omittedBy
|
|
|
470
475
|
return { text: truncated, omittedBytes: bytes.length - Buffer.byteLength(truncated, "utf8") };
|
|
471
476
|
}
|
|
472
477
|
|
|
473
|
-
function textContent(message: any): string {
|
|
474
|
-
if (!Array.isArray(message?.content)) return "";
|
|
475
|
-
return message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function traceMessage(message: any): string[] {
|
|
479
|
-
if (!Array.isArray(message?.content)) return [];
|
|
480
|
-
const entries: string[] = [];
|
|
481
|
-
for (const part of message.content) {
|
|
482
|
-
if (part?.type !== "toolCall" || typeof part.name !== "string") continue;
|
|
483
|
-
const args = truncateUtf8(JSON.stringify(part.arguments ?? {}), 2_000).text;
|
|
484
|
-
entries.push(`${part.name} ${args}`);
|
|
485
|
-
}
|
|
486
|
-
return entries;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
478
|
function makeQueuedResult(id: string, agent: string, task: string, step?: number): SubagentTaskResult {
|
|
490
479
|
return {
|
|
491
480
|
id,
|
|
@@ -498,8 +487,10 @@ function makeQueuedResult(id: string, agent: string, task: string, step?: number
|
|
|
498
487
|
traceBytes: 0,
|
|
499
488
|
traceTruncatedBytes: 0,
|
|
500
489
|
stderr: "",
|
|
490
|
+
stderrBytes: 0,
|
|
501
491
|
stderrTruncatedBytes: 0,
|
|
502
492
|
output: "",
|
|
493
|
+
outputBytes: 0,
|
|
503
494
|
outputTruncatedBytes: 0,
|
|
504
495
|
usage: emptyUsage(),
|
|
505
496
|
durationMs: 0,
|
|
@@ -517,6 +508,35 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
|
|
|
517
508
|
};
|
|
518
509
|
}
|
|
519
510
|
|
|
511
|
+
function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes: number): SubagentTaskResult {
|
|
512
|
+
if (!previous) return cloneResult(next);
|
|
513
|
+
const merged = cloneResult(next);
|
|
514
|
+
const trace: string[] = [];
|
|
515
|
+
let traceBytes = 0;
|
|
516
|
+
let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
|
|
517
|
+
for (const entry of [...previous.trace, ...next.trace]) {
|
|
518
|
+
const retained = truncateUtf8(entry, Math.max(0, maxTraceBytes - traceBytes));
|
|
519
|
+
if (retained.text) trace.push(retained.text);
|
|
520
|
+
const retainedBytes = Buffer.byteLength(retained.text, "utf8");
|
|
521
|
+
traceBytes += retainedBytes;
|
|
522
|
+
traceTruncatedBytes += retained.omittedBytes;
|
|
523
|
+
}
|
|
524
|
+
merged.trace = trace;
|
|
525
|
+
merged.traceBytes = traceBytes;
|
|
526
|
+
merged.traceTruncatedBytes = traceTruncatedBytes;
|
|
527
|
+
merged.stderr = [previous.stderr, next.stderr].filter(Boolean).join("\n");
|
|
528
|
+
merged.stderrBytes = previous.stderrBytes + next.stderrBytes;
|
|
529
|
+
merged.stderrTruncatedBytes = previous.stderrTruncatedBytes + next.stderrTruncatedBytes;
|
|
530
|
+
merged.output = next.output || previous.output;
|
|
531
|
+
merged.outputBytes = previous.outputBytes + next.outputBytes;
|
|
532
|
+
merged.outputTruncatedBytes = previous.outputTruncatedBytes + next.outputTruncatedBytes;
|
|
533
|
+
merged.usage = emptyUsage();
|
|
534
|
+
addUsage(merged.usage, previous.usage);
|
|
535
|
+
addUsage(merged.usage, next.usage);
|
|
536
|
+
merged.durationMs = previous.durationMs + next.durationMs;
|
|
537
|
+
return merged;
|
|
538
|
+
}
|
|
539
|
+
|
|
520
540
|
function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectAgentsDir: string | null, results: SubagentTaskResult[]): SubagentDetails {
|
|
521
541
|
const cloned = results.map(cloneResult);
|
|
522
542
|
return { mode, agentScope: scope, projectAgentsDir, results: cloned, aggregateUsage: aggregateUsage(cloned) };
|
|
@@ -538,8 +558,37 @@ interface RunTaskOptions {
|
|
|
538
558
|
model: ResolvedModel;
|
|
539
559
|
signal?: AbortSignal;
|
|
540
560
|
spawnProcess: (args: string[], cwd: string) => SpawnedProcess;
|
|
561
|
+
webExtension?: string;
|
|
562
|
+
projectTrusted: boolean;
|
|
541
563
|
limits: SubagentLimits;
|
|
564
|
+
timeoutMs?: number;
|
|
542
565
|
onChange: (result: SubagentTaskResult) => void;
|
|
566
|
+
onHandle?: (handle: SubagentProcessHandle) => void;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function applyProcessResult(
|
|
570
|
+
target: SubagentTaskResult,
|
|
571
|
+
source: Readonly<SubagentProcessResult>,
|
|
572
|
+
startedAt: number,
|
|
573
|
+
onChange: (result: SubagentTaskResult) => void,
|
|
574
|
+
): void {
|
|
575
|
+
target.status = source.status;
|
|
576
|
+
target.trace = [...source.trace];
|
|
577
|
+
target.traceBytes = source.traceBytes;
|
|
578
|
+
target.traceTruncatedBytes = source.traceTruncatedBytes;
|
|
579
|
+
target.stderr = source.stderr;
|
|
580
|
+
target.stderrBytes = source.stderrBytes;
|
|
581
|
+
target.stderrTruncatedBytes = source.stderrTruncatedBytes;
|
|
582
|
+
target.output = source.output;
|
|
583
|
+
target.outputBytes = source.outputBytes;
|
|
584
|
+
target.outputTruncatedBytes = source.outputTruncatedBytes;
|
|
585
|
+
target.usage = { ...source.usage, cost: { ...source.usage.cost } };
|
|
586
|
+
target.model = source.model ?? target.model;
|
|
587
|
+
target.terminationReason = source.terminationReason;
|
|
588
|
+
target.errorMessage = source.errorMessage;
|
|
589
|
+
target.exitCode = source.exitCode;
|
|
590
|
+
target.durationMs = source.durationMs || Date.now() - startedAt;
|
|
591
|
+
onChange(target);
|
|
543
592
|
}
|
|
544
593
|
|
|
545
594
|
async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
@@ -564,7 +613,6 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
564
613
|
}
|
|
565
614
|
|
|
566
615
|
let promptDirectory: string | undefined;
|
|
567
|
-
let child: SpawnedProcess | undefined;
|
|
568
616
|
try {
|
|
569
617
|
const prompt = await writeRolePrompt(agent);
|
|
570
618
|
promptDirectory = prompt.directory;
|
|
@@ -580,168 +628,35 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
580
628
|
"-p",
|
|
581
629
|
"--no-session",
|
|
582
630
|
"--no-extensions",
|
|
583
|
-
"--
|
|
631
|
+
"--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
|
|
584
632
|
"--no-prompt-templates",
|
|
633
|
+
options.projectTrusted ? "--approve" : "--no-approve",
|
|
585
634
|
"--model", options.model.model,
|
|
586
635
|
"--thinking", options.model.thinking,
|
|
587
636
|
"--tools", agent.tools.join(","),
|
|
588
637
|
"--append-system-prompt", prompt.filePath,
|
|
589
638
|
`Task: ${options.task}`,
|
|
590
639
|
];
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
requestedReason = reason;
|
|
608
|
-
if (errorMessage) result.errorMessage = errorMessage;
|
|
609
|
-
terminateProcess(child!, false);
|
|
610
|
-
forceTimer = setTimeout(() => {
|
|
611
|
-
if (closed) return;
|
|
612
|
-
terminateProcess(child!, true);
|
|
613
|
-
settleTimer = setTimeout(() => {
|
|
614
|
-
if (!closed) finish(null);
|
|
615
|
-
}, 1_000);
|
|
616
|
-
settleTimer.unref?.();
|
|
617
|
-
}, limits.killGraceMs);
|
|
618
|
-
forceTimer.unref?.();
|
|
619
|
-
};
|
|
620
|
-
|
|
621
|
-
const processLine = (line: string): void => {
|
|
622
|
-
if (!line.trim() || requestedStatus) return;
|
|
623
|
-
let event: any;
|
|
624
|
-
try {
|
|
625
|
-
event = JSON.parse(line);
|
|
626
|
-
} catch (error) {
|
|
627
|
-
malformedError = error instanceof Error ? error.message : String(error);
|
|
628
|
-
requestTermination("failed", "malformed_jsonl", `Malformed child JSONL: ${malformedError}`);
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
if (event?.type === "message_end" && event.message?.role === "assistant") {
|
|
632
|
-
const message = event.message;
|
|
633
|
-
result.usage.turns += 1;
|
|
634
|
-
addUsage(result.usage, { ...message.usage, turns: 0 });
|
|
635
|
-
result.trace.push(...traceMessage(message));
|
|
636
|
-
const output = textContent(message);
|
|
637
|
-
if (output) {
|
|
638
|
-
const capped = truncateUtf8(output, limits.taskOutputBytes);
|
|
639
|
-
result.output = capped.text;
|
|
640
|
-
result.outputTruncatedBytes = capped.omittedBytes;
|
|
641
|
-
}
|
|
642
|
-
if (typeof message.model === "string") result.model = message.provider ? `${message.provider}/${message.model}` : message.model;
|
|
643
|
-
const stopReason = typeof message.stopReason === "string" ? message.stopReason : undefined;
|
|
644
|
-
if (stopReason === "stop" || stopReason === "toolUse") {
|
|
645
|
-
result.terminationReason = undefined;
|
|
646
|
-
result.errorMessage = undefined;
|
|
647
|
-
} else if (stopReason) {
|
|
648
|
-
result.terminationReason = stopReason;
|
|
649
|
-
}
|
|
650
|
-
if (typeof message.errorMessage === "string") result.errorMessage = message.errorMessage;
|
|
651
|
-
if (stopReason === "length") requestTermination("limited", "model_output_limit");
|
|
652
|
-
if (result.usage.turns > agent.maxTurns || result.usage.turns >= agent.maxTurns && stopReason === "toolUse") {
|
|
653
|
-
requestTermination("limited", "turn_limit");
|
|
654
|
-
}
|
|
655
|
-
options.onChange(result);
|
|
656
|
-
} else if (event?.type === "agent_end" && event.willRetry === true) {
|
|
657
|
-
if (result.usage.turns >= agent.maxTurns) {
|
|
658
|
-
requestTermination("limited", "turn_limit");
|
|
659
|
-
options.onChange(result);
|
|
660
|
-
}
|
|
661
|
-
} else if (event?.type === "tool_result_end" && event.message) {
|
|
662
|
-
const toolName = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
|
|
663
|
-
result.trace.push(`${toolName} result${event.message.isError ? " (error)" : ""}`);
|
|
664
|
-
options.onChange(result);
|
|
665
|
-
}
|
|
666
|
-
};
|
|
667
|
-
|
|
668
|
-
let finish!: (code: number | null) => void;
|
|
669
|
-
const closedPromise = new Promise<void>((resolve) => {
|
|
670
|
-
finish = (code: number | null): void => {
|
|
671
|
-
if (closed) return;
|
|
672
|
-
closed = true;
|
|
673
|
-
if (forceTimer) clearTimeout(forceTimer);
|
|
674
|
-
if (settleTimer) clearTimeout(settleTimer);
|
|
675
|
-
const tail = decoder.end();
|
|
676
|
-
if (tail) lineBuffer += tail;
|
|
677
|
-
if (lineBuffer.trim() && !requestedStatus) processLine(lineBuffer);
|
|
678
|
-
result.exitCode = code;
|
|
679
|
-
if (requestedStatus) {
|
|
680
|
-
result.status = requestedStatus;
|
|
681
|
-
result.terminationReason = requestedReason;
|
|
682
|
-
} else if (result.terminationReason === "length") {
|
|
683
|
-
result.status = "limited";
|
|
684
|
-
result.terminationReason = "model_output_limit";
|
|
685
|
-
} else if (code !== 0 || result.errorMessage || result.terminationReason) {
|
|
686
|
-
result.status = "failed";
|
|
687
|
-
result.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
|
|
688
|
-
} else if (result.usage.turns === 0) {
|
|
689
|
-
result.status = "failed";
|
|
690
|
-
result.terminationReason = "missing_assistant_message";
|
|
691
|
-
result.errorMessage = "Child exited without an assistant response";
|
|
692
|
-
} else {
|
|
693
|
-
result.status = "complete";
|
|
694
|
-
result.terminationReason = "completed";
|
|
695
|
-
}
|
|
696
|
-
result.durationMs = Date.now() - startedAt;
|
|
697
|
-
options.onChange(result);
|
|
698
|
-
resolve();
|
|
699
|
-
};
|
|
640
|
+
const handle = runSubagentProcess({
|
|
641
|
+
args,
|
|
642
|
+
cwd: options.cwd,
|
|
643
|
+
signal: options.signal,
|
|
644
|
+
spawnProcess: options.spawnProcess,
|
|
645
|
+
limits: {
|
|
646
|
+
wallTimeMs: options.timeoutMs ?? agent.timeoutMs,
|
|
647
|
+
jsonlLineBytes: limits.jsonlLineBytes,
|
|
648
|
+
traceBytes: limits.traceBytes,
|
|
649
|
+
stderrBytes: limits.stderrBytes,
|
|
650
|
+
outputBytes: limits.taskOutputBytes,
|
|
651
|
+
quotaTokens: limits.quotaTokens,
|
|
652
|
+
quotaUsd: limits.quotaUsd,
|
|
653
|
+
killGraceMs: limits.killGraceMs,
|
|
654
|
+
},
|
|
655
|
+
onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
|
|
700
656
|
});
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
705
|
-
const remaining = Math.max(0, limits.traceBytes - rawTraceBytes);
|
|
706
|
-
rawTraceBytes += buffer.length;
|
|
707
|
-
const accepted = buffer.subarray(0, remaining);
|
|
708
|
-
if (accepted.length) {
|
|
709
|
-
lineBuffer += decoder.write(accepted);
|
|
710
|
-
const lines = lineBuffer.split(/\r?\n/u);
|
|
711
|
-
lineBuffer = lines.pop() ?? "";
|
|
712
|
-
for (const line of lines) processLine(line);
|
|
713
|
-
}
|
|
714
|
-
result.traceBytes = Math.min(rawTraceBytes, limits.traceBytes);
|
|
715
|
-
result.traceTruncatedBytes = Math.max(0, rawTraceBytes - limits.traceBytes);
|
|
716
|
-
if (rawTraceBytes > limits.traceBytes) requestTermination("limited", "trace_limit");
|
|
717
|
-
});
|
|
718
|
-
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
719
|
-
if (requestedStatus) return;
|
|
720
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
721
|
-
const remaining = Math.max(0, limits.stderrBytes - rawStderrBytes);
|
|
722
|
-
rawStderrBytes += buffer.length;
|
|
723
|
-
if (remaining > 0) result.stderr += buffer.subarray(0, remaining).toString("utf8");
|
|
724
|
-
result.stderrTruncatedBytes = Math.max(0, rawStderrBytes - limits.stderrBytes);
|
|
725
|
-
if (rawStderrBytes > limits.stderrBytes) requestTermination("limited", "stderr_limit");
|
|
726
|
-
});
|
|
727
|
-
child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
|
|
728
|
-
child.once("close", finish);
|
|
729
|
-
|
|
730
|
-
const timeoutTimer = setTimeout(() => requestTermination("limited", "timeout"), agent.timeoutMs);
|
|
731
|
-
timeoutTimer.unref?.();
|
|
732
|
-
const abortHandler = (): void => requestTermination("cancelled", "abort");
|
|
733
|
-
if (options.signal?.aborted) abortHandler();
|
|
734
|
-
else options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
735
|
-
|
|
736
|
-
try {
|
|
737
|
-
await closedPromise;
|
|
738
|
-
} finally {
|
|
739
|
-
clearTimeout(timeoutTimer);
|
|
740
|
-
options.signal?.removeEventListener("abort", abortHandler);
|
|
741
|
-
}
|
|
742
|
-
if (!result.output && result.stderr && (result.status as SubagentStatus) !== "complete") {
|
|
743
|
-
result.errorMessage ??= result.stderr.trim();
|
|
744
|
-
}
|
|
657
|
+
options.onHandle?.(handle);
|
|
658
|
+
const final = await handle.result;
|
|
659
|
+
applyProcessResult(result, final, startedAt, options.onChange);
|
|
745
660
|
return result;
|
|
746
661
|
} catch (error) {
|
|
747
662
|
result.status = options.signal?.aborted ? "cancelled" : "failed";
|
|
@@ -785,10 +700,19 @@ const ChainTaskSchema = Type.Object({
|
|
|
785
700
|
});
|
|
786
701
|
|
|
787
702
|
const SubagentParams = Type.Object({
|
|
703
|
+
action: Type.Optional(StringEnum(["spawn", "list", "inspect", "steer", "interrupt", "collect", "close"] as const, {
|
|
704
|
+
default: "spawn",
|
|
705
|
+
description: "Thread lifecycle action",
|
|
706
|
+
})),
|
|
707
|
+
threadId: Type.Optional(Type.String({ minLength: 1, maxLength: 128, description: "Stable child thread ID" })),
|
|
708
|
+
message: Type.Optional(Type.String({ minLength: 1, maxLength: 4_000, description: "Bounded steering message" })),
|
|
709
|
+
all: Type.Optional(Type.Boolean({ description: "Interrupt every active child thread" })),
|
|
788
710
|
agent: Type.Optional(Type.String({ minLength: 1, maxLength: 64, description: "Agent role for single mode" })),
|
|
789
711
|
task: Type.Optional(Type.String({ minLength: 1, maxLength: SUBAGENT_LIMITS.taskCharacters, description: "Task for single mode" })),
|
|
790
712
|
tasks: Type.Optional(Type.Array(TaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Parallel role tasks" })),
|
|
791
713
|
chain: Type.Optional(Type.Array(ChainTaskSchema, { minItems: 1, maxItems: SUBAGENT_LIMITS.maxTasks, description: "Sequential role tasks; {previous} inserts the prior result" })),
|
|
714
|
+
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" })),
|
|
715
|
+
thinking: Type.Optional(Type.String({ minLength: 1, maxLength: 16, description: "Thinking effort for every task: off, minimal, low, medium, high, xhigh, max, or inherit" })),
|
|
792
716
|
agentScope: Type.Optional(StringEnum(["user", "project", "both"] as const, {
|
|
793
717
|
default: "user",
|
|
794
718
|
description: "Role sources: user includes bundled and personal; project includes bundled and trusted project; both includes all",
|
|
@@ -804,16 +728,30 @@ function requestedAgents(params: { agent?: string; tasks?: TaskInput[]; chain?:
|
|
|
804
728
|
return (params.tasks ?? params.chain ?? []).map((task) => task.agent);
|
|
805
729
|
}
|
|
806
730
|
|
|
731
|
+
function clipCharacters(text: string, maxCharacters: number, fromEnd = false): string {
|
|
732
|
+
const characters = [...text];
|
|
733
|
+
if (characters.length <= maxCharacters) return text;
|
|
734
|
+
return (fromEnd ? characters.slice(-maxCharacters) : characters.slice(0, maxCharacters)).join("");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function buildSteeredTask(task: string, steering: readonly string[], previousOutput: string | undefined, maxCharacters: number): string {
|
|
738
|
+
const steeringLabel = "\n\nParent steering:\n";
|
|
739
|
+
const steeringText = clipCharacters(steering.join("\n"), Math.max(0, maxCharacters - [...steeringLabel].length), true);
|
|
740
|
+
const previousLabel = previousOutput ? "\n\nPrevious child handoff:\n" : "";
|
|
741
|
+
const required = [...steeringLabel, ...steeringText, ...previousLabel].length;
|
|
742
|
+
const taskText = clipCharacters(task, Math.max(0, maxCharacters - required));
|
|
743
|
+
const previousText = previousOutput
|
|
744
|
+
? clipCharacters(previousOutput, Math.max(0, maxCharacters - [...taskText, ...steeringLabel, ...steeringText, ...previousLabel].length))
|
|
745
|
+
: "";
|
|
746
|
+
return `${taskText}${previousText ? `${previousLabel}${previousText}` : ""}${steeringLabel}${steeringText}`;
|
|
747
|
+
}
|
|
748
|
+
|
|
807
749
|
function formatUsage(usage: SubagentUsage): string {
|
|
808
750
|
const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`, `${usage.totalTokens} tokens`];
|
|
809
751
|
if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
|
|
810
752
|
return parts.join(" · ");
|
|
811
753
|
}
|
|
812
754
|
|
|
813
|
-
function taskSummary(result: SubagentTaskResult): string {
|
|
814
|
-
return `${result.id} · ${result.agent} · ${result.status} · ${formatUsage(result.usage)}`;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
755
|
function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskResult[], maxBytes: number): string {
|
|
818
756
|
const sections = results.map((result) => {
|
|
819
757
|
const heading = `### ${result.id} · ${result.agent} · ${result.status}`;
|
|
@@ -825,8 +763,7 @@ function buildToolContent(mode: SubagentDetails["mode"], results: SubagentTaskRe
|
|
|
825
763
|
const complete = results.filter((result) => result.status === "complete").length;
|
|
826
764
|
const text = `${mode}: ${complete}/${results.length} complete · ${formatUsage(aggregateUsage(results))}\n\n${sections.join("\n\n---\n\n")}`;
|
|
827
765
|
const marker = "\n\n[Combined subagent output truncated to 50 KiB; inspect the expanded tool result for bounded per-task details.]";
|
|
828
|
-
|
|
829
|
-
return capped.omittedBytes ? `${capped.text}${marker}` : text;
|
|
766
|
+
return boundedText(text, maxBytes, marker);
|
|
830
767
|
}
|
|
831
768
|
|
|
832
769
|
function statusColor(status: SubagentStatus): "accent" | "success" | "error" | "warning" | "muted" {
|
|
@@ -845,23 +782,321 @@ function statusIcon(status: SubagentStatus): string {
|
|
|
845
782
|
return "!";
|
|
846
783
|
}
|
|
847
784
|
|
|
785
|
+
interface ActiveThreadRuntime {
|
|
786
|
+
controller: AbortController;
|
|
787
|
+
handle?: SubagentProcessHandle;
|
|
788
|
+
steering: string[];
|
|
789
|
+
restarting: boolean;
|
|
790
|
+
traceCount: number;
|
|
791
|
+
startedAt: number;
|
|
792
|
+
aggregate?: SubagentTaskResult;
|
|
793
|
+
requestedReason?: string;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function parentThreadId(ctx: ExtensionContext): string {
|
|
797
|
+
try {
|
|
798
|
+
const id = ctx.sessionManager?.getSessionId?.();
|
|
799
|
+
if (id) return `main:${id}`;
|
|
800
|
+
} catch {
|
|
801
|
+
// Test and RPC contexts may not expose a session manager.
|
|
802
|
+
}
|
|
803
|
+
return "main";
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function threadCapabilityBoundary(agent: AgentRole): {
|
|
807
|
+
filesystem: "read" | "write";
|
|
808
|
+
network: "none" | "read";
|
|
809
|
+
process: "none" | "limited";
|
|
810
|
+
childThreads: false;
|
|
811
|
+
} {
|
|
812
|
+
return {
|
|
813
|
+
filesystem: agent.access,
|
|
814
|
+
network: agent.tools.some((tool) => WEB_TOOLS.has(tool)) ? "read" : "none",
|
|
815
|
+
process: agent.tools.includes("bash") ? "limited" : "none",
|
|
816
|
+
childThreads: false,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function threadUsage(usage: SubagentUsage): SubagentThread["usage"] {
|
|
821
|
+
return {
|
|
822
|
+
inputTokens: usage.input,
|
|
823
|
+
outputTokens: usage.output,
|
|
824
|
+
cacheReadTokens: usage.cacheRead,
|
|
825
|
+
cacheWriteTokens: usage.cacheWrite,
|
|
826
|
+
totalTokens: usage.totalTokens,
|
|
827
|
+
costUsd: usage.cost.total,
|
|
828
|
+
turns: usage.turns,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function legacyStatus(state: SubagentThreadState): SubagentStatus {
|
|
833
|
+
if (state === "active") return "running";
|
|
834
|
+
if (state === "done" || state === "closed") return "complete";
|
|
835
|
+
if (state === "failed") return "failed";
|
|
836
|
+
if (state === "stopped") return "cancelled";
|
|
837
|
+
return "queued";
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function threadResult(thread: SubagentThread, source?: SubagentTaskResult): SubagentTaskResult {
|
|
841
|
+
if (source) {
|
|
842
|
+
const result = cloneResult(source);
|
|
843
|
+
if (thread.state === "queued") result.status = "queued";
|
|
844
|
+
else if (thread.state === "active") result.status = "running";
|
|
845
|
+
return result;
|
|
846
|
+
}
|
|
847
|
+
return {
|
|
848
|
+
...makeQueuedResult(thread.id, thread.role, thread.prompt),
|
|
849
|
+
status: legacyStatus(thread.state),
|
|
850
|
+
agentSource: "unknown",
|
|
851
|
+
model: thread.model,
|
|
852
|
+
tools: [...thread.tools],
|
|
853
|
+
trace: thread.trace.map((event) => event.message ?? event.kind),
|
|
854
|
+
usage: {
|
|
855
|
+
input: thread.usage.inputTokens,
|
|
856
|
+
output: thread.usage.outputTokens,
|
|
857
|
+
cacheRead: thread.usage.cacheReadTokens,
|
|
858
|
+
cacheWrite: thread.usage.cacheWriteTokens,
|
|
859
|
+
totalTokens: thread.usage.totalTokens,
|
|
860
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: thread.usage.costUsd },
|
|
861
|
+
turns: thread.usage.turns,
|
|
862
|
+
},
|
|
863
|
+
output: thread.result ?? "",
|
|
864
|
+
terminationReason: thread.stopReason,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function threadBoardRecord(result: SubagentTaskResult): ThreadBoardRecord {
|
|
869
|
+
return {
|
|
870
|
+
id: result.id,
|
|
871
|
+
agent: result.agent,
|
|
872
|
+
task: result.task,
|
|
873
|
+
status: result.status,
|
|
874
|
+
usage: {
|
|
875
|
+
input: result.usage.input,
|
|
876
|
+
output: result.usage.output,
|
|
877
|
+
cacheRead: result.usage.cacheRead,
|
|
878
|
+
cacheWrite: result.usage.cacheWrite,
|
|
879
|
+
totalTokens: result.usage.totalTokens,
|
|
880
|
+
turns: result.usage.turns,
|
|
881
|
+
cost: result.usage.cost.total,
|
|
882
|
+
},
|
|
883
|
+
trace: result.trace,
|
|
884
|
+
traceTruncatedBytes: result.traceTruncatedBytes,
|
|
885
|
+
handoff: result.output,
|
|
886
|
+
output: result.output,
|
|
887
|
+
terminationReason: result.terminationReason,
|
|
888
|
+
errorMessage: result.errorMessage,
|
|
889
|
+
durationMs: result.durationMs,
|
|
890
|
+
step: result.step,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
848
894
|
export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeOptions = {}): void {
|
|
849
895
|
const limits = { ...SUBAGENT_LIMITS, ...options.limits };
|
|
850
896
|
const spawnProcess = options.spawnProcess ?? defaultSpawnProcess;
|
|
897
|
+
const threads = new SubagentThreadRegistry();
|
|
898
|
+
const activeRuntimes = new Map<string, ActiveThreadRuntime>();
|
|
899
|
+
const savedResults = new Map<string, SubagentTaskResult>();
|
|
900
|
+
|
|
901
|
+
const detailsFor = (
|
|
902
|
+
parentId: string,
|
|
903
|
+
mode: SubagentDetails["mode"] = "single",
|
|
904
|
+
scope: AgentScope = "user",
|
|
905
|
+
projectAgentsDir: string | null = null,
|
|
906
|
+
selectedThreadId?: string,
|
|
907
|
+
): SubagentDetails => {
|
|
908
|
+
const all = threads.listAll().filter((thread) => thread.parentId === parentId);
|
|
909
|
+
const visible = all.filter((thread) => thread.state !== "closed");
|
|
910
|
+
const results = visible.map((thread) => threadResult(thread, savedResults.get(thread.id)));
|
|
911
|
+
return {
|
|
912
|
+
...cloneDetails(mode, scope, projectAgentsDir, results),
|
|
913
|
+
parentId,
|
|
914
|
+
threads: all,
|
|
915
|
+
activeThreads: all.filter((thread) => thread.state === "active"),
|
|
916
|
+
doneThreads: all.filter((thread) => ["done", "failed", "stopped"].includes(thread.state)),
|
|
917
|
+
selectedThreadId,
|
|
918
|
+
};
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const threadBoardText = (parentId: string, selectedThreadId?: string): string => {
|
|
922
|
+
const details = detailsFor(parentId, "single", "user", null, selectedThreadId);
|
|
923
|
+
const active = details.activeThreads ?? [];
|
|
924
|
+
const done = details.doneThreads ?? [];
|
|
925
|
+
const row = (thread: SubagentThread): string => `- ${thread.id} · ${thread.role} · ${thread.state} · ${thread.prompt}`;
|
|
926
|
+
const lines = [
|
|
927
|
+
`parent ${parentId}`,
|
|
928
|
+
`Active (${active.length})`,
|
|
929
|
+
...(active.length ? active.map(row) : ["- none"]),
|
|
930
|
+
`Done (${done.length})`,
|
|
931
|
+
...(done.length ? done.map(row) : ["- none"]),
|
|
932
|
+
"Controls: inspect · steer · interrupt · collect · close",
|
|
933
|
+
];
|
|
934
|
+
if (selectedThreadId) {
|
|
935
|
+
const selected = details.threads?.find((thread) => thread.id === selectedThreadId);
|
|
936
|
+
if (selected) {
|
|
937
|
+
lines.push(`Inspect ${selected.id}: ${selected.state}`);
|
|
938
|
+
lines.push(`Role: ${selected.role}`);
|
|
939
|
+
lines.push(`Model: ${selected.model}`);
|
|
940
|
+
lines.push(`Tools: ${selected.tools.join(", ")}`);
|
|
941
|
+
lines.push(`Trace: ${selected.trace.length} entries`);
|
|
942
|
+
if (selected.result) lines.push(`Handoff: ${selected.result}`);
|
|
943
|
+
if (selected.stopReason) lines.push(`Reason: ${selected.stopReason}`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return boundedText(lines.join("\n"), limits.toolOutputBytes, "\n\n[Thread board truncated; inspect a child thread for its bounded detail.]");
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
const syncThread = (threadId: SubagentThreadId, next: SubagentTaskResult, runtime?: ActiveThreadRuntime): SubagentTaskResult => {
|
|
950
|
+
const effective = mergeTaskResults(runtime?.aggregate, next, limits.traceBytes);
|
|
951
|
+
if (runtime?.requestedReason && next.status === "cancelled") effective.terminationReason = runtime.requestedReason;
|
|
952
|
+
savedResults.set(threadId, cloneResult(effective));
|
|
953
|
+
let thread = threads.inspect(threadId);
|
|
954
|
+
if (!thread || threads.isDisposed) return effective;
|
|
955
|
+
if (thread.state === "queued" && next.status === "running") {
|
|
956
|
+
thread = threads.begin(threadId);
|
|
957
|
+
}
|
|
958
|
+
if (thread.state !== "active") return effective;
|
|
959
|
+
if (runtime && next.trace.length < runtime.traceCount) runtime.traceCount = 0;
|
|
960
|
+
const from = runtime?.traceCount ?? 0;
|
|
961
|
+
let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
|
|
962
|
+
for (const entry of next.trace.slice(from)) {
|
|
963
|
+
const retained = truncateUtf8(entry, Math.max(0, limits.traceBytes - retainedTraceBytes));
|
|
964
|
+
if (retained.text) {
|
|
965
|
+
threads.trace(threadId, { kind: "child", message: retained.text });
|
|
966
|
+
retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (runtime) runtime.traceCount = next.trace.length;
|
|
970
|
+
const handoff = effective.output ? { summary: effective.output } : undefined;
|
|
971
|
+
thread = threads.patch(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
|
|
972
|
+
const restartPending = runtime?.restarting === true && ["cancelled", "complete"].includes(next.status);
|
|
973
|
+
if (restartPending) return effective;
|
|
974
|
+
if (effective.status === "complete") {
|
|
975
|
+
threads.complete(threadId, { usage: threadUsage(effective.usage), result: effective.output || undefined, handoff });
|
|
976
|
+
} else if (effective.status === "failed") {
|
|
977
|
+
threads.fail(threadId, {
|
|
978
|
+
usage: threadUsage(effective.usage),
|
|
979
|
+
result: effective.output || undefined,
|
|
980
|
+
handoff,
|
|
981
|
+
message: effective.errorMessage ?? effective.terminationReason ?? "child failed",
|
|
982
|
+
code: effective.terminationReason,
|
|
983
|
+
});
|
|
984
|
+
} else if (effective.status === "cancelled" || effective.status === "limited") {
|
|
985
|
+
threads.stop(threadId, {
|
|
986
|
+
usage: threadUsage(effective.usage),
|
|
987
|
+
result: effective.output || undefined,
|
|
988
|
+
handoff,
|
|
989
|
+
reason: effective.terminationReason ?? (effective.status === "limited" ? "resource_limit" : "interrupted"),
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
return effective;
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
if (typeof pi.on === "function") {
|
|
996
|
+
pi.on("session_shutdown", () => {
|
|
997
|
+
for (const runtime of activeRuntimes.values()) {
|
|
998
|
+
runtime.restarting = false;
|
|
999
|
+
runtime.requestedReason = "session_shutdown";
|
|
1000
|
+
runtime.handle?.stop("session_shutdown");
|
|
1001
|
+
runtime.controller.abort();
|
|
1002
|
+
}
|
|
1003
|
+
threads.dispose();
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
851
1006
|
|
|
852
1007
|
pi.registerTool({
|
|
853
1008
|
name: "subagent",
|
|
854
1009
|
label: "Subagents",
|
|
855
|
-
description: "
|
|
1010
|
+
description: "Spawn and manage named child threads. Children finish naturally; time, output, trace, stderr, quota, task count, and concurrency are the hard edges. Use action list, inspect, steer, interrupt, collect, and close to manage active and completed handoffs.",
|
|
856
1011
|
promptSnippet: "Delegate bounded specialist work to isolated KillerOS subagents",
|
|
857
1012
|
promptGuidelines: [
|
|
858
|
-
"Use subagent for clearly separable specialist work; prefer read-only scout, planner, or
|
|
1013
|
+
"Use subagent for clearly separable specialist work; prefer read-only scout, planner, reviewer, or security roles before a writer.",
|
|
859
1014
|
"Do not request multiple write-capable subagents in one parallel batch.",
|
|
1015
|
+
"Every child can load relevant skills with read and can use web_search, source_check, fetch_content, and get_search_content for external research.",
|
|
1016
|
+
"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.",
|
|
1017
|
+
"Keep completed and stopped threads inspectable until the parent explicitly closes them.",
|
|
860
1018
|
],
|
|
861
1019
|
parameters: SubagentParams,
|
|
862
|
-
executionMode: "
|
|
1020
|
+
executionMode: "parallel",
|
|
863
1021
|
|
|
864
1022
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1023
|
+
const action = params.action ?? "spawn";
|
|
1024
|
+
const parentId = parentThreadId(ctx);
|
|
1025
|
+
const actionDetails = (selectedThreadId?: string): SubagentDetails => detailsFor(parentId, "single", params.agentScope ?? "user", null, selectedThreadId);
|
|
1026
|
+
const actionResult = (text: string, selectedThreadId?: string) => {
|
|
1027
|
+
const details = actionDetails(selectedThreadId);
|
|
1028
|
+
return {
|
|
1029
|
+
content: [{ type: "text" as const, text: boundedText(text, limits.toolOutputBytes, "\n\n[Thread action output truncated.]") }],
|
|
1030
|
+
details,
|
|
1031
|
+
usage: details.aggregateUsage,
|
|
1032
|
+
};
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
if (action === "list") return actionResult(threadBoardText(parentId));
|
|
1036
|
+
if (action === "inspect") {
|
|
1037
|
+
if (!params.threadId) throw new Error("inspect requires threadId");
|
|
1038
|
+
const thread = threads.inspect(params.threadId as SubagentThreadId);
|
|
1039
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1040
|
+
return actionResult(threadBoardText(parentId, params.threadId), params.threadId);
|
|
1041
|
+
}
|
|
1042
|
+
if (action === "steer") {
|
|
1043
|
+
if (!params.threadId || !params.message) throw new Error("steer requires threadId and message");
|
|
1044
|
+
const threadId = params.threadId as SubagentThreadId;
|
|
1045
|
+
const thread = threads.inspect(threadId);
|
|
1046
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1047
|
+
threads.steer(threadId, params.message);
|
|
1048
|
+
const runtime = activeRuntimes.get(params.threadId);
|
|
1049
|
+
if (runtime) {
|
|
1050
|
+
runtime.steering.push(params.message);
|
|
1051
|
+
if (runtime.steering.length > MAX_RUNTIME_STEERING_MESSAGES) runtime.steering.splice(0, runtime.steering.length - MAX_RUNTIME_STEERING_MESSAGES);
|
|
1052
|
+
runtime.restarting = true;
|
|
1053
|
+
runtime.requestedReason = "steer";
|
|
1054
|
+
runtime.handle?.stop("steer");
|
|
1055
|
+
}
|
|
1056
|
+
return actionResult(`Steering queued for ${params.threadId}. The child keeps the same thread and handoff record.`, params.threadId);
|
|
1057
|
+
}
|
|
1058
|
+
if (action === "interrupt") {
|
|
1059
|
+
if (!params.all && !params.threadId) throw new Error("interrupt requires threadId or all=true");
|
|
1060
|
+
let targets: SubagentThread[];
|
|
1061
|
+
if (params.all) {
|
|
1062
|
+
targets = threads.listActive().filter((thread) => thread.parentId === parentId);
|
|
1063
|
+
} else {
|
|
1064
|
+
const target = threads.inspect(params.threadId as SubagentThreadId);
|
|
1065
|
+
if (!target || target.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1066
|
+
if (target.state !== "active" && target.state !== "queued") {
|
|
1067
|
+
throw new Error(`Cannot interrupt thread ${params.threadId} from ${target.state}`);
|
|
1068
|
+
}
|
|
1069
|
+
targets = [target];
|
|
1070
|
+
}
|
|
1071
|
+
for (const thread of targets) {
|
|
1072
|
+
if (thread.parentId !== parentId) continue;
|
|
1073
|
+
const runtime = activeRuntimes.get(thread.id);
|
|
1074
|
+
if (runtime) {
|
|
1075
|
+
runtime.restarting = false;
|
|
1076
|
+
runtime.requestedReason = "interrupt";
|
|
1077
|
+
runtime.handle?.stop("interrupt");
|
|
1078
|
+
runtime.controller.abort();
|
|
1079
|
+
} else if (thread.state === "active" || thread.state === "queued") {
|
|
1080
|
+
threads.stop(thread.id, { reason: "interrupt" });
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return actionResult(params.all ? "Interrupt requested for all active child threads." : `Interrupt requested for ${params.threadId}.`);
|
|
1084
|
+
}
|
|
1085
|
+
if (action === "collect") {
|
|
1086
|
+
if (!params.threadId) throw new Error("collect requires threadId");
|
|
1087
|
+
const thread = threads.inspect(params.threadId as SubagentThreadId);
|
|
1088
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1089
|
+
const collected = threads.collect(params.threadId as SubagentThreadId);
|
|
1090
|
+
return actionResult(`Collected ${params.threadId}: ${collected.result ?? collected.failure?.message ?? collected.stopReason ?? "no handoff"}`, params.threadId);
|
|
1091
|
+
}
|
|
1092
|
+
if (action === "close") {
|
|
1093
|
+
if (!params.threadId) throw new Error("close requires threadId");
|
|
1094
|
+
const thread = threads.inspect(params.threadId as SubagentThreadId);
|
|
1095
|
+
if (!thread || thread.parentId !== parentId) throw new Error(`Unknown child thread ${JSON.stringify(params.threadId)}`);
|
|
1096
|
+
threads.close(params.threadId as SubagentThreadId);
|
|
1097
|
+
return actionResult(`Closed ${params.threadId}. Its result record remains inspectable.`, params.threadId);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
865
1100
|
const scope: AgentScope = params.agentScope ?? "user";
|
|
866
1101
|
const hasSingleFields = params.agent !== undefined || params.task !== undefined;
|
|
867
1102
|
const hasParallel = params.tasks !== undefined;
|
|
@@ -895,7 +1130,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
895
1130
|
}
|
|
896
1131
|
|
|
897
1132
|
const resolvedModels = new Map<string, ResolvedModel>();
|
|
898
|
-
for (const name of new Set(requested))
|
|
1133
|
+
for (const name of new Set(requested)) {
|
|
1134
|
+
resolvedModels.set(name, resolveAgentModel(roles.get(name)!, ctx, params.model, params.thinking));
|
|
1135
|
+
}
|
|
899
1136
|
|
|
900
1137
|
const mode: SubagentDetails["mode"] = hasParallel ? "parallel" : hasChain ? "chain" : "single";
|
|
901
1138
|
const inputs: TaskInput[] = hasSingle
|
|
@@ -907,22 +1144,56 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
907
1144
|
if (writers.length > 1) throw new Error("Parallel batches may contain at most one write-capable subagent; writers are serialized");
|
|
908
1145
|
}
|
|
909
1146
|
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
1147
|
+
const inFlight = threads.listAll().filter((thread) => ["queued", "active"].includes(thread.state)).length;
|
|
1148
|
+
if (inFlight + inputs.length > limits.maxTasks) {
|
|
1149
|
+
throw new Error(`At most ${limits.maxTasks} child threads may be active at once`);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const threadRecords = inputs.map((input, index) => threads.spawn({
|
|
1153
|
+
parentId: parentId as SubagentThreadId,
|
|
1154
|
+
role: input.agent,
|
|
1155
|
+
prompt: input.task,
|
|
1156
|
+
model: resolvedModels.get(input.agent)!.model,
|
|
1157
|
+
tools: roles.get(input.agent)!.tools,
|
|
1158
|
+
capabilityBoundary: threadCapabilityBoundary(roles.get(input.agent)!),
|
|
1159
|
+
}));
|
|
1160
|
+
const results = threadRecords.map((thread, index) => {
|
|
1161
|
+
return makeQueuedResult(thread.id, inputs[index]!.agent, inputs[index]!.task, hasChain ? index + 1 : undefined);
|
|
1162
|
+
});
|
|
913
1163
|
const emit = (message = `${mode}: ${results.filter((result) => !["queued", "running"].includes(result.status)).length}/${results.length} settled`): void => {
|
|
1164
|
+
const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
|
|
1165
|
+
const currentResults = results.map(cloneResult);
|
|
914
1166
|
(onUpdate as ToolUpdate | undefined)?.({
|
|
915
1167
|
content: [{ type: "text", text: message }],
|
|
916
|
-
details:
|
|
1168
|
+
details: { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) },
|
|
917
1169
|
});
|
|
918
1170
|
};
|
|
919
1171
|
const runAt = async (index: number, task: string): Promise<void> => {
|
|
1172
|
+
const threadId = threadRecords[index]!.id;
|
|
1173
|
+
const initialThread = threads.inspect(threadId);
|
|
920
1174
|
if (signal?.aborted) {
|
|
921
1175
|
results[index] = { ...results[index]!, status: "cancelled", terminationReason: "abort" };
|
|
1176
|
+
if (initialThread?.state === "queued" || initialThread?.state === "active") threads.stop(threadId, { reason: "abort" });
|
|
1177
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
1178
|
+
emit();
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (!initialThread) return;
|
|
1182
|
+
if (initialThread.state === "closed") {
|
|
1183
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "disposed" };
|
|
1184
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
922
1185
|
emit();
|
|
923
1186
|
return;
|
|
924
1187
|
}
|
|
1188
|
+
if (initialThread.state === "stopped") {
|
|
1189
|
+
results[index] = { ...results[index]!, status: "cancelled", terminationReason: initialThread.stopReason ?? "interrupted" };
|
|
1190
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
1191
|
+
emit();
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (initialThread.state !== "queued") return;
|
|
925
1195
|
const input = inputs[index]!;
|
|
1196
|
+
threads.begin(threadId);
|
|
926
1197
|
if ([...task].length > limits.taskCharacters) {
|
|
927
1198
|
results[index] = {
|
|
928
1199
|
...results[index]!,
|
|
@@ -930,24 +1201,124 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
930
1201
|
terminationReason: "task_limit",
|
|
931
1202
|
errorMessage: `Expanded task exceeds ${limits.taskCharacters} characters`,
|
|
932
1203
|
};
|
|
1204
|
+
threads.fail(threadId, { message: results[index]!.errorMessage ?? "Expanded task exceeds the task limit", code: "task_limit" });
|
|
1205
|
+
savedResults.set(threadId, cloneResult(results[index]!));
|
|
933
1206
|
emit();
|
|
934
1207
|
return;
|
|
935
1208
|
}
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1209
|
+
const controller = new AbortController();
|
|
1210
|
+
const abortParent = (): void => controller.abort();
|
|
1211
|
+
if (signal) {
|
|
1212
|
+
if (signal.aborted) controller.abort();
|
|
1213
|
+
else signal.addEventListener("abort", abortParent, { once: true });
|
|
1214
|
+
}
|
|
1215
|
+
const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
|
|
1216
|
+
activeRuntimes.set(threadId, runtime);
|
|
1217
|
+
const agent = roles.get(input.agent)!;
|
|
1218
|
+
const queuedSteering = initialThread.steering.map((entry) => entry.message);
|
|
1219
|
+
let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, undefined, limits.taskCharacters) : task;
|
|
1220
|
+
const stopForBudget = (reason: string, message: string): void => {
|
|
1221
|
+
const limited = cloneResult(runtime.aggregate ?? results[index]!);
|
|
1222
|
+
limited.status = "limited";
|
|
1223
|
+
limited.terminationReason = reason;
|
|
1224
|
+
limited.errorMessage = message;
|
|
1225
|
+
runtime.aggregate = limited;
|
|
1226
|
+
results[index] = cloneResult(limited);
|
|
1227
|
+
savedResults.set(threadId, cloneResult(limited));
|
|
1228
|
+
if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
|
|
1229
|
+
threads.stop(threadId, {
|
|
1230
|
+
usage: threadUsage(limited.usage),
|
|
1231
|
+
result: limited.output || undefined,
|
|
1232
|
+
handoff: limited.output ? { summary: limited.output } : undefined,
|
|
1233
|
+
reason,
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
emit();
|
|
1237
|
+
};
|
|
1238
|
+
try {
|
|
1239
|
+
while (true) {
|
|
1240
|
+
const aggregate = runtime.aggregate;
|
|
1241
|
+
const remainingWallTimeMs = agent.timeoutMs - (Date.now() - runtime.startedAt);
|
|
1242
|
+
const usedTraceBytes = aggregate?.traceBytes ?? 0;
|
|
1243
|
+
const usedStderrBytes = aggregate?.stderrBytes ?? 0;
|
|
1244
|
+
const usedOutputBytes = aggregate?.outputBytes ?? 0;
|
|
1245
|
+
const usedTokens = aggregate?.usage.totalTokens ?? 0;
|
|
1246
|
+
const usedCost = aggregate?.usage.cost.total ?? 0;
|
|
1247
|
+
if (remainingWallTimeMs <= 0) {
|
|
1248
|
+
stopForBudget("wall_time_limit", `Child thread exceeds ${agent.timeoutMs} ms`);
|
|
1249
|
+
break;
|
|
1250
|
+
}
|
|
1251
|
+
if (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes) {
|
|
1252
|
+
stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
if (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes) {
|
|
1256
|
+
stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
|
|
1257
|
+
break;
|
|
1258
|
+
}
|
|
1259
|
+
if (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes) {
|
|
1260
|
+
stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
if (usedTokens >= limits.quotaTokens) {
|
|
1264
|
+
stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
1267
|
+
if (usedCost >= limits.quotaUsd) {
|
|
1268
|
+
stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
|
|
1269
|
+
break;
|
|
1270
|
+
}
|
|
1271
|
+
runtime.traceCount = 0;
|
|
1272
|
+
const next = await runTask({
|
|
1273
|
+
cwd: ctx.cwd,
|
|
1274
|
+
agent: roles.get(input.agent)!,
|
|
1275
|
+
task: currentTask,
|
|
1276
|
+
id: results[index]!.id,
|
|
1277
|
+
step: results[index]!.step,
|
|
1278
|
+
model: resolvedModels.get(input.agent)!,
|
|
1279
|
+
signal: controller.signal,
|
|
1280
|
+
webExtension: options.webExtension,
|
|
1281
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
1282
|
+
spawnProcess,
|
|
1283
|
+
limits: {
|
|
1284
|
+
...limits,
|
|
1285
|
+
traceBytes: limits.traceBytes - usedTraceBytes,
|
|
1286
|
+
stderrBytes: limits.stderrBytes - usedStderrBytes,
|
|
1287
|
+
taskOutputBytes: limits.taskOutputBytes - usedOutputBytes,
|
|
1288
|
+
quotaTokens: limits.quotaTokens - usedTokens,
|
|
1289
|
+
quotaUsd: limits.quotaUsd - usedCost,
|
|
1290
|
+
},
|
|
1291
|
+
timeoutMs: remainingWallTimeMs,
|
|
1292
|
+
onHandle: (handle) => { runtime.handle = handle; },
|
|
1293
|
+
onChange: (changed) => {
|
|
1294
|
+
results[index] = syncThread(threadId, changed, runtime);
|
|
1295
|
+
emit();
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
next.task = task;
|
|
1299
|
+
runtime.aggregate = mergeTaskResults(runtime.aggregate, next, limits.traceBytes);
|
|
1300
|
+
runtime.aggregate.task = task;
|
|
1301
|
+
results[index] = cloneResult(runtime.aggregate);
|
|
1302
|
+
savedResults.set(threadId, cloneResult(runtime.aggregate));
|
|
1303
|
+
const shouldRestart = runtime.steering.length > 0 && !controller.signal.aborted && (runtime.restarting || next.status === "complete" || next.status === "cancelled");
|
|
1304
|
+
if (!shouldRestart) break;
|
|
1305
|
+
const steering = runtime.steering.splice(0);
|
|
1306
|
+
runtime.restarting = false;
|
|
1307
|
+
runtime.requestedReason = undefined;
|
|
1308
|
+
if (!threads.isDisposed && threads.inspect(threadId)?.state === "active") {
|
|
1309
|
+
threads.patch(threadId, {
|
|
1310
|
+
usage: threadUsage(runtime.aggregate.usage),
|
|
1311
|
+
result: runtime.aggregate.output || undefined,
|
|
1312
|
+
handoff: runtime.aggregate.output ? { summary: runtime.aggregate.output } : undefined,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
currentTask = buildSteeredTask(task, steering, runtime.aggregate.output, limits.taskCharacters);
|
|
1316
|
+
}
|
|
1317
|
+
} finally {
|
|
1318
|
+
activeRuntimes.delete(threadId);
|
|
1319
|
+
signal?.removeEventListener("abort", abortParent);
|
|
1320
|
+
}
|
|
1321
|
+
emit();
|
|
951
1322
|
};
|
|
952
1323
|
|
|
953
1324
|
emit(`${mode}: ${results.length} queued`);
|
|
@@ -959,10 +1330,19 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
959
1330
|
if (results[index]!.status !== "complete") break;
|
|
960
1331
|
previous = results[index]!.output;
|
|
961
1332
|
}
|
|
962
|
-
for (
|
|
1333
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
1334
|
+
const result = results[index]!;
|
|
963
1335
|
if (result.status === "queued") {
|
|
964
|
-
|
|
965
|
-
|
|
1336
|
+
const thread = threads.inspect(threadRecords[index]!.id);
|
|
1337
|
+
const alreadyStopped = thread?.state === "stopped";
|
|
1338
|
+
result.status = signal?.aborted || alreadyStopped ? "cancelled" : "failed";
|
|
1339
|
+
result.terminationReason = alreadyStopped
|
|
1340
|
+
? thread.stopReason ?? "interrupted"
|
|
1341
|
+
: signal?.aborted ? "abort" : "chain_stopped";
|
|
1342
|
+
if (thread?.state === "queued" || thread?.state === "active") {
|
|
1343
|
+
threads.stop(threadRecords[index]!.id, { reason: result.terminationReason });
|
|
1344
|
+
}
|
|
1345
|
+
savedResults.set(threadRecords[index]!.id, cloneResult(result));
|
|
966
1346
|
}
|
|
967
1347
|
}
|
|
968
1348
|
} else if (hasParallel) {
|
|
@@ -974,7 +1354,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
974
1354
|
await runAt(0, inputs[0]!.task);
|
|
975
1355
|
}
|
|
976
1356
|
|
|
977
|
-
const
|
|
1357
|
+
const board = detailsFor(parentId, mode, scope, discovery.projectAgentsDir);
|
|
1358
|
+
const currentResults = results.map(cloneResult);
|
|
1359
|
+
const details: SubagentDetails = { ...board, results: currentResults, aggregateUsage: aggregateUsage(currentResults) };
|
|
978
1360
|
return {
|
|
979
1361
|
content: [{ type: "text", text: buildToolContent(mode, details.results, limits.toolOutputBytes) }],
|
|
980
1362
|
details,
|
|
@@ -984,6 +1366,7 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
984
1366
|
|
|
985
1367
|
renderCall(args, theme) {
|
|
986
1368
|
const scope = args.agentScope ?? "user";
|
|
1369
|
+
if (args.action && args.action !== "spawn") return new Text(`${theme.fg("toolTitle", theme.bold("threads "))}${theme.fg("accent", args.action)}${theme.fg("dim", args.threadId ? ` · ${args.threadId}` : "")}`, 0, 0);
|
|
987
1370
|
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);
|
|
988
1371
|
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);
|
|
989
1372
|
return new Text(`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "…")}${theme.fg("dim", ` · ${scope}`)}`, 0, 0);
|
|
@@ -995,17 +1378,31 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
995
1378
|
const first = result.content[0];
|
|
996
1379
|
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
997
1380
|
}
|
|
1381
|
+
const board = formatThreadBoard({
|
|
1382
|
+
title: `Subagents · ${details.mode}`,
|
|
1383
|
+
threads: details.results.map(threadBoardRecord),
|
|
1384
|
+
selectedThreadId: details.selectedThreadId,
|
|
1385
|
+
});
|
|
998
1386
|
if (!expanded) {
|
|
999
|
-
const lines =
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1387
|
+
const lines = [
|
|
1388
|
+
theme.fg("toolTitle", theme.bold(`Active (${board.active.length})`)),
|
|
1389
|
+
...board.active.map((task) => `${theme.fg("accent", "✻")} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${task.state.label} · ${task.usage.text}`)}`),
|
|
1390
|
+
theme.fg("toolTitle", theme.bold(`Done (${board.done.length})`)),
|
|
1391
|
+
...board.done.map((task) => `${theme.fg(task.state.status === "complete" ? "success" : "warning", `${task.state.label}`)} ${theme.fg("toolTitle", theme.bold(task.agent))}${theme.fg("dim", ` · ${task.id} · ${task.usage.text}`)}`),
|
|
1392
|
+
];
|
|
1003
1393
|
lines.push(theme.fg("dim", `Total · ${formatUsage(details.aggregateUsage)} · Ctrl+O to expand`));
|
|
1004
1394
|
return new Text(lines.join("\n"), 0, 0);
|
|
1005
1395
|
}
|
|
1006
1396
|
|
|
1007
1397
|
const container = new Container();
|
|
1008
1398
|
container.addChild(new Text(theme.fg("toolTitle", theme.bold(`Subagents · ${details.mode}`)), 0, 0));
|
|
1399
|
+
container.addChild(new Text(theme.fg("dim", `Active ${board.active.length} · Done ${board.done.length} · Controls: Inspect · Steer · Interrupt · Collect · Close`), 0, 0));
|
|
1400
|
+
if (board.selected) {
|
|
1401
|
+
const inspection = formatThreadInspection(threadBoardRecord(details.results.find((task) => task.id === board.selected!.id)!));
|
|
1402
|
+
container.addChild(new Spacer(1));
|
|
1403
|
+
container.addChild(new Text(theme.fg("accent", `Inspect ${inspection.id} · ${inspection.state.label} · ${inspection.usage.text}`), 0, 0));
|
|
1404
|
+
for (const entry of inspection.trace.entries) container.addChild(new Text(`${theme.fg("muted", "→ ")}${theme.fg("toolOutput", entry)}`, 0, 0));
|
|
1405
|
+
}
|
|
1009
1406
|
for (const task of details.results) {
|
|
1010
1407
|
container.addChild(new Spacer(1));
|
|
1011
1408
|
const status = theme.fg(statusColor(task.status), `${statusIcon(task.status)} ${task.status}`);
|