pi-subagents-lite 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/package.json +52 -0
- package/src/agent-discovery.ts +412 -0
- package/src/agent-manager.ts +545 -0
- package/src/agent-runner.ts +435 -0
- package/src/agent-types.ts +140 -0
- package/src/context.ts +13 -0
- package/src/default-agents.ts +67 -0
- package/src/index.ts +1356 -0
- package/src/model-precedence.ts +71 -0
- package/src/model-selector.ts +271 -0
- package/src/output-file.ts +176 -0
- package/src/prompts.ts +61 -0
- package/src/result-viewer.ts +218 -0
- package/src/skill-loader.ts +104 -0
- package/src/types.ts +96 -0
- package/src/ui/agent-widget.ts +666 -0
- package/src/usage.ts +39 -0
- package/src/utils.ts +40 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-manager.ts — Tracks agents, per-model concurrency, background execution.
|
|
3
|
+
*
|
|
4
|
+
* Forked from upstream pi-subagents. Key modifications:
|
|
5
|
+
* - Per-model concurrency (Map<string, { limit, running }>) replaces
|
|
6
|
+
* single maxConcurrent counter
|
|
7
|
+
* - No worktree isolation (all worktree imports and code paths removed)
|
|
8
|
+
* - Exposes steer(id, message) for /steer command
|
|
9
|
+
* - SpawnOptions.modelKey for concurrency pool lookup
|
|
10
|
+
* - ConcurrencyConfig with default + models map
|
|
11
|
+
* - No IsolationMode references
|
|
12
|
+
* - AgentRecord: removed worktree, worktreeResult, groupId, joinMode
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
17
|
+
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
19
|
+
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
20
|
+
import type { AgentInvocation, AgentRecord, SubagentType, ThinkingLevel } from "./types.js";
|
|
21
|
+
import { addUsage, getLifetimeTotal } from "./usage.js";
|
|
22
|
+
|
|
23
|
+
/** Safely extract a human-readable error message from an unknown exception. */
|
|
24
|
+
function errorMessage(err: unknown): string {
|
|
25
|
+
return err instanceof Error ? err.message : String(err);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a cleanup function for the output file stream.
|
|
30
|
+
* Captures final stats from the record at cleanup time so the DONE line
|
|
31
|
+
* reflects actual turn count, tool uses, and total tokens.
|
|
32
|
+
*/
|
|
33
|
+
function createOutputCleanup(
|
|
34
|
+
session: AgentSession,
|
|
35
|
+
path: string,
|
|
36
|
+
record: AgentRecord,
|
|
37
|
+
): () => void {
|
|
38
|
+
const outputStats = { turnCount: 0, toolUseCount: 0, totalTokens: 0 };
|
|
39
|
+
const cleanup = streamToOutputFile(session, path, outputStats);
|
|
40
|
+
return () => {
|
|
41
|
+
outputStats.turnCount = record.turnCount ?? 0;
|
|
42
|
+
outputStats.toolUseCount = record.toolUses;
|
|
43
|
+
outputStats.totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
44
|
+
cleanup();
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether the agent status is terminal (no longer running or queued). */
|
|
49
|
+
function isTerminalStatus(status: AgentRecord["status"]): boolean {
|
|
50
|
+
return status !== "running" && status !== "queued";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Configuration for per-model concurrency limits. */
|
|
54
|
+
export interface ConcurrencyConfig {
|
|
55
|
+
/** Default concurrency limit for models not in the models or providers map. */
|
|
56
|
+
default: number;
|
|
57
|
+
/** Per-provider concurrency limits keyed by provider name (e.g. "llamacpp"). */
|
|
58
|
+
providers?: Record<string, number>;
|
|
59
|
+
/** Per-model concurrency limits keyed by "provider/modelId". */
|
|
60
|
+
models: Record<string, number>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type OnAgentComplete = (record: AgentRecord) => void;
|
|
64
|
+
type OnAgentStart = (record: AgentRecord) => void;
|
|
65
|
+
|
|
66
|
+
/** Internal per-model concurrency state. */
|
|
67
|
+
interface ConcurrencySlot {
|
|
68
|
+
limit: number;
|
|
69
|
+
running: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface SpawnArgs {
|
|
73
|
+
pi: ExtensionAPI;
|
|
74
|
+
ctx: ExtensionContext;
|
|
75
|
+
type: SubagentType;
|
|
76
|
+
prompt: string;
|
|
77
|
+
options: SpawnOptions;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SpawnOptions {
|
|
81
|
+
description: string;
|
|
82
|
+
model?: Model<any>;
|
|
83
|
+
maxTurns?: number;
|
|
84
|
+
isolated?: boolean;
|
|
85
|
+
thinkingLevel?: ThinkingLevel;
|
|
86
|
+
isBackground?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Model key for concurrency pool lookup (e.g. "llamacpp/4b_small").
|
|
89
|
+
* When set, the agent is counted against that model's concurrency limit.
|
|
90
|
+
* When unset, the agent bypasses per-model concurrency limits.
|
|
91
|
+
*/
|
|
92
|
+
modelKey?: string;
|
|
93
|
+
/** Resolved invocation snapshot captured for UI display. */
|
|
94
|
+
invocation?: AgentInvocation;
|
|
95
|
+
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
96
|
+
signal?: AbortSignal;
|
|
97
|
+
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
98
|
+
onToolActivity?: (activity: ToolActivity) => void;
|
|
99
|
+
/** Called on streaming text deltas from the assistant response. */
|
|
100
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
101
|
+
/** Called when the agent session is created (for accessing session stats). */
|
|
102
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
103
|
+
/** Called at the end of each agentic turn with the cumulative count. */
|
|
104
|
+
onTurnEnd?: (turnCount: number) => void;
|
|
105
|
+
/** Called once per assistant message_end with that message's usage delta. */
|
|
106
|
+
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
107
|
+
/** Called when the session successfully compacts. */
|
|
108
|
+
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class AgentManager {
|
|
112
|
+
private agents = new Map<string, AgentRecord>();
|
|
113
|
+
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
114
|
+
private onComplete?: OnAgentComplete;
|
|
115
|
+
private onStart?: OnAgentStart;
|
|
116
|
+
|
|
117
|
+
/** Per-model concurrency slots keyed by "provider/modelId". */
|
|
118
|
+
private concurrencySlots = new Map<string, ConcurrencySlot>();
|
|
119
|
+
|
|
120
|
+
/** Per-provider concurrency slots — shared pool for all models from a provider. */
|
|
121
|
+
private providerSlots = new Map<string, ConcurrencySlot>();
|
|
122
|
+
|
|
123
|
+
/** Default concurrency limit for models not in the slots map. */
|
|
124
|
+
private defaultConcurrency: number;
|
|
125
|
+
|
|
126
|
+
/** Queue of agents waiting to start, keyed by modelKey. */
|
|
127
|
+
private queue: { id: string; modelKey: string; args: SpawnArgs }[] = [];
|
|
128
|
+
|
|
129
|
+
constructor(
|
|
130
|
+
onComplete?: OnAgentComplete,
|
|
131
|
+
concurrency?: ConcurrencyConfig,
|
|
132
|
+
onStart?: OnAgentStart,
|
|
133
|
+
) {
|
|
134
|
+
this.onComplete = onComplete;
|
|
135
|
+
this.onStart = onStart;
|
|
136
|
+
this.defaultConcurrency = concurrency?.default ?? 4;
|
|
137
|
+
|
|
138
|
+
// Initialize per-provider slots from config (shared pool)
|
|
139
|
+
if (concurrency?.providers) {
|
|
140
|
+
for (const [provider, limit] of Object.entries(concurrency.providers)) {
|
|
141
|
+
this.providerSlots.set(provider, { limit: Math.max(1, limit), running: 0 });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Initialize per-model slots from config
|
|
146
|
+
if (concurrency?.models) {
|
|
147
|
+
for (const [modelKey, limit] of Object.entries(concurrency.models)) {
|
|
148
|
+
this.concurrencySlots.set(modelKey, { limit: Math.max(1, limit), running: 0 });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
153
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
154
|
+
this.cleanupInterval.unref();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Update the concurrency configuration.
|
|
159
|
+
* Existing slots are updated; new slots are created; removed slots stay
|
|
160
|
+
* (their running count will drain naturally). The queue is drained after
|
|
161
|
+
* update so newly expanded limits take effect immediately.
|
|
162
|
+
*/
|
|
163
|
+
setConcurrency(config: ConcurrencyConfig): void {
|
|
164
|
+
this.defaultConcurrency = config.default;
|
|
165
|
+
|
|
166
|
+
// Update per-provider slots (shared pool)
|
|
167
|
+
if (config.providers) {
|
|
168
|
+
for (const [provider, limit] of Object.entries(config.providers)) {
|
|
169
|
+
const existing = this.providerSlots.get(provider);
|
|
170
|
+
if (existing) {
|
|
171
|
+
existing.limit = Math.max(1, limit);
|
|
172
|
+
} else {
|
|
173
|
+
this.providerSlots.set(provider, { limit: Math.max(1, limit), running: 0 });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Update existing slots and create new ones
|
|
179
|
+
for (const [modelKey, limit] of Object.entries(config.models)) {
|
|
180
|
+
const existing = this.concurrencySlots.get(modelKey);
|
|
181
|
+
if (existing) {
|
|
182
|
+
existing.limit = Math.max(1, limit);
|
|
183
|
+
} else {
|
|
184
|
+
this.concurrencySlots.set(modelKey, { limit: Math.max(1, limit), running: 0 });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Start queued agents if the new limits allow
|
|
189
|
+
this.drainQueue();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get or create a concurrency slot for a model key.
|
|
194
|
+
* Precedence: per-model slot > per-provider shared slot > default (per-model).
|
|
195
|
+
* Returns { slot, isProviderSlot } so caller knows which slot to decrement.
|
|
196
|
+
*/
|
|
197
|
+
private getSlot(modelKey: string): { slot: ConcurrencySlot; isProviderSlot: boolean } {
|
|
198
|
+
// 1. Check per-model slot
|
|
199
|
+
let slot = this.concurrencySlots.get(modelKey);
|
|
200
|
+
if (slot) return { slot, isProviderSlot: false };
|
|
201
|
+
|
|
202
|
+
// 2. Check per-provider shared slot
|
|
203
|
+
const provider = modelKey.split("/")[0];
|
|
204
|
+
const providerSlot = this.providerSlots.get(provider);
|
|
205
|
+
if (providerSlot) return { slot: providerSlot, isProviderSlot: true };
|
|
206
|
+
|
|
207
|
+
// 3. Create per-model slot with default limit
|
|
208
|
+
slot = { limit: Math.max(1, this.defaultConcurrency), running: 0 };
|
|
209
|
+
this.concurrencySlots.set(modelKey, slot);
|
|
210
|
+
return { slot, isProviderSlot: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Spawn an agent and return its ID immediately (for background use).
|
|
215
|
+
* If the per-model concurrency limit is reached, the agent is queued.
|
|
216
|
+
*/
|
|
217
|
+
spawn(
|
|
218
|
+
pi: ExtensionAPI,
|
|
219
|
+
ctx: ExtensionContext,
|
|
220
|
+
type: SubagentType,
|
|
221
|
+
prompt: string,
|
|
222
|
+
options: SpawnOptions,
|
|
223
|
+
): string {
|
|
224
|
+
const id = randomUUID().slice(0, 17);
|
|
225
|
+
const abortController = new AbortController();
|
|
226
|
+
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
227
|
+
|
|
228
|
+
// Check concurrency — applies to both foreground and background agents
|
|
229
|
+
let queued = false;
|
|
230
|
+
let concurrencySlot: ConcurrencySlot | undefined;
|
|
231
|
+
if (options.modelKey) {
|
|
232
|
+
const { slot } = this.getSlot(options.modelKey);
|
|
233
|
+
if (slot.running >= slot.limit) {
|
|
234
|
+
queued = true;
|
|
235
|
+
this.queue.push({ id, modelKey: options.modelKey, args });
|
|
236
|
+
} else {
|
|
237
|
+
concurrencySlot = slot;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const record: AgentRecord = {
|
|
242
|
+
id,
|
|
243
|
+
type,
|
|
244
|
+
description: options.description,
|
|
245
|
+
status: queued ? "queued" : "running",
|
|
246
|
+
toolUses: 0,
|
|
247
|
+
startedAt: Date.now(),
|
|
248
|
+
abortController,
|
|
249
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
250
|
+
compactionCount: 0,
|
|
251
|
+
invocation: options.invocation,
|
|
252
|
+
maxTurns: options.maxTurns,
|
|
253
|
+
};
|
|
254
|
+
this.agents.set(id, record);
|
|
255
|
+
|
|
256
|
+
if (queued) return id;
|
|
257
|
+
|
|
258
|
+
// startAgent can throw — clean up record so callers don't see an orphan
|
|
259
|
+
try {
|
|
260
|
+
this.startAgent(id, record, args, concurrencySlot);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
this.agents.delete(id);
|
|
263
|
+
throw err;
|
|
264
|
+
}
|
|
265
|
+
return id;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Actually start an agent (called immediately or from queue drain).
|
|
270
|
+
* When concurrencySlot is provided, the slot's running count is managed
|
|
271
|
+
* (incremented on start, decremented in finally).
|
|
272
|
+
*/
|
|
273
|
+
private startAgent(
|
|
274
|
+
id: string,
|
|
275
|
+
record: AgentRecord,
|
|
276
|
+
{ pi, ctx, type, prompt, options }: SpawnArgs,
|
|
277
|
+
concurrencySlot?: ConcurrencySlot,
|
|
278
|
+
) {
|
|
279
|
+
if (concurrencySlot) concurrencySlot.running++;
|
|
280
|
+
|
|
281
|
+
record.status = "running";
|
|
282
|
+
record.startedAt = Date.now();
|
|
283
|
+
|
|
284
|
+
// Create output file for this agent
|
|
285
|
+
record.outputFile = createOutputFilePath(id);
|
|
286
|
+
writeInitialEntry(record.outputFile, prompt);
|
|
287
|
+
|
|
288
|
+
this.onStart?.(record);
|
|
289
|
+
|
|
290
|
+
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
291
|
+
if (options.signal) {
|
|
292
|
+
options.signal.addEventListener("abort", () => this.abort(id), { once: true });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const promise = runAgent(ctx, type, prompt, {
|
|
296
|
+
pi,
|
|
297
|
+
agentId: id,
|
|
298
|
+
model: options.model,
|
|
299
|
+
maxTurns: options.maxTurns,
|
|
300
|
+
isolated: options.isolated,
|
|
301
|
+
thinkingLevel: options.thinkingLevel,
|
|
302
|
+
signal: record.abortController!.signal,
|
|
303
|
+
onToolActivity: (activity) => {
|
|
304
|
+
if (activity.type === "end") record.toolUses++;
|
|
305
|
+
options.onToolActivity?.(activity);
|
|
306
|
+
},
|
|
307
|
+
onTurnEnd: (turnCount) => {
|
|
308
|
+
record.turnCount = turnCount;
|
|
309
|
+
options.onTurnEnd?.(turnCount);
|
|
310
|
+
},
|
|
311
|
+
onTextDelta: options.onTextDelta,
|
|
312
|
+
onAssistantUsage: (usage) => {
|
|
313
|
+
addUsage(record.lifetimeUsage, usage);
|
|
314
|
+
options.onAssistantUsage?.(usage);
|
|
315
|
+
},
|
|
316
|
+
onCompaction: (info) => {
|
|
317
|
+
record.compactionCount++;
|
|
318
|
+
options.onCompaction?.(info);
|
|
319
|
+
},
|
|
320
|
+
onSessionCreated: (session) => {
|
|
321
|
+
record.session = session;
|
|
322
|
+
// Flush any steers that arrived before the session was ready
|
|
323
|
+
if (record.pendingSteers?.length) {
|
|
324
|
+
for (const msg of record.pendingSteers) {
|
|
325
|
+
session.steer(msg).catch(() => {});
|
|
326
|
+
}
|
|
327
|
+
record.pendingSteers = undefined;
|
|
328
|
+
}
|
|
329
|
+
// Stream session events to the output file
|
|
330
|
+
if (record.outputFile) {
|
|
331
|
+
record.outputCleanup = createOutputCleanup(
|
|
332
|
+
session, record.outputFile, record,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
options.onSessionCreated?.(session);
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
.then(({ responseText, session, aborted, steered }) => {
|
|
339
|
+
// Don't overwrite status if externally stopped via abort()
|
|
340
|
+
if (record.status !== "stopped") {
|
|
341
|
+
record.status = aborted ? "aborted" : steered ? "steered" : "completed";
|
|
342
|
+
}
|
|
343
|
+
record.result = responseText;
|
|
344
|
+
record.session = session;
|
|
345
|
+
record.completedAt ??= Date.now();
|
|
346
|
+
return responseText;
|
|
347
|
+
})
|
|
348
|
+
.catch((err) => {
|
|
349
|
+
// Don't overwrite status if externally stopped via abort()
|
|
350
|
+
if (record.status !== "stopped") {
|
|
351
|
+
record.status = "error";
|
|
352
|
+
}
|
|
353
|
+
record.error = errorMessage(err);
|
|
354
|
+
record.completedAt ??= Date.now();
|
|
355
|
+
return "";
|
|
356
|
+
})
|
|
357
|
+
.finally(() => {
|
|
358
|
+
// Final flush of streaming output file
|
|
359
|
+
if (record.outputCleanup) {
|
|
360
|
+
try { record.outputCleanup(); } catch { /* ignore */ }
|
|
361
|
+
record.outputCleanup = undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Decrement per-model concurrency count
|
|
365
|
+
if (concurrencySlot) concurrencySlot.running--;
|
|
366
|
+
|
|
367
|
+
try { this.onComplete?.(record); } catch { /* ignore */ }
|
|
368
|
+
this.drainQueue();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
record.promise = promise;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Start queued agents up to the per-model concurrency limits. */
|
|
375
|
+
private drainQueue() {
|
|
376
|
+
const started = new Set<string>();
|
|
377
|
+
for (const entry of this.queue) {
|
|
378
|
+
const record = this.agents.get(entry.id);
|
|
379
|
+
if (!record || record.status !== "queued") continue;
|
|
380
|
+
|
|
381
|
+
const slot = this.getSlot(entry.modelKey);
|
|
382
|
+
if (slot.running >= slot.limit) continue;
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
this.startAgent(entry.id, record, entry.args, slot);
|
|
386
|
+
started.add(entry.id);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
// Late failure — surface on the record so the user can see it
|
|
389
|
+
record.status = "error";
|
|
390
|
+
record.error = errorMessage(err);
|
|
391
|
+
record.completedAt = Date.now();
|
|
392
|
+
started.add(entry.id);
|
|
393
|
+
this.onComplete?.(record);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
this.queue = this.queue.filter(e => !started.has(e.id));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Spawn an agent and wait for completion (foreground use).
|
|
401
|
+
* Respects per-model concurrency limits — queued if at capacity, awaited on completion.
|
|
402
|
+
*/
|
|
403
|
+
async spawnAndWait(
|
|
404
|
+
pi: ExtensionAPI,
|
|
405
|
+
ctx: ExtensionContext,
|
|
406
|
+
type: SubagentType,
|
|
407
|
+
prompt: string,
|
|
408
|
+
options: Omit<SpawnOptions, "isBackground">,
|
|
409
|
+
): Promise<AgentRecord> {
|
|
410
|
+
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
|
|
411
|
+
const record = this.agents.get(id)!;
|
|
412
|
+
await record.promise;
|
|
413
|
+
return record;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Resume an existing agent session with a new prompt.
|
|
418
|
+
*/
|
|
419
|
+
async resume(
|
|
420
|
+
id: string,
|
|
421
|
+
prompt: string,
|
|
422
|
+
signal?: AbortSignal,
|
|
423
|
+
): Promise<AgentRecord | undefined> {
|
|
424
|
+
const record = this.agents.get(id);
|
|
425
|
+
if (!record?.session) return undefined;
|
|
426
|
+
|
|
427
|
+
record.status = "running";
|
|
428
|
+
record.startedAt = Date.now();
|
|
429
|
+
record.completedAt = undefined;
|
|
430
|
+
record.result = undefined;
|
|
431
|
+
record.error = undefined;
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const responseText = await resumeAgent(record.session, prompt, {
|
|
435
|
+
onToolActivity: (activity) => {
|
|
436
|
+
if (activity.type === "end") record.toolUses++;
|
|
437
|
+
},
|
|
438
|
+
onAssistantUsage: (usage) => {
|
|
439
|
+
addUsage(record.lifetimeUsage, usage);
|
|
440
|
+
},
|
|
441
|
+
onCompaction: (info) => {
|
|
442
|
+
record.compactionCount++;
|
|
443
|
+
},
|
|
444
|
+
signal,
|
|
445
|
+
});
|
|
446
|
+
record.status = "completed";
|
|
447
|
+
record.result = responseText;
|
|
448
|
+
record.completedAt = Date.now();
|
|
449
|
+
} catch (err) {
|
|
450
|
+
record.status = "error";
|
|
451
|
+
record.error = errorMessage(err);
|
|
452
|
+
record.completedAt = Date.now();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return record;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Send a steering message to a running agent.
|
|
460
|
+
* If the session hasn't been created yet, the message is queued.
|
|
461
|
+
*/
|
|
462
|
+
async steer(id: string, message: string): Promise<boolean> {
|
|
463
|
+
const record = this.agents.get(id);
|
|
464
|
+
if (!record) return false;
|
|
465
|
+
|
|
466
|
+
if (record.status !== "running") return false;
|
|
467
|
+
|
|
468
|
+
if (!record.session) {
|
|
469
|
+
// Session not yet created — queue the steer
|
|
470
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
471
|
+
record.pendingSteers.push(message);
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
await record.session.steer(message);
|
|
477
|
+
return true;
|
|
478
|
+
} catch {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
getRecord(id: string): AgentRecord | undefined {
|
|
484
|
+
return this.agents.get(id);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
listAgents(): AgentRecord[] {
|
|
488
|
+
return [...this.agents.values()].sort(
|
|
489
|
+
(a, b) => b.startedAt - a.startedAt,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
abort(id: string): boolean {
|
|
494
|
+
const record = this.agents.get(id);
|
|
495
|
+
if (!record) return false;
|
|
496
|
+
|
|
497
|
+
return this.stopAgent(record);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Stop an agent by aborting its session or removing it from the queue.
|
|
502
|
+
* Returns true if the agent was stopped, false if it wasn't running/queued.
|
|
503
|
+
*/
|
|
504
|
+
private stopAgent(record: AgentRecord): boolean {
|
|
505
|
+
if (record.status === "queued") {
|
|
506
|
+
this.queue = this.queue.filter(q => q.id !== record.id);
|
|
507
|
+
} else if (record.status !== "running") {
|
|
508
|
+
return false;
|
|
509
|
+
} else {
|
|
510
|
+
record.abortController?.abort();
|
|
511
|
+
}
|
|
512
|
+
record.status = "stopped";
|
|
513
|
+
record.completedAt = Date.now();
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private disposeSession(session: AgentSession | undefined): void {
|
|
518
|
+
session?.dispose();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Dispose a record's session and remove it from the map. */
|
|
522
|
+
private removeRecord(id: string, record: AgentRecord): void {
|
|
523
|
+
this.disposeSession(record.session);
|
|
524
|
+
record.session = undefined;
|
|
525
|
+
this.agents.delete(id);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private cleanup() {
|
|
529
|
+
const cutoff = Date.now() - 10 * 60_000;
|
|
530
|
+
for (const [id, record] of this.agents) {
|
|
531
|
+
if (!isTerminalStatus(record.status)) continue;
|
|
532
|
+
if ((record.completedAt ?? 0) >= cutoff) continue;
|
|
533
|
+
this.removeRecord(id, record);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
dispose() {
|
|
538
|
+
clearInterval(this.cleanupInterval);
|
|
539
|
+
this.queue = [];
|
|
540
|
+
for (const record of this.agents.values()) {
|
|
541
|
+
this.disposeSession(record.session);
|
|
542
|
+
}
|
|
543
|
+
this.agents.clear();
|
|
544
|
+
}
|
|
545
|
+
}
|