pi-subagents-lite 0.2.0 → 0.3.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/README.md +222 -36
- package/package.json +3 -1
- package/src/agent-discovery.ts +36 -45
- package/src/agent-manager.ts +101 -87
- package/src/agent-runner.ts +40 -49
- package/src/agent-types.ts +15 -37
- package/src/config-io.ts +40 -0
- package/src/context.ts +80 -1
- package/src/index.ts +105 -1117
- package/src/menus.ts +866 -0
- package/src/model-precedence.ts +46 -36
- package/src/model-selector.ts +19 -19
- package/src/output-file.ts +123 -33
- package/src/prompts.ts +2 -2
- package/src/result-viewer.ts +166 -37
- package/src/skill-loader.ts +1 -1
- package/src/stop-agent-tool.ts +76 -0
- package/src/tool-execution.ts +361 -0
- package/src/types.ts +16 -1
- package/src/ui/agent-widget.ts +98 -91
- package/src/usage.ts +12 -4
- package/src/utils.ts +53 -4
package/src/agent-manager.ts
CHANGED
|
@@ -17,13 +17,27 @@ import type { Model } from "@earendil-works/pi-ai";
|
|
|
17
17
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
19
19
|
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
20
|
-
import type {
|
|
21
|
-
|
|
20
|
+
import type {
|
|
21
|
+
AgentInvocation,
|
|
22
|
+
AgentRecord,
|
|
23
|
+
CompactionInfo,
|
|
24
|
+
SubagentType,
|
|
25
|
+
ThinkingLevel,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
import { addUsage, getLifetimeTotal, type LifetimeUsage } from "./usage.js";
|
|
28
|
+
import { errorMessage } from "./utils.js";
|
|
22
29
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
/** How often to check for expired agent records (milliseconds). */
|
|
31
|
+
const CLEANUP_INTERVAL_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
/** Age after which a completed agent record is evicted (milliseconds). */
|
|
34
|
+
const CLEANUP_AGE_CUTOFF_MS = 10 * 60_000;
|
|
35
|
+
|
|
36
|
+
/** Length of short agent ID (UUID prefix). */
|
|
37
|
+
const AGENT_ID_LENGTH = 17;
|
|
38
|
+
|
|
39
|
+
/** Default per-model concurrency limit when not specified in config. */
|
|
40
|
+
const DEFAULT_CONCURRENCY_LIMIT = 4;
|
|
27
41
|
|
|
28
42
|
/**
|
|
29
43
|
* Create a cleanup function for the output file stream.
|
|
@@ -35,12 +49,13 @@ function createOutputCleanup(
|
|
|
35
49
|
path: string,
|
|
36
50
|
record: AgentRecord,
|
|
37
51
|
): () => void {
|
|
38
|
-
const outputStats = { turnCount: 0, toolUseCount: 0, totalTokens: 0 };
|
|
52
|
+
const outputStats = { turnCount: 0, toolUseCount: 0, totalTokens: 0, cost: 0 };
|
|
39
53
|
const cleanup = streamToOutputFile(session, path, outputStats);
|
|
40
54
|
return () => {
|
|
41
55
|
outputStats.turnCount = record.turnCount ?? 0;
|
|
42
56
|
outputStats.toolUseCount = record.toolUses;
|
|
43
57
|
outputStats.totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
58
|
+
outputStats.cost = record.lifetimeUsage.cost;
|
|
44
59
|
cleanup();
|
|
45
60
|
};
|
|
46
61
|
}
|
|
@@ -57,7 +72,7 @@ export interface ConcurrencyConfig {
|
|
|
57
72
|
/** Per-provider concurrency limits keyed by provider name (e.g. "llamacpp"). */
|
|
58
73
|
providers?: Record<string, number>;
|
|
59
74
|
/** Per-model concurrency limits keyed by "provider/modelId". */
|
|
60
|
-
models
|
|
75
|
+
models?: Record<string, number>;
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
type OnAgentComplete = (record: AgentRecord) => void;
|
|
@@ -103,9 +118,9 @@ export interface SpawnOptions {
|
|
|
103
118
|
/** Called at the end of each agentic turn with the cumulative count. */
|
|
104
119
|
onTurnEnd?: (turnCount: number) => void;
|
|
105
120
|
/** Called once per assistant message_end with that message's usage delta. */
|
|
106
|
-
onAssistantUsage?: (usage:
|
|
121
|
+
onAssistantUsage?: (usage: LifetimeUsage) => void;
|
|
107
122
|
/** Called when the session successfully compacts. */
|
|
108
|
-
onCompaction?: (info:
|
|
123
|
+
onCompaction?: (info: CompactionInfo) => void;
|
|
109
124
|
}
|
|
110
125
|
|
|
111
126
|
export class AgentManager {
|
|
@@ -133,24 +148,19 @@ export class AgentManager {
|
|
|
133
148
|
) {
|
|
134
149
|
this.onComplete = onComplete;
|
|
135
150
|
this.onStart = onStart;
|
|
136
|
-
this.defaultConcurrency = concurrency?.default ??
|
|
151
|
+
this.defaultConcurrency = concurrency?.default ?? DEFAULT_CONCURRENCY_LIMIT;
|
|
137
152
|
|
|
138
153
|
// Initialize per-provider slots from config (shared pool)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
this.providerSlots.set(provider, { limit: Math.max(1, limit), running: 0 });
|
|
142
|
-
}
|
|
154
|
+
for (const [provider, limit] of Object.entries(concurrency?.providers ?? {})) {
|
|
155
|
+
this.applyConcurrencyEntry(this.providerSlots, provider, limit);
|
|
143
156
|
}
|
|
144
157
|
|
|
145
158
|
// Initialize per-model slots from config
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
this.concurrencySlots.set(modelKey, { limit: Math.max(1, limit), running: 0 });
|
|
149
|
-
}
|
|
159
|
+
for (const [modelKey, limit] of Object.entries(concurrency?.models ?? {})) {
|
|
160
|
+
this.applyConcurrencyEntry(this.concurrencySlots, modelKey, limit);
|
|
150
161
|
}
|
|
151
162
|
|
|
152
|
-
|
|
153
|
-
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
163
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
154
164
|
this.cleanupInterval.unref();
|
|
155
165
|
}
|
|
156
166
|
|
|
@@ -164,50 +174,52 @@ export class AgentManager {
|
|
|
164
174
|
this.defaultConcurrency = config.default;
|
|
165
175
|
|
|
166
176
|
// Update per-provider slots (shared pool)
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
}
|
|
177
|
+
for (const [provider, limit] of Object.entries(config.providers ?? {})) {
|
|
178
|
+
this.applyConcurrencyEntry(this.providerSlots, provider, limit);
|
|
176
179
|
}
|
|
177
180
|
|
|
178
181
|
// Update existing slots and create new ones
|
|
179
|
-
for (const [modelKey, limit] of Object.entries(config.models)) {
|
|
180
|
-
|
|
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
|
-
}
|
|
182
|
+
for (const [modelKey, limit] of Object.entries(config.models ?? {})) {
|
|
183
|
+
this.applyConcurrencyEntry(this.concurrencySlots, modelKey, limit);
|
|
186
184
|
}
|
|
187
185
|
|
|
188
186
|
// Start queued agents if the new limits allow
|
|
189
187
|
this.drainQueue();
|
|
190
188
|
}
|
|
191
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Update or create a concurrency slot entry.
|
|
192
|
+
* If the key already exists in the map, updates its limit.
|
|
193
|
+
* Otherwise, creates a new slot with the given limit and running=0.
|
|
194
|
+
*/
|
|
195
|
+
private applyConcurrencyEntry(map: Map<string, ConcurrencySlot>, key: string, limit: number): void {
|
|
196
|
+
const safeLimit = Math.max(1, limit);
|
|
197
|
+
const existing = map.get(key);
|
|
198
|
+
if (existing) {
|
|
199
|
+
existing.limit = safeLimit;
|
|
200
|
+
} else {
|
|
201
|
+
map.set(key, { limit: safeLimit, running: 0 });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
192
205
|
/**
|
|
193
206
|
* Get or create a concurrency slot for a model key.
|
|
194
207
|
* Precedence: per-model slot > per-provider shared slot > default (per-model).
|
|
195
|
-
* Returns { slot, isProviderSlot } so caller knows which slot to decrement.
|
|
196
208
|
*/
|
|
197
|
-
private getSlot(modelKey: string):
|
|
209
|
+
private getSlot(modelKey: string): ConcurrencySlot {
|
|
198
210
|
// 1. Check per-model slot
|
|
199
211
|
let slot = this.concurrencySlots.get(modelKey);
|
|
200
|
-
if (slot) return
|
|
212
|
+
if (slot) return slot;
|
|
201
213
|
|
|
202
214
|
// 2. Check per-provider shared slot
|
|
203
215
|
const provider = modelKey.split("/")[0];
|
|
204
216
|
const providerSlot = this.providerSlots.get(provider);
|
|
205
|
-
if (providerSlot) return
|
|
217
|
+
if (providerSlot) return providerSlot;
|
|
206
218
|
|
|
207
219
|
// 3. Create per-model slot with default limit
|
|
208
220
|
slot = { limit: Math.max(1, this.defaultConcurrency), running: 0 };
|
|
209
221
|
this.concurrencySlots.set(modelKey, slot);
|
|
210
|
-
return
|
|
222
|
+
return slot;
|
|
211
223
|
}
|
|
212
224
|
|
|
213
225
|
/**
|
|
@@ -221,7 +233,7 @@ export class AgentManager {
|
|
|
221
233
|
prompt: string,
|
|
222
234
|
options: SpawnOptions,
|
|
223
235
|
): string {
|
|
224
|
-
const id = randomUUID().slice(0,
|
|
236
|
+
const id = randomUUID().slice(0, AGENT_ID_LENGTH);
|
|
225
237
|
const abortController = new AbortController();
|
|
226
238
|
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
227
239
|
|
|
@@ -229,7 +241,7 @@ export class AgentManager {
|
|
|
229
241
|
let queued = false;
|
|
230
242
|
let concurrencySlot: ConcurrencySlot | undefined;
|
|
231
243
|
if (options.modelKey) {
|
|
232
|
-
const
|
|
244
|
+
const slot = this.getSlot(options.modelKey);
|
|
233
245
|
if (slot.running >= slot.limit) {
|
|
234
246
|
queued = true;
|
|
235
247
|
this.queue.push({ id, modelKey: options.modelKey, args });
|
|
@@ -246,7 +258,7 @@ export class AgentManager {
|
|
|
246
258
|
toolUses: 0,
|
|
247
259
|
startedAt: Date.now(),
|
|
248
260
|
abortController,
|
|
249
|
-
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
261
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
250
262
|
compactionCount: 0,
|
|
251
263
|
invocation: options.invocation,
|
|
252
264
|
maxTurns: options.maxTurns,
|
|
@@ -300,29 +312,21 @@ export class AgentManager {
|
|
|
300
312
|
isolated: options.isolated,
|
|
301
313
|
thinkingLevel: options.thinkingLevel,
|
|
302
314
|
signal: record.abortController!.signal,
|
|
303
|
-
|
|
304
|
-
if (activity.type === "end") record.toolUses++;
|
|
305
|
-
options.onToolActivity?.(activity);
|
|
306
|
-
},
|
|
315
|
+
...this.createRecordCallbacks(record, options),
|
|
307
316
|
onTurnEnd: (turnCount) => {
|
|
308
317
|
record.turnCount = turnCount;
|
|
309
318
|
options.onTurnEnd?.(turnCount);
|
|
310
319
|
},
|
|
311
320
|
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
321
|
onSessionCreated: (session) => {
|
|
321
322
|
record.session = session;
|
|
322
323
|
// Flush any steers that arrived before the session was ready
|
|
323
324
|
if (record.pendingSteers?.length) {
|
|
324
325
|
for (const msg of record.pendingSteers) {
|
|
325
|
-
session.steer(msg).catch(() => {
|
|
326
|
+
session.steer(msg).catch(() => {
|
|
327
|
+
// Steer is advisory — a failure here (e.g. session already aborting)
|
|
328
|
+
// is fine; the user can re-send if needed.
|
|
329
|
+
});
|
|
326
330
|
}
|
|
327
331
|
record.pendingSteers = undefined;
|
|
328
332
|
}
|
|
@@ -364,13 +368,47 @@ export class AgentManager {
|
|
|
364
368
|
// Decrement per-model concurrency count
|
|
365
369
|
if (concurrencySlot) concurrencySlot.running--;
|
|
366
370
|
|
|
367
|
-
|
|
371
|
+
this.safeNotifyComplete(record);
|
|
368
372
|
this.drainQueue();
|
|
369
373
|
});
|
|
370
374
|
|
|
371
375
|
record.promise = promise;
|
|
372
376
|
}
|
|
373
377
|
|
|
378
|
+
/** Notify completion callback, ignoring any errors. */
|
|
379
|
+
private safeNotifyComplete(record: AgentRecord): void {
|
|
380
|
+
try { this.onComplete?.(record); } catch { /* ignore */ }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Build common record-tracking callbacks shared by startAgent and resume.
|
|
385
|
+
* Updates the record's toolUses, lifetimeUsage, and compactionCount.
|
|
386
|
+
* When options are provided, also forwards events to the caller.
|
|
387
|
+
*/
|
|
388
|
+
private createRecordCallbacks(
|
|
389
|
+
record: AgentRecord,
|
|
390
|
+
options?: Pick<SpawnOptions, "onToolActivity" | "onAssistantUsage" | "onCompaction">,
|
|
391
|
+
): {
|
|
392
|
+
onToolActivity: (activity: ToolActivity) => void;
|
|
393
|
+
onAssistantUsage: (usage: LifetimeUsage) => void;
|
|
394
|
+
onCompaction: (info: CompactionInfo) => void;
|
|
395
|
+
} {
|
|
396
|
+
return {
|
|
397
|
+
onToolActivity: (activity) => {
|
|
398
|
+
if (activity.type === "end") record.toolUses++;
|
|
399
|
+
options?.onToolActivity?.(activity);
|
|
400
|
+
},
|
|
401
|
+
onAssistantUsage: (usage) => {
|
|
402
|
+
addUsage(record.lifetimeUsage, usage);
|
|
403
|
+
options?.onAssistantUsage?.(usage);
|
|
404
|
+
},
|
|
405
|
+
onCompaction: (info) => {
|
|
406
|
+
record.compactionCount++;
|
|
407
|
+
options?.onCompaction?.(info);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
374
412
|
/** Start queued agents up to the per-model concurrency limits. */
|
|
375
413
|
private drainQueue() {
|
|
376
414
|
const started = new Set<string>();
|
|
@@ -390,29 +428,12 @@ export class AgentManager {
|
|
|
390
428
|
record.error = errorMessage(err);
|
|
391
429
|
record.completedAt = Date.now();
|
|
392
430
|
started.add(entry.id);
|
|
393
|
-
this.
|
|
431
|
+
this.safeNotifyComplete(record);
|
|
394
432
|
}
|
|
395
433
|
}
|
|
396
434
|
this.queue = this.queue.filter(e => !started.has(e.id));
|
|
397
435
|
}
|
|
398
436
|
|
|
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
437
|
/**
|
|
417
438
|
* Resume an existing agent session with a new prompt.
|
|
418
439
|
*/
|
|
@@ -432,15 +453,7 @@ export class AgentManager {
|
|
|
432
453
|
|
|
433
454
|
try {
|
|
434
455
|
const responseText = await resumeAgent(record.session, prompt, {
|
|
435
|
-
|
|
436
|
-
if (activity.type === "end") record.toolUses++;
|
|
437
|
-
},
|
|
438
|
-
onAssistantUsage: (usage) => {
|
|
439
|
-
addUsage(record.lifetimeUsage, usage);
|
|
440
|
-
},
|
|
441
|
-
onCompaction: (info) => {
|
|
442
|
-
record.compactionCount++;
|
|
443
|
-
},
|
|
456
|
+
...this.createRecordCallbacks(record),
|
|
444
457
|
signal,
|
|
445
458
|
});
|
|
446
459
|
record.status = "completed";
|
|
@@ -476,6 +489,7 @@ export class AgentManager {
|
|
|
476
489
|
await record.session.steer(message);
|
|
477
490
|
return true;
|
|
478
491
|
} catch {
|
|
492
|
+
// steer failures are surfaced to the caller via the boolean return value
|
|
479
493
|
return false;
|
|
480
494
|
}
|
|
481
495
|
}
|
|
@@ -526,7 +540,7 @@ export class AgentManager {
|
|
|
526
540
|
}
|
|
527
541
|
|
|
528
542
|
private cleanup() {
|
|
529
|
-
const cutoff = Date.now() -
|
|
543
|
+
const cutoff = Date.now() - CLEANUP_AGE_CUTOFF_MS;
|
|
530
544
|
for (const [id, record] of this.agents) {
|
|
531
545
|
if (!isTerminalStatus(record.status)) continue;
|
|
532
546
|
if ((record.completedAt ?? 0) >= cutoff) continue;
|
package/src/agent-runner.ts
CHANGED
|
@@ -25,10 +25,12 @@ import {
|
|
|
25
25
|
} from "@earendil-works/pi-coding-agent";
|
|
26
26
|
import { getAgentConfig, getConfig, getToolNamesForType } from "./agent-types.js";
|
|
27
27
|
import { extractText } from "./context.js";
|
|
28
|
+
import type { LifetimeUsage } from "./usage.js";
|
|
29
|
+
import { findModelInRegistry } from "./utils.js";
|
|
28
30
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
29
31
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
30
32
|
import { preloadSkills } from "./skill-loader.js";
|
|
31
|
-
import type { EnvInfo, SubagentType, ThinkingLevel } from "./types.js";
|
|
33
|
+
import type { CompactionInfo, EnvInfo, SubagentType, ThinkingLevel } from "./types.js";
|
|
32
34
|
|
|
33
35
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
34
36
|
export const EXCLUDED_TOOL_NAMES = ["Agent"];
|
|
@@ -36,34 +38,15 @@ export const EXCLUDED_TOOL_NAMES = ["Agent"];
|
|
|
36
38
|
/** Additional turns allowed after the soft limit steer message. */
|
|
37
39
|
const GRACE_TURNS = 5;
|
|
38
40
|
|
|
41
|
+
/** Timeout for quick git commands (branch detection, repo check). */
|
|
42
|
+
const GIT_EXEC_TIMEOUT_MS = 5000;
|
|
43
|
+
|
|
39
44
|
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
40
45
|
function normalizeMaxTurns(n: number | undefined): number | undefined {
|
|
41
46
|
if (n == null || n === 0) return undefined;
|
|
42
47
|
return Math.max(1, n);
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
/**
|
|
46
|
-
* Try to find the right model for an agent type.
|
|
47
|
-
* Priority: explicit option > config.model > parent model.
|
|
48
|
-
*/
|
|
49
|
-
function resolveDefaultModel(
|
|
50
|
-
parentModel: Model<any> | undefined,
|
|
51
|
-
registry: { find(provider: string, modelId: string): Model<any> | undefined },
|
|
52
|
-
configModel?: string,
|
|
53
|
-
): Model<any> | undefined {
|
|
54
|
-
if (configModel) {
|
|
55
|
-
const slashIdx = configModel.indexOf("/");
|
|
56
|
-
if (slashIdx !== -1) {
|
|
57
|
-
const provider = configModel.slice(0, slashIdx);
|
|
58
|
-
const modelId = configModel.slice(slashIdx + 1);
|
|
59
|
-
const found = registry.find(provider, modelId);
|
|
60
|
-
if (found) return found;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return parentModel;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
50
|
/** Info about a tool event in the subagent. */
|
|
68
51
|
export interface ToolActivity {
|
|
69
52
|
type: "start" | "end";
|
|
@@ -95,12 +78,12 @@ interface RunOptions {
|
|
|
95
78
|
* Lets callers maintain a lifetime accumulator that survives compaction
|
|
96
79
|
* (which replaces session.state.messages and resets stats-derived sums).
|
|
97
80
|
*/
|
|
98
|
-
onAssistantUsage?: (usage:
|
|
81
|
+
onAssistantUsage?: (usage: LifetimeUsage) => void;
|
|
99
82
|
/**
|
|
100
83
|
* Called when the session successfully compacts. `tokensBefore` is upstream's
|
|
101
84
|
* pre-compaction context size estimate. Aborted compactions don't fire.
|
|
102
85
|
*/
|
|
103
|
-
onCompaction?: (info:
|
|
86
|
+
onCompaction?: (info: CompactionInfo) => void;
|
|
104
87
|
}
|
|
105
88
|
|
|
106
89
|
interface RunResult {
|
|
@@ -155,11 +138,28 @@ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () =>
|
|
|
155
138
|
return () => signal.removeEventListener("abort", onAbort);
|
|
156
139
|
}
|
|
157
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Extract a LifetimeUsage from a runtime assistant message_end event.
|
|
143
|
+
* pi-ai attaches `usage: { input, output, cacheWrite, cost: { total } }` to
|
|
144
|
+
* assistant messages at runtime, but this shape isn't reflected in the
|
|
145
|
+
* AgentSessionEvent public types.
|
|
146
|
+
*/
|
|
147
|
+
function usageFromAssistantMessage(msg: Record<string, unknown>): LifetimeUsage | undefined {
|
|
148
|
+
const usage = msg.usage as Record<string, unknown> | undefined;
|
|
149
|
+
if (!usage) return undefined;
|
|
150
|
+
return {
|
|
151
|
+
input: (usage.input as number) ?? 0,
|
|
152
|
+
output: (usage.output as number) ?? 0,
|
|
153
|
+
cacheWrite: (usage.cacheWrite as number) ?? 0,
|
|
154
|
+
cost: ((usage.cost as Record<string, unknown>)?.total as number) ?? 0,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
158
|
/**
|
|
159
159
|
* Subscribe to shared session events (tool activity, usage, compaction)
|
|
160
160
|
* used by both runAgent and resumeAgent. Returns an unsubscribe function.
|
|
161
161
|
*/
|
|
162
|
-
function subscribeToSessionEvents(
|
|
162
|
+
export function subscribeToSessionEvents(
|
|
163
163
|
session: AgentSession,
|
|
164
164
|
options: Pick<RunOptions, "onToolActivity" | "onAssistantUsage" | "onCompaction">,
|
|
165
165
|
): () => void {
|
|
@@ -174,13 +174,10 @@ function subscribeToSessionEvents(
|
|
|
174
174
|
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
175
175
|
}
|
|
176
176
|
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
output: u.output ?? 0,
|
|
182
|
-
cacheWrite: u.cacheWrite ?? 0,
|
|
183
|
-
});
|
|
177
|
+
const msg = event.message as unknown as Record<string, unknown>;
|
|
178
|
+
const usage = usageFromAssistantMessage(msg);
|
|
179
|
+
if (usage) {
|
|
180
|
+
options.onAssistantUsage?.(usage);
|
|
184
181
|
}
|
|
185
182
|
}
|
|
186
183
|
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
@@ -210,7 +207,7 @@ function filterActiveTools(
|
|
|
210
207
|
}
|
|
211
208
|
|
|
212
209
|
const builtinToolNameSet = new Set(builtinToolNames);
|
|
213
|
-
|
|
210
|
+
const filtered = activeTools.filter((t) => {
|
|
214
211
|
if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
|
|
215
212
|
if (disallowedSet?.has(t)) return false;
|
|
216
213
|
if (builtinToolNameSet.has(t)) return true;
|
|
@@ -219,12 +216,13 @@ function filterActiveTools(
|
|
|
219
216
|
}
|
|
220
217
|
return true;
|
|
221
218
|
});
|
|
219
|
+
return filtered.length !== activeTools.length ? filtered : null;
|
|
222
220
|
}
|
|
223
221
|
|
|
224
222
|
/** Run a git command via pi.exec, returning stdout on success or null on failure. */
|
|
225
223
|
async function execGit(pi: ExtensionAPI, args: string[], cwd: string): Promise<string | null> {
|
|
226
224
|
try {
|
|
227
|
-
const result = await pi.exec("git", args, { cwd, timeout:
|
|
225
|
+
const result = await pi.exec("git", args, { cwd, timeout: GIT_EXEC_TIMEOUT_MS });
|
|
228
226
|
return result.code === 0 ? result.stdout.trim() : null;
|
|
229
227
|
} catch {
|
|
230
228
|
return null;
|
|
@@ -262,8 +260,10 @@ export async function runAgent(
|
|
|
262
260
|
const env = await detectEnv(options.pi, effectiveCwd);
|
|
263
261
|
|
|
264
262
|
// Resolve extensions/skills: isolated overrides to false
|
|
265
|
-
|
|
266
|
-
const
|
|
263
|
+
// Falls back to agent config (frontmatter) when not set via options (tool injection)
|
|
264
|
+
const effectiveIsolated = options.isolated ?? agentConfig?.isolated;
|
|
265
|
+
const extensions = effectiveIsolated ? false : config.extensions;
|
|
266
|
+
const skills = effectiveIsolated ? false : config.skills;
|
|
267
267
|
|
|
268
268
|
// Build prompt extras (no memoryBlock — skills only).
|
|
269
269
|
// When skills is string[], preload their content into the prompt.
|
|
@@ -305,8 +305,8 @@ export async function runAgent(
|
|
|
305
305
|
await loader.reload();
|
|
306
306
|
|
|
307
307
|
// Resolve model: explicit option > config.model > parent model
|
|
308
|
-
const model = options.model ??
|
|
309
|
-
|
|
308
|
+
const model = options.model ?? findModelInRegistry(
|
|
309
|
+
agentConfig?.model, ctx.modelRegistry, ctx.model,
|
|
310
310
|
);
|
|
311
311
|
|
|
312
312
|
// Resolve thinking level: explicit option > agent config > undefined (inherit)
|
|
@@ -419,16 +419,7 @@ export async function resumeAgent(
|
|
|
419
419
|
return collector.getText().trim() || getLastAssistantText(session);
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
-
|
|
423
|
-
* Send a steering message to a running subagent.
|
|
424
|
-
* The message will interrupt the agent after its current tool execution.
|
|
425
|
-
*/
|
|
426
|
-
export async function steerAgent(
|
|
427
|
-
session: AgentSession,
|
|
428
|
-
message: string,
|
|
429
|
-
): Promise<void> {
|
|
430
|
-
await session.steer(message);
|
|
431
|
-
}
|
|
422
|
+
|
|
432
423
|
|
|
433
424
|
|
|
434
425
|
|
package/src/agent-types.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
|
12
12
|
import type { AgentConfig } from "./types.js";
|
|
13
13
|
|
|
14
14
|
/** All known built-in tool names. */
|
|
15
|
-
|
|
15
|
+
const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
16
16
|
|
|
17
17
|
/** Unified runtime registry of all agents (defaults + user-defined). */
|
|
18
18
|
const agents = new Map<string, AgentConfig>();
|
|
@@ -36,8 +36,8 @@ export function registerAgents(userAgents: Map<string, AgentConfig>): void {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
function
|
|
39
|
+
/** Resolve a type name case-insensitively. Also matches displayName. Returns the canonical key or undefined. */
|
|
40
|
+
export function resolveType(name: string): string | undefined {
|
|
41
41
|
if (!name) return undefined;
|
|
42
42
|
if (agents.has(name)) return name;
|
|
43
43
|
const lower = name.toLowerCase();
|
|
@@ -48,14 +48,9 @@ function resolveKey(name: string): string | undefined {
|
|
|
48
48
|
return undefined;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
|
|
52
|
-
export function resolveType(name: string): string | undefined {
|
|
53
|
-
return resolveKey(name);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
51
|
/** Get the agent config for a type (case-insensitive). */
|
|
57
52
|
export function getAgentConfig(name: string): AgentConfig | undefined {
|
|
58
|
-
const key =
|
|
53
|
+
const key = resolveType(name);
|
|
59
54
|
return key ? agents.get(key) : undefined;
|
|
60
55
|
}
|
|
61
56
|
|
|
@@ -71,26 +66,6 @@ export function getAllTypes(): string[] {
|
|
|
71
66
|
return [...agents.keys()];
|
|
72
67
|
}
|
|
73
68
|
|
|
74
|
-
/** Get names of default agents currently in the registry. */
|
|
75
|
-
export function getDefaultAgentNames(): string[] {
|
|
76
|
-
return [...agents.entries()]
|
|
77
|
-
.filter(([_, config]) => config.isDefault === true)
|
|
78
|
-
.map(([name]) => name);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Get names of user-defined agents (non-defaults) currently in the registry. */
|
|
82
|
-
export function getUserAgentNames(): string[] {
|
|
83
|
-
return [...agents.entries()]
|
|
84
|
-
.filter(([_, config]) => config.isDefault !== true)
|
|
85
|
-
.map(([name]) => name);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Check if a type is valid and enabled (case-insensitive). */
|
|
89
|
-
export function isValidType(type: string): boolean {
|
|
90
|
-
const config = getAgentConfig(type);
|
|
91
|
-
return config !== undefined && config.enabled !== false;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
69
|
/** Get built-in tool names for a type (case-insensitive). */
|
|
95
70
|
export function getToolNamesForType(type: string): string[] {
|
|
96
71
|
const config = getAgentConfig(type);
|
|
@@ -99,14 +74,16 @@ export function getToolNamesForType(type: string): string[] {
|
|
|
99
74
|
: [...BUILTIN_TOOL_NAMES];
|
|
100
75
|
}
|
|
101
76
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
77
|
+
/** Resolved config shape returned by getConfig. */
|
|
78
|
+
interface ResolvedAgentConfig {
|
|
104
79
|
displayName: string;
|
|
105
80
|
description: string;
|
|
106
81
|
builtinToolNames: string[];
|
|
107
82
|
extensions: true | string[] | false;
|
|
108
83
|
skills: true | string[] | false;
|
|
109
|
-
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toResolved(config: AgentConfig): ResolvedAgentConfig {
|
|
110
87
|
return {
|
|
111
88
|
displayName: config.displayName ?? config.name,
|
|
112
89
|
description: config.description,
|
|
@@ -117,16 +94,17 @@ function toSubagentTypeConfig(config: AgentConfig): {
|
|
|
117
94
|
}
|
|
118
95
|
|
|
119
96
|
/** Get config for a type (case-insensitive). Falls back to general-purpose. */
|
|
120
|
-
export function getConfig(type: string):
|
|
121
|
-
const
|
|
122
|
-
const config =
|
|
97
|
+
export function getConfig(type: string): ResolvedAgentConfig {
|
|
98
|
+
const resolvedKey = resolveType(type);
|
|
99
|
+
const config = resolvedKey ? agents.get(resolvedKey) : undefined;
|
|
123
100
|
|
|
124
|
-
|
|
101
|
+
// If config exists and is enabled, use it; otherwise fall back to general-purpose
|
|
102
|
+
const activeConfig = config?.enabled !== false
|
|
125
103
|
? config
|
|
126
104
|
: agents.get("general-purpose");
|
|
127
105
|
|
|
128
106
|
if (activeConfig && activeConfig.enabled !== false) {
|
|
129
|
-
return
|
|
107
|
+
return toResolved(activeConfig);
|
|
130
108
|
}
|
|
131
109
|
|
|
132
110
|
// Absolute fallback — general-purpose was disabled or missing
|
package/src/config-io.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-io.ts — Config persistence (read/write).
|
|
3
|
+
*
|
|
4
|
+
* Atomic writes: write to .tmp then rename.
|
|
5
|
+
* Loaded at session_start; saved on every /agents menu mutation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import type { SubagentsConfig } from "./model-precedence.js";
|
|
11
|
+
|
|
12
|
+
const CONFIG_DIR = path.join(process.env.HOME || "", ".pi", "agent");
|
|
13
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "subagents-lite.json");
|
|
14
|
+
|
|
15
|
+
/** Read config from disk. Returns defaults if file doesn't exist or is invalid. */
|
|
16
|
+
export function loadConfig(): SubagentsConfig {
|
|
17
|
+
try {
|
|
18
|
+
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
19
|
+
return JSON.parse(raw) as SubagentsConfig;
|
|
20
|
+
} catch {
|
|
21
|
+
// File doesn't exist or is invalid — return defaults
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
agent: { default: null, forceBackground: false },
|
|
26
|
+
concurrency: { default: 4 },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Write config to disk with atomic rename. */
|
|
31
|
+
export function saveConfigAtomic(config: SubagentsConfig): void {
|
|
32
|
+
const tmpPath = CONFIG_PATH + ".tmp";
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
35
|
+
fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
|
|
36
|
+
fs.renameSync(tmpPath, CONFIG_PATH);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error(`[subagents] Failed to save config: ${err}`);
|
|
39
|
+
}
|
|
40
|
+
}
|