@youngjurry/pi-agents 0.7.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 +116 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/SECURITY.md +7 -0
- package/context.ts +124 -0
- package/control.ts +1332 -0
- package/index.ts +300 -0
- package/package.json +67 -0
- package/roles.ts +116 -0
- package/settings.ts +102 -0
- package/tools.ts +303 -0
- package/types.ts +161 -0
- package/viewer.ts +414 -0
package/control.ts
ADDED
|
@@ -0,0 +1,1332 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
5
|
+
import {
|
|
6
|
+
buildSessionContext,
|
|
7
|
+
createAgentSession,
|
|
8
|
+
DefaultResourceLoader,
|
|
9
|
+
getAgentDir,
|
|
10
|
+
ModelRuntime,
|
|
11
|
+
SessionManager,
|
|
12
|
+
SettingsManager,
|
|
13
|
+
type ExtensionAPI,
|
|
14
|
+
type ExtensionContext,
|
|
15
|
+
type ExtensionUIContext,
|
|
16
|
+
type ToolDefinition,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import {
|
|
19
|
+
childAgentInstructions,
|
|
20
|
+
formatTeamMessage,
|
|
21
|
+
lastAssistantAnswer,
|
|
22
|
+
parseForkMode,
|
|
23
|
+
rootAgentInstructions,
|
|
24
|
+
sanitizeForkMessages,
|
|
25
|
+
} from "./context.ts";
|
|
26
|
+
import { discoverRoles, resolveRole } from "./roles.ts";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_CHILD_THINKING_LEVEL,
|
|
29
|
+
DEFAULT_MAX_CONCURRENT_SUBAGENTS,
|
|
30
|
+
DEFAULT_MAX_RESIDENT_SUBAGENTS,
|
|
31
|
+
loadAgentSettings,
|
|
32
|
+
selectAgentModel,
|
|
33
|
+
selectAgentThinkingLevel,
|
|
34
|
+
} from "./settings.ts";
|
|
35
|
+
import {
|
|
36
|
+
AGENT_GATEWAY_TOOL_NAMES,
|
|
37
|
+
CHILD_META_ENTRY_TYPE,
|
|
38
|
+
COLLABORATION_TOOL_NAMES,
|
|
39
|
+
DIRECT_AGENT_TOOL_NAMES,
|
|
40
|
+
EXTENSION_ID,
|
|
41
|
+
FORK_CONTEXT_ENTRY_TYPE,
|
|
42
|
+
ROOT_PATH,
|
|
43
|
+
STATE_ENTRY_TYPE,
|
|
44
|
+
type AgentCounts,
|
|
45
|
+
type AgentLifecycleStatus,
|
|
46
|
+
type AgentRecord,
|
|
47
|
+
type AgentRole,
|
|
48
|
+
type AgentRoleView,
|
|
49
|
+
type AgentTranscriptView,
|
|
50
|
+
type AgentView,
|
|
51
|
+
type ForkContextPayload,
|
|
52
|
+
type PersistedAgent,
|
|
53
|
+
type PersistedTreeState,
|
|
54
|
+
type RootBinding,
|
|
55
|
+
} from "./types.ts";
|
|
56
|
+
|
|
57
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
|
58
|
+
const MIN_WAIT_TIMEOUT_MS = 10_000;
|
|
59
|
+
const MAX_WAIT_TIMEOUT_MS = 3_600_000;
|
|
60
|
+
|
|
61
|
+
const DEFAULT_NICKNAMES = [
|
|
62
|
+
"Ada", "Alan", "Grace", "Linus", "Margaret", "Edsger", "Barbara", "Donald",
|
|
63
|
+
"Frances", "Claude", "Hopper", "Turing", "Lovelace", "Shannon", "Knuth", "Dijkstra",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export interface SpawnRequest {
|
|
67
|
+
message: string;
|
|
68
|
+
taskName: string;
|
|
69
|
+
agentType?: string;
|
|
70
|
+
model?: string;
|
|
71
|
+
thinkingLevel?: ThinkingLevel;
|
|
72
|
+
forkTurns?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface PreparedSpawn {
|
|
76
|
+
request: SpawnRequest;
|
|
77
|
+
childPath: string;
|
|
78
|
+
callerPath: string;
|
|
79
|
+
role: AgentRole;
|
|
80
|
+
selectedModel: Model<any>;
|
|
81
|
+
thinkingLevel?: ThinkingLevel;
|
|
82
|
+
forkMessages: AgentMessage[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface MessageRequest {
|
|
86
|
+
target: string;
|
|
87
|
+
message: string;
|
|
88
|
+
triggerTurn: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface StateWaiter {
|
|
92
|
+
resolve: (value: "mailbox" | "timeout" | "aborted") => void;
|
|
93
|
+
timer: ReturnType<typeof setTimeout>;
|
|
94
|
+
abort?: () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface RootStorageOwner {
|
|
98
|
+
version: 1;
|
|
99
|
+
rootSessionId: string;
|
|
100
|
+
rootSessionFile?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeAgentName(name: string): string {
|
|
104
|
+
const normalized = name.trim();
|
|
105
|
+
if (!normalized) throw new Error("task_name must not be empty");
|
|
106
|
+
if (normalized === "root" || normalized === "." || normalized === "..") {
|
|
107
|
+
throw new Error(`task_name '${normalized}' is reserved`);
|
|
108
|
+
}
|
|
109
|
+
if (!/^[a-z0-9_]+$/.test(normalized)) {
|
|
110
|
+
throw new Error("task_name must use only lowercase letters, digits, and underscores");
|
|
111
|
+
}
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clonePersisted(record: AgentRecord): PersistedAgent {
|
|
116
|
+
return {
|
|
117
|
+
id: record.id,
|
|
118
|
+
path: record.path,
|
|
119
|
+
parentPath: record.parentPath,
|
|
120
|
+
parentId: record.parentId,
|
|
121
|
+
taskName: record.taskName,
|
|
122
|
+
nickname: record.nickname,
|
|
123
|
+
role: record.role,
|
|
124
|
+
modelProvider: record.modelProvider,
|
|
125
|
+
modelId: record.modelId,
|
|
126
|
+
thinkingLevel: record.thinkingLevel,
|
|
127
|
+
status: record.status,
|
|
128
|
+
statusMessage: record.statusMessage,
|
|
129
|
+
resultFile: record.resultFile,
|
|
130
|
+
sessionFile: record.sessionFile,
|
|
131
|
+
createdAt: record.createdAt,
|
|
132
|
+
updatedAt: record.updatedAt,
|
|
133
|
+
lastUsedAt: record.lastUsedAt,
|
|
134
|
+
lastAssignedAt: record.lastAssignedAt,
|
|
135
|
+
queuedMessage: record.queuedMessage,
|
|
136
|
+
queuedMail: record.queuedMail,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isPersistedState(value: unknown): value is PersistedTreeState {
|
|
141
|
+
if (!value || typeof value !== "object") return false;
|
|
142
|
+
const state = value as Partial<PersistedTreeState>;
|
|
143
|
+
return state.version === 1 && typeof state.rootSessionId === "string" && Array.isArray(state.agents);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export class AgentControl {
|
|
147
|
+
private readonly agentsByPath = new Map<string, AgentRecord>();
|
|
148
|
+
private readonly pathBySessionId = new Map<string, string>();
|
|
149
|
+
private readonly mailboxPending = new Map<string, number>();
|
|
150
|
+
private readonly waiters = new Map<string, Set<StateWaiter>>();
|
|
151
|
+
private readonly listeners = new Set<() => void>();
|
|
152
|
+
private readonly usedNicknames = new Set<string>();
|
|
153
|
+
private readonly activeTurns = new Set<string>();
|
|
154
|
+
private readonly pendingMail = new Map<string, string[]>();
|
|
155
|
+
private root?: RootBinding;
|
|
156
|
+
private rootStatus: AgentLifecycleStatus = "completed";
|
|
157
|
+
private rootStatusMessage?: string;
|
|
158
|
+
private tools: ToolDefinition[] = [];
|
|
159
|
+
private readonly transcriptToolDefinitions = new Map<string, ToolDefinition>();
|
|
160
|
+
private transcriptToolDefinitionsReady = false;
|
|
161
|
+
private transcriptToolDefinitionsPromise?: Promise<void>;
|
|
162
|
+
private modelRuntime?: ModelRuntime;
|
|
163
|
+
private modelRuntimePromise?: Promise<ModelRuntime>;
|
|
164
|
+
private activeExecutionSlots = 0;
|
|
165
|
+
private schedulerPromise?: Promise<void>;
|
|
166
|
+
private spawnOperationTail: Promise<void> = Promise.resolve();
|
|
167
|
+
private disposed = false;
|
|
168
|
+
private shuttingDown = false;
|
|
169
|
+
private uiDialogTail: Promise<void> = Promise.resolve();
|
|
170
|
+
private userOverlayDepth = 0;
|
|
171
|
+
private readonly userOverlayWaiters = new Set<() => void>();
|
|
172
|
+
private readonly rootStorageDirectory = path.join(getAgentDir(), "codex-agents", "roots");
|
|
173
|
+
private childSessionDirectory = path.join(getAgentDir(), "codex-agents", "sessions");
|
|
174
|
+
private agentResultDirectory = path.join(getAgentDir(), "codex-agents", "results");
|
|
175
|
+
|
|
176
|
+
constructor(
|
|
177
|
+
private readonly pi: ExtensionAPI,
|
|
178
|
+
private readonly selfExtensionPath: string,
|
|
179
|
+
private readonly maxConcurrentSubagents = DEFAULT_MAX_CONCURRENT_SUBAGENTS,
|
|
180
|
+
private readonly maxResidentSubagents = DEFAULT_MAX_RESIDENT_SUBAGENTS,
|
|
181
|
+
) {}
|
|
182
|
+
|
|
183
|
+
setTools(tools: ToolDefinition[]): void {
|
|
184
|
+
this.tools = tools;
|
|
185
|
+
for (const tool of tools) this.transcriptToolDefinitions.set(tool.name, tool);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onChange(listener: () => void): () => void {
|
|
189
|
+
this.listeners.add(listener);
|
|
190
|
+
return () => this.listeners.delete(listener);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private changed(): void {
|
|
194
|
+
for (const listener of this.listeners) listener();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private enqueueUiDialog<T>(operation: () => Promise<T>): Promise<T> {
|
|
198
|
+
const result = this.uiDialogTail.then(operation, operation);
|
|
199
|
+
this.uiDialogTail = result.then(() => undefined, () => undefined);
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
beginUserOverlay(): () => void {
|
|
204
|
+
this.userOverlayDepth++;
|
|
205
|
+
let released = false;
|
|
206
|
+
return () => {
|
|
207
|
+
if (released) return;
|
|
208
|
+
released = true;
|
|
209
|
+
this.userOverlayDepth = Math.max(0, this.userOverlayDepth - 1);
|
|
210
|
+
if (this.userOverlayDepth > 0) return;
|
|
211
|
+
for (const resolve of this.userOverlayWaiters) resolve();
|
|
212
|
+
this.userOverlayWaiters.clear();
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private waitForUserOverlayClose(): Promise<void> {
|
|
217
|
+
if (this.userOverlayDepth === 0) return Promise.resolve();
|
|
218
|
+
return new Promise((resolve) => this.userOverlayWaiters.add(resolve));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private enqueueSpawnOperation<T>(operation: () => Promise<T>): Promise<T> {
|
|
222
|
+
const result = this.spawnOperationTail.then(operation, operation);
|
|
223
|
+
this.spawnOperationTail = result.then(() => undefined, () => undefined);
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private configureChildTools(session: NonNullable<AgentRecord["session"]>): void {
|
|
228
|
+
const hidden = new Set<string>([...DIRECT_AGENT_TOOL_NAMES, "spawn_agent"]);
|
|
229
|
+
const active = session.getActiveToolNames().filter((name) => !hidden.has(name));
|
|
230
|
+
for (const name of AGENT_GATEWAY_TOOL_NAMES) {
|
|
231
|
+
if (!active.includes(name) && session.getToolDefinition(name)) active.push(name);
|
|
232
|
+
}
|
|
233
|
+
session.setActiveToolsByName(active);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private attachRootUi(record: AgentRecord, session: NonNullable<AgentRecord["session"]>): void {
|
|
237
|
+
const root = this.root;
|
|
238
|
+
if (!root?.ctx.hasUI) return;
|
|
239
|
+
const rootUi = root.ctx.ui;
|
|
240
|
+
const dialogMethods = new Set<PropertyKey>(["select", "confirm", "input", "editor"]);
|
|
241
|
+
const proxiedUi = new Proxy(rootUi, {
|
|
242
|
+
get: (target, property) => {
|
|
243
|
+
const value = Reflect.get(target, property, target);
|
|
244
|
+
if (typeof value !== "function") return value;
|
|
245
|
+
if (dialogMethods.has(property)) {
|
|
246
|
+
return (...args: unknown[]) => this.enqueueUiDialog(async () => {
|
|
247
|
+
await this.waitForUserOverlayClose();
|
|
248
|
+
const taggedArgs = [...args];
|
|
249
|
+
if (typeof taggedArgs[0] === "string") taggedArgs[0] = `[${record.path}] ${taggedArgs[0]}`;
|
|
250
|
+
return value.apply(target, taggedArgs);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (property === "custom") {
|
|
254
|
+
return (...args: unknown[]) => this.enqueueUiDialog(async () => {
|
|
255
|
+
await this.waitForUserOverlayClose();
|
|
256
|
+
return value.apply(target, args);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (property === "notify") {
|
|
260
|
+
return (message: string, ...args: unknown[]) => value.apply(target, [`[${record.path}] ${message}`, ...args]);
|
|
261
|
+
}
|
|
262
|
+
return value.bind(target);
|
|
263
|
+
},
|
|
264
|
+
}) as ExtensionUIContext;
|
|
265
|
+
session.extensionRunner.setUIContext(proxiedUi, root.ctx.mode);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private migrateStoredFile(file: string | undefined, directory: string): { path: string | undefined; migrated: boolean } {
|
|
269
|
+
if (!file) return { path: undefined, migrated: false };
|
|
270
|
+
const source = path.resolve(file);
|
|
271
|
+
const destinationDirectory = path.resolve(directory);
|
|
272
|
+
if (path.dirname(source) === destinationDirectory) return { path: source, migrated: false };
|
|
273
|
+
const target = path.join(destinationDirectory, path.basename(source));
|
|
274
|
+
try {
|
|
275
|
+
fs.mkdirSync(destinationDirectory, { recursive: true });
|
|
276
|
+
if (fs.existsSync(source)) {
|
|
277
|
+
if (fs.existsSync(target)) throw new Error(`agent storage migration target already exists: ${target}`);
|
|
278
|
+
fs.renameSync(source, target);
|
|
279
|
+
return { path: target, migrated: true };
|
|
280
|
+
}
|
|
281
|
+
if (fs.existsSync(target)) return { path: target, migrated: true };
|
|
282
|
+
} catch {
|
|
283
|
+
// Keep the original path and fail safely during lazy loading if it becomes unavailable.
|
|
284
|
+
}
|
|
285
|
+
return { path: source, migrated: false };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private configureRootStorage(ctx: ExtensionContext, sessionId: string): void {
|
|
289
|
+
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
290
|
+
const rootDirectory = path.join(this.rootStorageDirectory, safeSessionId);
|
|
291
|
+
this.childSessionDirectory = path.join(rootDirectory, "sessions");
|
|
292
|
+
this.agentResultDirectory = path.join(rootDirectory, "results");
|
|
293
|
+
try {
|
|
294
|
+
fs.mkdirSync(this.childSessionDirectory, { recursive: true });
|
|
295
|
+
fs.mkdirSync(this.agentResultDirectory, { recursive: true });
|
|
296
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
297
|
+
const owner: RootStorageOwner = {
|
|
298
|
+
version: 1,
|
|
299
|
+
rootSessionId: sessionId,
|
|
300
|
+
rootSessionFile: sessionFile ? path.resolve(sessionFile) : undefined,
|
|
301
|
+
};
|
|
302
|
+
fs.writeFileSync(path.join(rootDirectory, "owner.json"), `${JSON.stringify(owner, null, 2)}\n`, "utf8");
|
|
303
|
+
} catch {
|
|
304
|
+
// Session operation will surface a concrete error later if storage is unavailable.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
cleanupOrphanStorage(currentSessionId: string): number {
|
|
309
|
+
if (!fs.existsSync(this.rootStorageDirectory)) return 0;
|
|
310
|
+
let entries: fs.Dirent[];
|
|
311
|
+
try {
|
|
312
|
+
entries = fs.readdirSync(this.rootStorageDirectory, { withFileTypes: true });
|
|
313
|
+
} catch {
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
let removed = 0;
|
|
317
|
+
for (const entry of entries) {
|
|
318
|
+
if (!entry.isDirectory()) continue;
|
|
319
|
+
const directory = path.join(this.rootStorageDirectory, entry.name);
|
|
320
|
+
let owner: RootStorageOwner;
|
|
321
|
+
try {
|
|
322
|
+
owner = JSON.parse(fs.readFileSync(path.join(directory, "owner.json"), "utf8")) as RootStorageOwner;
|
|
323
|
+
} catch {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (owner.version !== 1 || typeof owner.rootSessionId !== "string") continue;
|
|
327
|
+
if (owner.rootSessionId === currentSessionId) continue;
|
|
328
|
+
if (owner.rootSessionFile && fs.existsSync(owner.rootSessionFile)) continue;
|
|
329
|
+
try {
|
|
330
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
331
|
+
removed++;
|
|
332
|
+
} catch {
|
|
333
|
+
// A failed cleanup must not block resume.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return removed;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
bindRoot(ctx: ExtensionContext): void {
|
|
340
|
+
this.disposed = false;
|
|
341
|
+
this.shuttingDown = false;
|
|
342
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
343
|
+
this.configureRootStorage(ctx, sessionId);
|
|
344
|
+
const changedSession = this.root?.sessionId !== sessionId;
|
|
345
|
+
this.root = {
|
|
346
|
+
ctx,
|
|
347
|
+
sessionId,
|
|
348
|
+
cwd: ctx.cwd,
|
|
349
|
+
model: ctx.model,
|
|
350
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
351
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
352
|
+
};
|
|
353
|
+
this.pathBySessionId.set(sessionId, ROOT_PATH);
|
|
354
|
+
if (changedSession) this.restoreState(ctx);
|
|
355
|
+
this.changed();
|
|
356
|
+
void this.scheduleQueued();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
refreshRootContext(ctx: ExtensionContext): void {
|
|
360
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
361
|
+
const knownPath = this.pathBySessionId.get(sessionId);
|
|
362
|
+
// Collaboration tools in child AgentSessions receive their own ExtensionContext.
|
|
363
|
+
// Never mistake that context for a replacement root session.
|
|
364
|
+
if (knownPath && knownPath !== ROOT_PATH) return;
|
|
365
|
+
if (!this.root) {
|
|
366
|
+
this.bindRoot(ctx);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (this.root.sessionId !== sessionId) return;
|
|
370
|
+
this.root.ctx = ctx;
|
|
371
|
+
this.root.cwd = ctx.cwd;
|
|
372
|
+
this.root.model = ctx.model;
|
|
373
|
+
this.root.thinkingLevel = ctx.thinkingLevel;
|
|
374
|
+
this.root.systemPrompt = ctx.getSystemPrompt();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
setRootStatus(status: AgentLifecycleStatus, message?: string): void {
|
|
378
|
+
this.rootStatus = status;
|
|
379
|
+
this.rootStatusMessage = message;
|
|
380
|
+
this.changed();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
markMailboxConsumed(path: string): void {
|
|
384
|
+
this.mailboxPending.set(path, 0);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
noteTurnStart(path: string): void {
|
|
388
|
+
this.activeTurns.add(path);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
noteTurnEnd(path: string, stopReason?: string): void {
|
|
392
|
+
if (!this.activeTurns.delete(path)) return;
|
|
393
|
+
const queued = this.pendingMail.get(path) ?? [];
|
|
394
|
+
if (queued.length === 0) return;
|
|
395
|
+
const deferWithoutWake = stopReason === "aborted" || stopReason === "error";
|
|
396
|
+
if (deferWithoutWake && path !== ROOT_PATH) {
|
|
397
|
+
// Child sessions are resumed through launch(), which carries this mail into
|
|
398
|
+
// the next explicit task without restarting an interrupted agent.
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.pendingMail.delete(path);
|
|
402
|
+
const delivery = deferWithoutWake ? "nextTurn" : "steer";
|
|
403
|
+
void this.flushMail(path, queued, delivery).catch(() => {
|
|
404
|
+
const newer = this.pendingMail.get(path) ?? [];
|
|
405
|
+
this.pendingMail.set(path, [...queued, ...newer]);
|
|
406
|
+
this.changed();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
drainPendingMail(path: string): string[] {
|
|
411
|
+
const queued = this.pendingMail.get(path) ?? [];
|
|
412
|
+
this.pendingMail.delete(path);
|
|
413
|
+
return queued;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private async flushMail(path: string, contents: string[], delivery: "steer" | "nextTurn"): Promise<void> {
|
|
417
|
+
const content = contents.join("\n\n");
|
|
418
|
+
const message = { customType: EXTENSION_ID, content, display: true, details: { type: "AGENT_STATUS" } };
|
|
419
|
+
if (path === ROOT_PATH) {
|
|
420
|
+
this.pi.sendMessage(message, {
|
|
421
|
+
triggerTurn: delivery === "steer",
|
|
422
|
+
deliverAs: delivery,
|
|
423
|
+
});
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const record = this.agentsByPath.get(path);
|
|
427
|
+
if (!record) throw new Error(`agent ${path} not found while flushing queued mail`);
|
|
428
|
+
if (!record.session) await this.ensureLoaded(record);
|
|
429
|
+
if (delivery === "nextTurn") {
|
|
430
|
+
await record.session!.sendCustomMessage(message, { triggerTurn: false, deliverAs: "nextTurn" });
|
|
431
|
+
} else if (record.session!.isIdle) {
|
|
432
|
+
this.launch(record, content);
|
|
433
|
+
} else {
|
|
434
|
+
await record.session!.sendCustomMessage(message, { triggerTurn: true, deliverAs: "steer" });
|
|
435
|
+
}
|
|
436
|
+
record.lastUsedAt = Date.now();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
configureInitialRootTools(): void {
|
|
440
|
+
const hidden = new Set<string>([...DIRECT_AGENT_TOOL_NAMES, "spawn_agent"]);
|
|
441
|
+
const current = this.pi.getActiveTools();
|
|
442
|
+
const next = current.filter((name) => !hidden.has(name));
|
|
443
|
+
for (const name of AGENT_GATEWAY_TOOL_NAMES) {
|
|
444
|
+
if (!next.includes(name)) next.push(name);
|
|
445
|
+
}
|
|
446
|
+
if (next.length !== current.length || next.some((name, index) => name !== current[index])) {
|
|
447
|
+
this.pi.setActiveTools(next);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
callerPath(ctx: ExtensionContext): string {
|
|
452
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
453
|
+
const caller = this.pathBySessionId.get(sessionId);
|
|
454
|
+
if (caller === ROOT_PATH) this.refreshRootContext(ctx);
|
|
455
|
+
if (!caller) throw new Error(`agent session ${sessionId} is not registered in this agent tree`);
|
|
456
|
+
return caller;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private callerMessages(ctx: ExtensionContext): AgentMessage[] {
|
|
460
|
+
const callerPath = this.callerPath(ctx);
|
|
461
|
+
if (callerPath === ROOT_PATH) {
|
|
462
|
+
return buildSessionContext(
|
|
463
|
+
ctx.sessionManager.getEntries(),
|
|
464
|
+
ctx.sessionManager.getLeafId(),
|
|
465
|
+
).messages;
|
|
466
|
+
}
|
|
467
|
+
const record = this.agentsByPath.get(callerPath);
|
|
468
|
+
if (!record?.session) throw new Error(`agent ${callerPath} is not loaded`);
|
|
469
|
+
return record.session.messages;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private resolveReference(callerPath: string, reference: string): string {
|
|
473
|
+
const target = reference.trim();
|
|
474
|
+
if (!target) throw new Error("target must not be empty");
|
|
475
|
+
if (target === ROOT_PATH) return ROOT_PATH;
|
|
476
|
+
if (target.startsWith("/")) return target;
|
|
477
|
+
if (/^[0-9a-f-]{16,}$/i.test(target)) {
|
|
478
|
+
const byId = this.pathBySessionId.get(target);
|
|
479
|
+
if (byId) return byId;
|
|
480
|
+
}
|
|
481
|
+
return `${callerPath}/${target}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private requireTarget(callerPath: string, reference: string): { path: string; record?: AgentRecord } {
|
|
485
|
+
const targetPath = this.resolveReference(callerPath, reference);
|
|
486
|
+
if (targetPath === ROOT_PATH) return { path: ROOT_PATH };
|
|
487
|
+
const record = this.agentsByPath.get(targetPath);
|
|
488
|
+
if (!record) throw new Error(`live agent path '${targetPath}' not found`);
|
|
489
|
+
return { path: targetPath, record };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private reserveExecutionSlot(): void {
|
|
493
|
+
if (this.activeExecutionSlots >= this.maxConcurrentSubagents) {
|
|
494
|
+
throw new Error(`agent concurrency limit reached (${this.maxConcurrentSubagents} subagents)`);
|
|
495
|
+
}
|
|
496
|
+
this.activeExecutionSlots++;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private releaseExecutionSlot(record: AgentRecord): void {
|
|
500
|
+
if (!record.holdsExecutionSlot) return;
|
|
501
|
+
record.holdsExecutionSlot = false;
|
|
502
|
+
this.activeExecutionSlots = Math.max(0, this.activeExecutionSlots - 1);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private reserveNickname(candidates?: string[]): string {
|
|
506
|
+
const pool = candidates?.length ? candidates : DEFAULT_NICKNAMES;
|
|
507
|
+
for (const name of pool) {
|
|
508
|
+
if (!this.usedNicknames.has(name)) {
|
|
509
|
+
this.usedNicknames.add(name);
|
|
510
|
+
return name;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
let suffix = 2;
|
|
514
|
+
while (true) {
|
|
515
|
+
for (const name of pool) {
|
|
516
|
+
const candidate = `${name} ${suffix}`;
|
|
517
|
+
if (!this.usedNicknames.has(candidate)) {
|
|
518
|
+
this.usedNicknames.add(candidate);
|
|
519
|
+
return candidate;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
suffix++;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private async getModelRuntime(ctx: ExtensionContext): Promise<ModelRuntime> {
|
|
527
|
+
if (this.modelRuntime) return this.modelRuntime;
|
|
528
|
+
if (this.modelRuntimePromise) return this.modelRuntimePromise;
|
|
529
|
+
const agentDir = getAgentDir();
|
|
530
|
+
this.modelRuntimePromise = ModelRuntime.create({
|
|
531
|
+
authPath: path.join(agentDir, "auth.json"),
|
|
532
|
+
modelsPath: path.join(agentDir, "models.json"),
|
|
533
|
+
}).then((runtime) => {
|
|
534
|
+
for (const providerId of ctx.modelRegistry.getRegisteredProviderIds()) {
|
|
535
|
+
const nativeProvider = ctx.modelRegistry.getRegisteredNativeProvider(providerId);
|
|
536
|
+
if (nativeProvider) runtime.registerNativeProvider(nativeProvider);
|
|
537
|
+
const config = ctx.modelRegistry.getRegisteredProviderConfig(providerId);
|
|
538
|
+
if (config) runtime.registerProvider(providerId, config);
|
|
539
|
+
}
|
|
540
|
+
this.modelRuntime = runtime;
|
|
541
|
+
return runtime;
|
|
542
|
+
});
|
|
543
|
+
return this.modelRuntimePromise;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private async resolveModel(ctx: ExtensionContext, requested?: string): Promise<Model<any>> {
|
|
547
|
+
const value = requested?.trim();
|
|
548
|
+
if (!value) {
|
|
549
|
+
throw new Error("no sub-agent model configured; set a task model, Role model, or defaultModel in agents-setting.json");
|
|
550
|
+
}
|
|
551
|
+
const runtime = await this.getModelRuntime(ctx);
|
|
552
|
+
const slash = value.indexOf("/");
|
|
553
|
+
if (slash > 0) {
|
|
554
|
+
const model = runtime.getModel(value.slice(0, slash), value.slice(slash + 1));
|
|
555
|
+
if (model) return model;
|
|
556
|
+
} else {
|
|
557
|
+
const matches = runtime.getModels().filter((model) => model.id === value);
|
|
558
|
+
if (matches.length === 1) return matches[0]!;
|
|
559
|
+
if (matches.length > 1) throw new Error(`model '${value}' is ambiguous; use provider/model`);
|
|
560
|
+
}
|
|
561
|
+
throw new Error(`model '${value}' not found`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private captureTranscriptToolDefinitions(loader: DefaultResourceLoader): void {
|
|
565
|
+
for (const extension of loader.getExtensions().extensions) {
|
|
566
|
+
for (const registered of extension.tools.values()) {
|
|
567
|
+
if (!this.transcriptToolDefinitions.has(registered.definition.name)) {
|
|
568
|
+
this.transcriptToolDefinitions.set(registered.definition.name, registered.definition);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
this.transcriptToolDefinitionsReady = true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async prepareTranscriptToolDefinitions(ctx: ExtensionContext): Promise<void> {
|
|
576
|
+
if (this.transcriptToolDefinitionsReady) return;
|
|
577
|
+
if (!this.transcriptToolDefinitionsPromise) {
|
|
578
|
+
const settingsManager = SettingsManager.create(ctx.cwd, getAgentDir());
|
|
579
|
+
this.transcriptToolDefinitionsPromise = this.createLoader(ctx.cwd, settingsManager, "")
|
|
580
|
+
.then(() => undefined)
|
|
581
|
+
.catch((error) => {
|
|
582
|
+
this.transcriptToolDefinitionsPromise = undefined;
|
|
583
|
+
throw error;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
await this.transcriptToolDefinitionsPromise;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private async createLoader(cwd: string, settingsManager: SettingsManager, instructions: string): Promise<DefaultResourceLoader> {
|
|
590
|
+
const selfPath = path.resolve(this.selfExtensionPath);
|
|
591
|
+
const loader = new DefaultResourceLoader({
|
|
592
|
+
cwd,
|
|
593
|
+
agentDir: getAgentDir(),
|
|
594
|
+
settingsManager,
|
|
595
|
+
extensionsOverride: (base) => ({
|
|
596
|
+
...base,
|
|
597
|
+
extensions: base.extensions.filter((extension) => path.resolve(extension.resolvedPath) !== selfPath),
|
|
598
|
+
}),
|
|
599
|
+
systemPromptOverride: (base) => `${base || "You are a coding agent."}\n\n${instructions}`,
|
|
600
|
+
});
|
|
601
|
+
await loader.reload();
|
|
602
|
+
this.captureTranscriptToolDefinitions(loader);
|
|
603
|
+
return loader;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async evictForResidency(protectedPath?: string): Promise<void> {
|
|
607
|
+
const allResidents = [...this.agentsByPath.values()].filter((record) => record.loaded);
|
|
608
|
+
if (allResidents.length < this.maxResidentSubagents) return;
|
|
609
|
+
const candidate = allResidents
|
|
610
|
+
.filter((record) => record.path !== protectedPath)
|
|
611
|
+
.filter((record) => record.status !== "running" && !record.holdsExecutionSlot && record.session?.isIdle !== false)
|
|
612
|
+
.sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0];
|
|
613
|
+
if (!candidate) throw new Error(`agent residency limit reached (${this.maxResidentSubagents}); all resident agents are busy`);
|
|
614
|
+
this.unload(candidate);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private unload(record: AgentRecord): void {
|
|
618
|
+
record.unsubscribe?.();
|
|
619
|
+
record.unsubscribe = undefined;
|
|
620
|
+
record.session?.dispose();
|
|
621
|
+
record.session = undefined;
|
|
622
|
+
record.loaded = false;
|
|
623
|
+
record.updatedAt = Date.now();
|
|
624
|
+
this.changed();
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private childInstructions(record: Pick<AgentRecord, "path" | "parentPath" | "role">, rolePrompt?: string): string {
|
|
628
|
+
return childAgentInstructions(record.path, record.parentPath, rolePrompt);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private subscribe(record: AgentRecord): void {
|
|
632
|
+
const session = record.session;
|
|
633
|
+
if (!session) return;
|
|
634
|
+
record.unsubscribe?.();
|
|
635
|
+
record.unsubscribe = session.subscribe((event) => {
|
|
636
|
+
record.lastUsedAt = Date.now();
|
|
637
|
+
if (event.type === "agent_start") {
|
|
638
|
+
record.status = "running";
|
|
639
|
+
record.statusMessage = undefined;
|
|
640
|
+
record.updatedAt = Date.now();
|
|
641
|
+
}
|
|
642
|
+
if (event.type === "turn_start") {
|
|
643
|
+
this.activeTurns.add(record.path);
|
|
644
|
+
this.markMailboxConsumed(record.path);
|
|
645
|
+
}
|
|
646
|
+
if (event.type === "turn_end") {
|
|
647
|
+
this.noteTurnEnd(record.path, event.message.role === "assistant" ? event.message.stopReason : undefined);
|
|
648
|
+
}
|
|
649
|
+
if (event.type === "message_update" || event.type === "tool_execution_start" || event.type === "tool_execution_end") {
|
|
650
|
+
record.updatedAt = Date.now();
|
|
651
|
+
}
|
|
652
|
+
if (event.type === "agent_end" && !event.willRetry) {
|
|
653
|
+
this.completeRun(record, event.messages);
|
|
654
|
+
if (this.activeTurns.has(record.path)) {
|
|
655
|
+
const lastAssistant = [...event.messages].reverse().find((message) => message.role === "assistant");
|
|
656
|
+
this.noteTurnEnd(record.path, lastAssistant?.role === "assistant" ? lastAssistant.stopReason : undefined);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
this.changed();
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private writeAgentResult(record: AgentRecord): string | undefined {
|
|
664
|
+
if (!record.finalAnswer) return undefined;
|
|
665
|
+
try {
|
|
666
|
+
fs.mkdirSync(this.agentResultDirectory, { recursive: true });
|
|
667
|
+
const resultFile = path.join(this.agentResultDirectory, `${record.id}.md`);
|
|
668
|
+
fs.writeFileSync(resultFile, record.finalAnswer, "utf8");
|
|
669
|
+
record.resultFile = resultFile;
|
|
670
|
+
return resultFile;
|
|
671
|
+
} catch {
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private completionNotice(record: AgentRecord): string {
|
|
677
|
+
const suffix = record.statusMessage ? `: ${record.statusMessage}` : "";
|
|
678
|
+
return `[agent ${record.status}] ${record.path}${suffix}\nPull: list_agents(view=\"results\", path_prefix=\"${record.path}\")`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private completeRun(record: AgentRecord, messages: readonly AgentMessage[]): void {
|
|
682
|
+
const answer = lastAssistantAnswer(messages.length > 0 ? messages : record.session?.messages || []);
|
|
683
|
+
if (answer.timestamp && answer.timestamp === record.lastCompletionTimestamp) return;
|
|
684
|
+
record.lastCompletionTimestamp = answer.timestamp;
|
|
685
|
+
record.updatedAt = Date.now();
|
|
686
|
+
if (answer.aborted) {
|
|
687
|
+
record.status = "interrupted";
|
|
688
|
+
record.statusMessage = "interrupted";
|
|
689
|
+
this.releaseExecutionSlot(record);
|
|
690
|
+
this.persistState();
|
|
691
|
+
this.changed();
|
|
692
|
+
void this.scheduleQueued();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (answer.error) {
|
|
696
|
+
record.status = "errored";
|
|
697
|
+
record.statusMessage = answer.error;
|
|
698
|
+
record.finalAnswer = answer.error;
|
|
699
|
+
} else {
|
|
700
|
+
record.status = "completed";
|
|
701
|
+
record.finalAnswer = answer.text || "(no final answer)";
|
|
702
|
+
record.statusMessage = undefined;
|
|
703
|
+
}
|
|
704
|
+
this.writeAgentResult(record);
|
|
705
|
+
this.releaseExecutionSlot(record);
|
|
706
|
+
this.persistState();
|
|
707
|
+
this.changed();
|
|
708
|
+
void this.scheduleQueued();
|
|
709
|
+
void this.deliver(record.path, record.parentPath, this.completionNotice(record), false, "AGENT_STATUS").catch(() => {});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private launch(record: AgentRecord, content: string): void {
|
|
713
|
+
const session = record.session;
|
|
714
|
+
if (!session) throw new Error(`agent ${record.path} is not loaded`);
|
|
715
|
+
const carriedMail = this.drainPendingMail(record.path);
|
|
716
|
+
if (carriedMail.length > 0) {
|
|
717
|
+
this.mailboxPending.set(record.path, 0);
|
|
718
|
+
content = `${carriedMail.join("\n\n")}\n\n${content}`;
|
|
719
|
+
}
|
|
720
|
+
if (!record.holdsExecutionSlot) {
|
|
721
|
+
this.reserveExecutionSlot();
|
|
722
|
+
record.holdsExecutionSlot = true;
|
|
723
|
+
}
|
|
724
|
+
record.launchGeneration++;
|
|
725
|
+
record.status = "running";
|
|
726
|
+
record.statusMessage = undefined;
|
|
727
|
+
record.lastUsedAt = Date.now();
|
|
728
|
+
const generation = record.launchGeneration;
|
|
729
|
+
void session.sendCustomMessage(
|
|
730
|
+
{
|
|
731
|
+
customType: EXTENSION_ID,
|
|
732
|
+
content,
|
|
733
|
+
display: true,
|
|
734
|
+
details: { sender: record.parentPath, recipient: record.path },
|
|
735
|
+
},
|
|
736
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
737
|
+
).catch((error) => {
|
|
738
|
+
if (generation !== record.launchGeneration) return;
|
|
739
|
+
if (carriedMail.length > 0) {
|
|
740
|
+
const newer = this.pendingMail.get(record.path) ?? [];
|
|
741
|
+
this.pendingMail.set(record.path, [...carriedMail, ...newer]);
|
|
742
|
+
}
|
|
743
|
+
record.status = "errored";
|
|
744
|
+
record.statusMessage = error instanceof Error ? error.message : String(error);
|
|
745
|
+
record.finalAnswer = record.statusMessage;
|
|
746
|
+
record.updatedAt = Date.now();
|
|
747
|
+
this.releaseExecutionSlot(record);
|
|
748
|
+
this.persistState();
|
|
749
|
+
void this.scheduleQueued();
|
|
750
|
+
this.changed();
|
|
751
|
+
void this.deliver(record.path, record.parentPath, this.completionNotice(record), false, "AGENT_STATUS").catch(() => {});
|
|
752
|
+
}).finally(() => {
|
|
753
|
+
if (generation === record.launchGeneration && record.status === "running" && session.isIdle) {
|
|
754
|
+
record.status = "completed";
|
|
755
|
+
this.releaseExecutionSlot(record);
|
|
756
|
+
void this.scheduleQueued();
|
|
757
|
+
}
|
|
758
|
+
this.changed();
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private async prepareSpawnBatch(ctx: ExtensionContext, requests: SpawnRequest[]): Promise<PreparedSpawn[]> {
|
|
763
|
+
if (this.disposed || this.shuttingDown) throw new Error("agent control is shutting down");
|
|
764
|
+
if (requests.length === 0) throw new Error("agents must contain at least one task");
|
|
765
|
+
const callerPath = this.callerPath(ctx);
|
|
766
|
+
const callerMessages = this.callerMessages(ctx);
|
|
767
|
+
const settings = loadAgentSettings();
|
|
768
|
+
const seenPaths = new Set<string>();
|
|
769
|
+
const prepared: PreparedSpawn[] = [];
|
|
770
|
+
for (const request of requests) {
|
|
771
|
+
const taskName = normalizeAgentName(request.taskName);
|
|
772
|
+
const childPath = `${callerPath}/${taskName}`;
|
|
773
|
+
if (seenPaths.has(childPath)) throw new Error(`agent path '${childPath}' is duplicated in this batch`);
|
|
774
|
+
if (this.agentsByPath.has(childPath)) {
|
|
775
|
+
throw new Error(`agent path '${childPath}' already exists`);
|
|
776
|
+
}
|
|
777
|
+
if (!request.message.trim()) throw new Error(`message for '${taskName}' must not be empty`);
|
|
778
|
+
seenPaths.add(childPath);
|
|
779
|
+
const role = resolveRole(ctx.cwd, ctx.isProjectTrusted(), request.agentType);
|
|
780
|
+
const selectedModel = await this.resolveModel(
|
|
781
|
+
ctx,
|
|
782
|
+
selectAgentModel(request.model, role.model, settings.defaultModel),
|
|
783
|
+
);
|
|
784
|
+
prepared.push({
|
|
785
|
+
request: { ...request, taskName },
|
|
786
|
+
childPath,
|
|
787
|
+
callerPath,
|
|
788
|
+
role,
|
|
789
|
+
selectedModel,
|
|
790
|
+
thinkingLevel: selectAgentThinkingLevel(
|
|
791
|
+
selectedModel,
|
|
792
|
+
request.model,
|
|
793
|
+
request.thinkingLevel,
|
|
794
|
+
role.thinkingLevel,
|
|
795
|
+
settings.defaultThinkingLevel,
|
|
796
|
+
),
|
|
797
|
+
forkMessages: sanitizeForkMessages(callerMessages, parseForkMode(request.forkTurns)),
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
return prepared;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
private materializeQueuedBatch(ctx: ExtensionContext, prepared: PreparedSpawn[]): AgentRecord[] {
|
|
804
|
+
const records: AgentRecord[] = [];
|
|
805
|
+
const baseTime = Date.now();
|
|
806
|
+
try {
|
|
807
|
+
for (let index = 0; index < prepared.length; index++) {
|
|
808
|
+
const item = prepared[index];
|
|
809
|
+
const sessionManager = SessionManager.create(ctx.cwd, this.childSessionDirectory);
|
|
810
|
+
const now = baseTime;
|
|
811
|
+
const record: AgentRecord = {
|
|
812
|
+
id: sessionManager.getSessionId(),
|
|
813
|
+
path: item.childPath,
|
|
814
|
+
parentPath: item.callerPath,
|
|
815
|
+
parentId: item.callerPath === ROOT_PATH ? this.root!.sessionId : this.agentsByPath.get(item.callerPath)!.id,
|
|
816
|
+
taskName: item.request.taskName,
|
|
817
|
+
nickname: this.reserveNickname(item.role.nicknameCandidates),
|
|
818
|
+
role: item.role.name,
|
|
819
|
+
modelProvider: item.selectedModel.provider,
|
|
820
|
+
modelId: item.selectedModel.id,
|
|
821
|
+
thinkingLevel: item.thinkingLevel,
|
|
822
|
+
status: "queued",
|
|
823
|
+
statusMessage: "waiting for an execution slot",
|
|
824
|
+
createdAt: now,
|
|
825
|
+
updatedAt: now,
|
|
826
|
+
lastUsedAt: now,
|
|
827
|
+
lastAssignedAt: now,
|
|
828
|
+
loaded: false,
|
|
829
|
+
holdsExecutionSlot: false,
|
|
830
|
+
launchGeneration: 0,
|
|
831
|
+
queuedMessage: formatTeamMessage({
|
|
832
|
+
type: "NEW_TASK",
|
|
833
|
+
taskName: item.request.taskName,
|
|
834
|
+
sender: item.callerPath,
|
|
835
|
+
payload: item.request.message,
|
|
836
|
+
}),
|
|
837
|
+
};
|
|
838
|
+
record.sessionFile = sessionManager.getSessionFile();
|
|
839
|
+
records.push(record);
|
|
840
|
+
if (!record.sessionFile) throw new Error(`failed to create persisted session for ${record.path}`);
|
|
841
|
+
sessionManager.appendCustomEntry(CHILD_META_ENTRY_TYPE, {
|
|
842
|
+
path: record.path,
|
|
843
|
+
parentPath: record.parentPath,
|
|
844
|
+
role: record.role,
|
|
845
|
+
});
|
|
846
|
+
sessionManager.appendCustomEntry(FORK_CONTEXT_ENTRY_TYPE, { messages: item.forkMessages });
|
|
847
|
+
}
|
|
848
|
+
} catch (error) {
|
|
849
|
+
for (const record of records) {
|
|
850
|
+
if (record.nickname) this.usedNicknames.delete(record.nickname);
|
|
851
|
+
if (record.sessionFile) {
|
|
852
|
+
try { fs.rmSync(record.sessionFile, { force: true }); } catch { /* best effort */ }
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
throw error;
|
|
856
|
+
}
|
|
857
|
+
for (const record of records) {
|
|
858
|
+
this.agentsByPath.set(record.path, record);
|
|
859
|
+
this.pathBySessionId.set(record.id, record.path);
|
|
860
|
+
}
|
|
861
|
+
this.persistState();
|
|
862
|
+
return records;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
private async startQueued(record: AgentRecord): Promise<void> {
|
|
866
|
+
if (record.status !== "queued") return;
|
|
867
|
+
this.reserveExecutionSlot();
|
|
868
|
+
record.holdsExecutionSlot = true;
|
|
869
|
+
record.status = "pending_init";
|
|
870
|
+
record.statusMessage = "initializing";
|
|
871
|
+
record.updatedAt = Date.now();
|
|
872
|
+
this.persistState();
|
|
873
|
+
try {
|
|
874
|
+
await this.ensureLoaded(record);
|
|
875
|
+
if (this.shuttingDown || this.disposed) {
|
|
876
|
+
record.status = "queued";
|
|
877
|
+
record.statusMessage = "waiting for an execution slot";
|
|
878
|
+
this.releaseExecutionSlot(record);
|
|
879
|
+
this.unload(record);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const content = [...(record.queuedMail ?? []), record.queuedMessage].filter((item): item is string => Boolean(item)).join("\n\n");
|
|
883
|
+
if ((record.queuedMail?.length ?? 0) > 0) this.mailboxPending.set(record.path, 0);
|
|
884
|
+
record.queuedMessage = undefined;
|
|
885
|
+
record.queuedMail = undefined;
|
|
886
|
+
this.launch(record, content);
|
|
887
|
+
this.persistState();
|
|
888
|
+
} catch (error) {
|
|
889
|
+
record.status = "errored";
|
|
890
|
+
record.statusMessage = error instanceof Error ? error.message : String(error);
|
|
891
|
+
record.finalAnswer = record.statusMessage;
|
|
892
|
+
record.queuedMessage = undefined;
|
|
893
|
+
record.queuedMail = undefined;
|
|
894
|
+
record.updatedAt = Date.now();
|
|
895
|
+
this.releaseExecutionSlot(record);
|
|
896
|
+
this.writeAgentResult(record);
|
|
897
|
+
this.persistState();
|
|
898
|
+
void this.deliver(record.path, record.parentPath, this.completionNotice(record), false, "AGENT_STATUS").catch(() => {});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
private async runQueuedScheduler(): Promise<void> {
|
|
903
|
+
while (!this.disposed && !this.shuttingDown && this.activeExecutionSlots < this.maxConcurrentSubagents) {
|
|
904
|
+
const next = this.queuedRecords()[0];
|
|
905
|
+
if (!next) break;
|
|
906
|
+
await this.startQueued(next);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
private async scheduleQueued(): Promise<void> {
|
|
911
|
+
if (this.disposed || this.shuttingDown) return;
|
|
912
|
+
if (this.schedulerPromise) return this.schedulerPromise;
|
|
913
|
+
const operation = this.runQueuedScheduler();
|
|
914
|
+
this.schedulerPromise = operation;
|
|
915
|
+
try {
|
|
916
|
+
await operation;
|
|
917
|
+
} finally {
|
|
918
|
+
if (this.schedulerPromise === operation) this.schedulerPromise = undefined;
|
|
919
|
+
if (!this.disposed && !this.shuttingDown && this.activeExecutionSlots < this.maxConcurrentSubagents && this.queuedRecords().length > 0) {
|
|
920
|
+
queueMicrotask(() => void this.scheduleQueued());
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async spawnMany(ctx: ExtensionContext, requests: SpawnRequest[]): Promise<AgentView[]> {
|
|
926
|
+
return this.enqueueSpawnOperation(async () => {
|
|
927
|
+
const prepared = await this.prepareSpawnBatch(ctx, requests);
|
|
928
|
+
const records = this.materializeQueuedBatch(ctx, prepared);
|
|
929
|
+
await this.scheduleQueued();
|
|
930
|
+
return records.map((record) => this.view(record));
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private async deliver(
|
|
935
|
+
senderPath: string,
|
|
936
|
+
recipientPath: string,
|
|
937
|
+
payload: string,
|
|
938
|
+
triggerTurn: boolean,
|
|
939
|
+
type: "NEW_TASK" | "MESSAGE" | "AGENT_STATUS" = triggerTurn ? "NEW_TASK" : "MESSAGE",
|
|
940
|
+
): Promise<void> {
|
|
941
|
+
const taskPath = type === "AGENT_STATUS" ? senderPath : recipientPath;
|
|
942
|
+
const taskName = taskPath.split("/").filter(Boolean).at(-1) || "root";
|
|
943
|
+
const content = formatTeamMessage({ type, taskName, sender: senderPath, payload });
|
|
944
|
+
if (this.activeTurns.has(recipientPath)) {
|
|
945
|
+
// Never append into a live turn: it would split assistant tool_calls from
|
|
946
|
+
// their tool results and produce protocol-invalid history.
|
|
947
|
+
const queued = this.pendingMail.get(recipientPath) ?? [];
|
|
948
|
+
queued.push(content);
|
|
949
|
+
this.pendingMail.set(recipientPath, queued);
|
|
950
|
+
} else if (recipientPath === ROOT_PATH) {
|
|
951
|
+
this.pi.sendMessage(
|
|
952
|
+
{ customType: EXTENSION_ID, content, display: true, details: { sender: senderPath, recipient: recipientPath, type } },
|
|
953
|
+
{ triggerTurn, deliverAs: "steer" },
|
|
954
|
+
);
|
|
955
|
+
} else {
|
|
956
|
+
const record = this.agentsByPath.get(recipientPath);
|
|
957
|
+
if (!record) throw new Error(`agent ${recipientPath} not found`);
|
|
958
|
+
if (!record.session && (record.status === "queued" || record.status === "pending_init")) {
|
|
959
|
+
record.queuedMail = [...(record.queuedMail ?? []), content];
|
|
960
|
+
record.updatedAt = Date.now();
|
|
961
|
+
this.persistState();
|
|
962
|
+
} else {
|
|
963
|
+
await this.ensureLoaded(record);
|
|
964
|
+
const wasIdle = record.session!.isIdle;
|
|
965
|
+
if (triggerTurn && wasIdle) {
|
|
966
|
+
this.launch(record, content);
|
|
967
|
+
} else {
|
|
968
|
+
await record.session!.sendCustomMessage(
|
|
969
|
+
{ customType: EXTENSION_ID, content, display: true, details: { sender: senderPath, recipient: recipientPath, type } },
|
|
970
|
+
{ triggerTurn, deliverAs: "steer" },
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
record.lastUsedAt = Date.now();
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
this.notifyMailbox(recipientPath);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async message(ctx: ExtensionContext, request: MessageRequest): Promise<AgentView> {
|
|
980
|
+
const callerPath = this.callerPath(ctx);
|
|
981
|
+
if (!request.message.trim()) throw new Error("message must not be empty");
|
|
982
|
+
const target = this.requireTarget(callerPath, request.target);
|
|
983
|
+
if (request.triggerTurn && target.path === ROOT_PATH) {
|
|
984
|
+
throw new Error("follow-up tasks cannot target the root agent");
|
|
985
|
+
}
|
|
986
|
+
await this.deliver(callerPath, target.path, request.message, request.triggerTurn);
|
|
987
|
+
if (request.triggerTurn && target.record) {
|
|
988
|
+
target.record.lastAssignedAt = Date.now();
|
|
989
|
+
target.record.updatedAt = target.record.lastAssignedAt;
|
|
990
|
+
this.persistState();
|
|
991
|
+
}
|
|
992
|
+
return target.path === ROOT_PATH ? this.rootView() : this.view(target.record!);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private notifyMailbox(recipientPath: string): void {
|
|
996
|
+
this.mailboxPending.set(recipientPath, (this.mailboxPending.get(recipientPath) || 0) + 1);
|
|
997
|
+
const waiters = this.waiters.get(recipientPath);
|
|
998
|
+
if (waiters) {
|
|
999
|
+
for (const waiter of [...waiters]) {
|
|
1000
|
+
clearTimeout(waiter.timer);
|
|
1001
|
+
waiter.abort?.();
|
|
1002
|
+
waiter.resolve("mailbox");
|
|
1003
|
+
}
|
|
1004
|
+
waiters.clear();
|
|
1005
|
+
}
|
|
1006
|
+
this.changed();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async waitForMailbox(ctx: ExtensionContext, timeoutMs?: number, signal?: AbortSignal): Promise<{ timedOut: boolean; aborted: boolean }> {
|
|
1010
|
+
const callerPath = this.callerPath(ctx);
|
|
1011
|
+
if ((this.pendingMail.get(callerPath)?.length || 0) > 0 || (this.mailboxPending.get(callerPath) || 0) > 0) {
|
|
1012
|
+
this.mailboxPending.set(callerPath, 0);
|
|
1013
|
+
return { timedOut: false, aborted: false };
|
|
1014
|
+
}
|
|
1015
|
+
const duration = Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS));
|
|
1016
|
+
const outcome = await new Promise<"mailbox" | "timeout" | "aborted">((resolve) => {
|
|
1017
|
+
const waiter: StateWaiter = {
|
|
1018
|
+
resolve,
|
|
1019
|
+
timer: setTimeout(() => {
|
|
1020
|
+
this.waiters.get(callerPath)?.delete(waiter);
|
|
1021
|
+
resolve("timeout");
|
|
1022
|
+
}, duration),
|
|
1023
|
+
};
|
|
1024
|
+
if (signal) {
|
|
1025
|
+
const onAbort = () => {
|
|
1026
|
+
clearTimeout(waiter.timer);
|
|
1027
|
+
this.waiters.get(callerPath)?.delete(waiter);
|
|
1028
|
+
resolve("aborted");
|
|
1029
|
+
};
|
|
1030
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1031
|
+
waiter.abort = () => signal.removeEventListener("abort", onAbort);
|
|
1032
|
+
}
|
|
1033
|
+
const set = this.waiters.get(callerPath) || new Set<StateWaiter>();
|
|
1034
|
+
set.add(waiter);
|
|
1035
|
+
this.waiters.set(callerPath, set);
|
|
1036
|
+
});
|
|
1037
|
+
if (outcome === "mailbox") this.mailboxPending.set(callerPath, 0);
|
|
1038
|
+
return { timedOut: outcome === "timeout", aborted: outcome === "aborted" };
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async interrupt(ctx: ExtensionContext, reference: string): Promise<AgentView> {
|
|
1042
|
+
const callerPath = this.callerPath(ctx);
|
|
1043
|
+
const target = this.requireTarget(callerPath, reference);
|
|
1044
|
+
if (target.path === ROOT_PATH) throw new Error("root is not a spawned agent");
|
|
1045
|
+
if (target.path === callerPath) throw new Error("an agent cannot interrupt itself");
|
|
1046
|
+
const record = target.record!;
|
|
1047
|
+
if (record.status === "queued") {
|
|
1048
|
+
record.status = "interrupted";
|
|
1049
|
+
record.statusMessage = "cancelled while waiting for an execution slot";
|
|
1050
|
+
record.queuedMessage = undefined;
|
|
1051
|
+
record.queuedMail = undefined;
|
|
1052
|
+
record.updatedAt = Date.now();
|
|
1053
|
+
this.persistState();
|
|
1054
|
+
return this.view(record);
|
|
1055
|
+
}
|
|
1056
|
+
await this.ensureLoaded(record);
|
|
1057
|
+
await record.session!.abort();
|
|
1058
|
+
record.status = "interrupted";
|
|
1059
|
+
record.statusMessage = "interrupted";
|
|
1060
|
+
record.updatedAt = Date.now();
|
|
1061
|
+
this.releaseExecutionSlot(record);
|
|
1062
|
+
this.persistState();
|
|
1063
|
+
void this.scheduleQueued();
|
|
1064
|
+
return this.view(record);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
list(ctx: ExtensionContext, prefix?: string, includeResults = false): AgentView[] {
|
|
1068
|
+
const callerPath = this.callerPath(ctx);
|
|
1069
|
+
const resolvedPrefix = prefix?.trim() ? this.resolveReference(callerPath, prefix) : undefined;
|
|
1070
|
+
const views = [this.rootView(), ...[...this.agentsByPath.values()].map((record) => this.view(record, includeResults))];
|
|
1071
|
+
return views
|
|
1072
|
+
.filter((agent) => !resolvedPrefix || agent.path === resolvedPrefix || agent.path.startsWith(`${resolvedPrefix}/`))
|
|
1073
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
transcript(ctx: ExtensionContext, reference: string): AgentTranscriptView {
|
|
1077
|
+
const callerPath = this.callerPath(ctx);
|
|
1078
|
+
const target = this.requireTarget(callerPath, reference);
|
|
1079
|
+
if (target.path === ROOT_PATH) throw new Error("the root transcript is already visible in the main session");
|
|
1080
|
+
const record = target.record!;
|
|
1081
|
+
if (!record.sessionFile) throw new Error(`agent ${record.path} has no persisted session file`);
|
|
1082
|
+
|
|
1083
|
+
const sessionManager = SessionManager.open(record.sessionFile);
|
|
1084
|
+
const forkContextLength = this.forkContextFromSessionManager(sessionManager).length;
|
|
1085
|
+
const messages = record.session
|
|
1086
|
+
? structuredClone(record.session.messages.slice(forkContextLength))
|
|
1087
|
+
: structuredClone(sessionManager.buildSessionContext().messages);
|
|
1088
|
+
const toolNames = new Set<string>();
|
|
1089
|
+
for (const message of messages) {
|
|
1090
|
+
if (message.role !== "assistant") continue;
|
|
1091
|
+
for (const block of message.content) {
|
|
1092
|
+
if (block.type === "toolCall") toolNames.add(block.name);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
const toolDefinitions: ToolDefinition[] = [];
|
|
1096
|
+
for (const name of toolNames) {
|
|
1097
|
+
const liveDefinition = record.session?.getToolDefinition(name);
|
|
1098
|
+
if (liveDefinition) this.transcriptToolDefinitions.set(name, liveDefinition);
|
|
1099
|
+
const definition = liveDefinition ?? this.transcriptToolDefinitions.get(name);
|
|
1100
|
+
if (definition) toolDefinitions.push(definition);
|
|
1101
|
+
}
|
|
1102
|
+
return {
|
|
1103
|
+
agent: this.view(record),
|
|
1104
|
+
sessionFile: record.sessionFile,
|
|
1105
|
+
cwd: sessionManager.getCwd(),
|
|
1106
|
+
messages,
|
|
1107
|
+
toolDefinitions,
|
|
1108
|
+
createdAt: record.createdAt,
|
|
1109
|
+
updatedAt: record.updatedAt,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
listRoles(ctx: ExtensionContext): AgentRoleView[] {
|
|
1114
|
+
this.callerPath(ctx);
|
|
1115
|
+
const settings = loadAgentSettings();
|
|
1116
|
+
return discoverRoles(ctx.cwd, ctx.isProjectTrusted()).map((role) => ({
|
|
1117
|
+
name: role.name,
|
|
1118
|
+
description: role.description,
|
|
1119
|
+
model: role.model || settings.defaultModel,
|
|
1120
|
+
thinkingLevel: role.thinkingLevel ?? settings.defaultThinkingLevel ?? DEFAULT_CHILD_THINKING_LEVEL,
|
|
1121
|
+
tools: role.tools,
|
|
1122
|
+
source: role.source,
|
|
1123
|
+
}));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
private rootView(): AgentView {
|
|
1128
|
+
return {
|
|
1129
|
+
id: this.root?.sessionId || "root",
|
|
1130
|
+
path: ROOT_PATH,
|
|
1131
|
+
model: this.root?.model ? `${this.root.model.provider}/${this.root.model.id}` : "unknown",
|
|
1132
|
+
thinkingLevel: this.root?.thinkingLevel,
|
|
1133
|
+
status: this.rootStatus,
|
|
1134
|
+
statusMessage: this.rootStatusMessage,
|
|
1135
|
+
loaded: true,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
private queuedRecords(): AgentRecord[] {
|
|
1140
|
+
return [...this.agentsByPath.values()]
|
|
1141
|
+
.filter((record) => record.status === "queued")
|
|
1142
|
+
.sort((left, right) => left.createdAt - right.createdAt);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private queuePosition(record: AgentRecord): number | undefined {
|
|
1146
|
+
if (record.status !== "queued") return undefined;
|
|
1147
|
+
const index = this.queuedRecords().findIndex((candidate) => candidate.path === record.path);
|
|
1148
|
+
return index >= 0 ? index + 1 : undefined;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
view(record: AgentRecord, includeResult = false): AgentView {
|
|
1152
|
+
let storedAnswer = includeResult ? record.finalAnswer : undefined;
|
|
1153
|
+
if (includeResult && !storedAnswer && record.resultFile) {
|
|
1154
|
+
try { storedAnswer = fs.readFileSync(record.resultFile, "utf8"); } catch { /* result may have been cleaned up */ }
|
|
1155
|
+
}
|
|
1156
|
+
return {
|
|
1157
|
+
id: record.id,
|
|
1158
|
+
path: record.path,
|
|
1159
|
+
parentPath: record.parentPath,
|
|
1160
|
+
nickname: record.nickname,
|
|
1161
|
+
role: record.role,
|
|
1162
|
+
model: `${record.modelProvider}/${record.modelId}`,
|
|
1163
|
+
thinkingLevel: record.thinkingLevel,
|
|
1164
|
+
status: record.status,
|
|
1165
|
+
statusMessage: record.statusMessage,
|
|
1166
|
+
loaded: record.loaded,
|
|
1167
|
+
resultFile: includeResult ? record.resultFile : undefined,
|
|
1168
|
+
finalAnswer: storedAnswer,
|
|
1169
|
+
queuePosition: this.queuePosition(record),
|
|
1170
|
+
lastAssignedAt: record.lastAssignedAt ?? record.createdAt,
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
private persistState(): void {
|
|
1175
|
+
if (!this.root || this.disposed) return;
|
|
1176
|
+
const state: PersistedTreeState = {
|
|
1177
|
+
version: 1,
|
|
1178
|
+
rootSessionId: this.root.sessionId,
|
|
1179
|
+
agents: [...this.agentsByPath.values()].map(clonePersisted),
|
|
1180
|
+
};
|
|
1181
|
+
try {
|
|
1182
|
+
this.pi.appendEntry(STATE_ENTRY_TYPE, state);
|
|
1183
|
+
} catch {
|
|
1184
|
+
// The root extension may already be stale during shutdown/reload.
|
|
1185
|
+
}
|
|
1186
|
+
this.changed();
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
private restoreState(ctx: ExtensionContext): void {
|
|
1190
|
+
for (const record of this.agentsByPath.values()) this.unload(record);
|
|
1191
|
+
this.agentsByPath.clear();
|
|
1192
|
+
this.pathBySessionId.clear();
|
|
1193
|
+
this.pathBySessionId.set(ctx.sessionManager.getSessionId(), ROOT_PATH);
|
|
1194
|
+
this.activeExecutionSlots = 0;
|
|
1195
|
+
this.usedNicknames.clear();
|
|
1196
|
+
let latest: PersistedTreeState | undefined;
|
|
1197
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
1198
|
+
if (entry.type === "custom" && entry.customType === STATE_ENTRY_TYPE && isPersistedState(entry.data)) latest = entry.data;
|
|
1199
|
+
}
|
|
1200
|
+
if (!latest || latest.rootSessionId !== ctx.sessionManager.getSessionId()) return;
|
|
1201
|
+
let migratedAnyFile = false;
|
|
1202
|
+
for (const persisted of latest.agents) {
|
|
1203
|
+
const status: AgentLifecycleStatus = persisted.status === "queued" || (persisted.status === "pending_init" && Boolean(persisted.queuedMessage))
|
|
1204
|
+
? "queued"
|
|
1205
|
+
: persisted.status === "running" || persisted.status === "pending_init"
|
|
1206
|
+
? "interrupted"
|
|
1207
|
+
: persisted.status;
|
|
1208
|
+
const migratedSession = this.migrateStoredFile(persisted.sessionFile, this.childSessionDirectory);
|
|
1209
|
+
const migratedResult = this.migrateStoredFile(persisted.resultFile, this.agentResultDirectory);
|
|
1210
|
+
migratedAnyFile ||= migratedSession.migrated || migratedResult.migrated;
|
|
1211
|
+
const record: AgentRecord = {
|
|
1212
|
+
...persisted,
|
|
1213
|
+
sessionFile: migratedSession.path,
|
|
1214
|
+
resultFile: migratedResult.path,
|
|
1215
|
+
status,
|
|
1216
|
+
statusMessage: status === "queued"
|
|
1217
|
+
? "waiting for an execution slot"
|
|
1218
|
+
: status === "interrupted" && persisted.status === "running"
|
|
1219
|
+
? "interrupted by previous session shutdown"
|
|
1220
|
+
: persisted.statusMessage,
|
|
1221
|
+
loaded: false,
|
|
1222
|
+
holdsExecutionSlot: false,
|
|
1223
|
+
launchGeneration: 0,
|
|
1224
|
+
};
|
|
1225
|
+
this.agentsByPath.set(record.path, record);
|
|
1226
|
+
this.pathBySessionId.set(record.id, record.path);
|
|
1227
|
+
if (record.nickname) this.usedNicknames.add(record.nickname);
|
|
1228
|
+
}
|
|
1229
|
+
if (migratedAnyFile) this.persistState();
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private forkContextFromSessionManager(sessionManager: SessionManager): AgentMessage[] {
|
|
1233
|
+
const hasCompaction = sessionManager.getEntries().some((entry) => entry.type === "compaction");
|
|
1234
|
+
if (hasCompaction) return [];
|
|
1235
|
+
for (const entry of sessionManager.getEntries()) {
|
|
1236
|
+
if (entry.type !== "custom" || entry.customType !== FORK_CONTEXT_ENTRY_TYPE) continue;
|
|
1237
|
+
const payload = entry.data as ForkContextPayload | undefined;
|
|
1238
|
+
if (payload?.messages && Array.isArray(payload.messages)) return structuredClone(payload.messages);
|
|
1239
|
+
}
|
|
1240
|
+
return [];
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
private async ensureLoaded(record: AgentRecord): Promise<void> {
|
|
1244
|
+
if (record.session) {
|
|
1245
|
+
record.loaded = true;
|
|
1246
|
+
record.lastUsedAt = Date.now();
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (!record.sessionFile) throw new Error(`agent ${record.path} has no persisted session file`);
|
|
1250
|
+
if (!this.root) throw new Error("root session is not bound");
|
|
1251
|
+
await this.evictForResidency(record.path);
|
|
1252
|
+
const sessionManager = SessionManager.open(record.sessionFile);
|
|
1253
|
+
const forkContext = this.forkContextFromSessionManager(sessionManager);
|
|
1254
|
+
const role = resolveRole(this.root.cwd, this.root.ctx.isProjectTrusted(), record.role);
|
|
1255
|
+
const settingsManager = SettingsManager.create(this.root.cwd, getAgentDir());
|
|
1256
|
+
const loader = await this.createLoader(this.root.cwd, settingsManager, this.childInstructions(record, role.systemPrompt));
|
|
1257
|
+
const runtime = await this.getModelRuntime(this.root.ctx);
|
|
1258
|
+
const model = runtime.getModel(record.modelProvider, record.modelId) || this.root.model;
|
|
1259
|
+
if (!model) throw new Error(`model ${record.modelProvider}/${record.modelId} is unavailable`);
|
|
1260
|
+
const allowedTools = role.tools ? [...new Set([...role.tools, ...COLLABORATION_TOOL_NAMES])] : undefined;
|
|
1261
|
+
const { session } = await createAgentSession({
|
|
1262
|
+
cwd: this.root.cwd,
|
|
1263
|
+
agentDir: getAgentDir(),
|
|
1264
|
+
modelRuntime: runtime,
|
|
1265
|
+
model,
|
|
1266
|
+
thinkingLevel: record.thinkingLevel,
|
|
1267
|
+
tools: allowedTools,
|
|
1268
|
+
customTools: this.tools,
|
|
1269
|
+
resourceLoader: loader,
|
|
1270
|
+
sessionManager,
|
|
1271
|
+
settingsManager,
|
|
1272
|
+
});
|
|
1273
|
+
this.configureChildTools(session);
|
|
1274
|
+
if (forkContext.length > 0) session.agent.state.messages = [...forkContext, ...session.messages];
|
|
1275
|
+
record.session = session;
|
|
1276
|
+
this.attachRootUi(record, session);
|
|
1277
|
+
record.loaded = true;
|
|
1278
|
+
record.lastUsedAt = Date.now();
|
|
1279
|
+
this.pathBySessionId.set(session.sessionId, record.path);
|
|
1280
|
+
if (session.sessionId !== record.id) {
|
|
1281
|
+
this.pathBySessionId.delete(record.id);
|
|
1282
|
+
record.id = session.sessionId;
|
|
1283
|
+
}
|
|
1284
|
+
this.subscribe(record);
|
|
1285
|
+
this.changed();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async shutdown(): Promise<void> {
|
|
1289
|
+
this.shuttingDown = true;
|
|
1290
|
+
if (this.schedulerPromise) {
|
|
1291
|
+
try { await this.schedulerPromise; } catch { /* scheduler failures are recorded per agent */ }
|
|
1292
|
+
}
|
|
1293
|
+
for (const waiters of this.waiters.values()) {
|
|
1294
|
+
for (const waiter of waiters) {
|
|
1295
|
+
clearTimeout(waiter.timer);
|
|
1296
|
+
waiter.abort?.();
|
|
1297
|
+
waiter.resolve("aborted");
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
this.waiters.clear();
|
|
1301
|
+
this.activeTurns.clear();
|
|
1302
|
+
this.pendingMail.clear();
|
|
1303
|
+
for (const record of this.agentsByPath.values()) {
|
|
1304
|
+
if (record.session?.isStreaming) {
|
|
1305
|
+
try { await record.session.abort(); } catch { /* best effort */ }
|
|
1306
|
+
record.status = "interrupted";
|
|
1307
|
+
record.statusMessage = "root session shut down";
|
|
1308
|
+
}
|
|
1309
|
+
this.releaseExecutionSlot(record);
|
|
1310
|
+
}
|
|
1311
|
+
this.persistState();
|
|
1312
|
+
this.disposed = true;
|
|
1313
|
+
for (const record of this.agentsByPath.values()) this.unload(record);
|
|
1314
|
+
this.changed();
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
getRootInstructions(): string {
|
|
1318
|
+
return rootAgentInstructions();
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
getCounts(): AgentCounts {
|
|
1322
|
+
const records = [...this.agentsByPath.values()];
|
|
1323
|
+
return {
|
|
1324
|
+
running: records.filter((record) => record.status === "running" || record.status === "pending_init").length,
|
|
1325
|
+
queued: records.filter((record) => record.status === "queued").length,
|
|
1326
|
+
loaded: records.filter((record) => record.loaded).length,
|
|
1327
|
+
total: records.length,
|
|
1328
|
+
slots: this.maxConcurrentSubagents,
|
|
1329
|
+
residentSlots: this.maxResidentSubagents,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
}
|