pi-subagents-lite 0.2.0 → 0.3.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/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
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-execution.ts — Agent tool execution handlers.
|
|
3
|
+
*
|
|
4
|
+
* Contains the execute callbacks registered for the Agent tool,
|
|
5
|
+
* plus nudge scheduling and activity tracking helpers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
+
|
|
11
|
+
import type { AgentRecord } from "./types.js";
|
|
12
|
+
import type { SpawnOptions as AgentManagerSpawnOptions } from "./agent-manager.js";
|
|
13
|
+
import type { AgentActivity } from "./ui/agent-widget.js";
|
|
14
|
+
import { resolveType, getAgentConfig } from "./agent-types.js";
|
|
15
|
+
import { resolveModel } from "./model-precedence.js";
|
|
16
|
+
import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
|
|
17
|
+
|
|
18
|
+
// Shared state imported from index.ts
|
|
19
|
+
import { parseModelKey, findModelInRegistry, parseThinkingLevel } from "./utils.js";
|
|
20
|
+
import {
|
|
21
|
+
__config,
|
|
22
|
+
sessionOverrides,
|
|
23
|
+
manager,
|
|
24
|
+
piInstance,
|
|
25
|
+
agentActivity,
|
|
26
|
+
widget,
|
|
27
|
+
} from "./index.js";
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Module-level state
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
/** Agent IDs that were spawned as background — only these trigger a nudge on completion. */
|
|
34
|
+
export const backgroundAgentIds = new Set<string>();
|
|
35
|
+
|
|
36
|
+
const pendingNudges = new Set<string>();
|
|
37
|
+
let nudgeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
|
+
|
|
39
|
+
/** Batch delay for nudges — only emit one update per batch window (ms). */
|
|
40
|
+
const NUDGE_DELAY_MS = 200;
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Tool result helpers
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
/** Shortcut for a successful tool result. */
|
|
47
|
+
export function successResult(text: string, details?: Record<string, unknown>) {
|
|
48
|
+
return { content: [{ type: "text", text }], details };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Shortcut for an error tool result. */
|
|
52
|
+
export function errorResult(text: string, details?: Record<string, unknown>) {
|
|
53
|
+
return { content: [{ type: "text", text }], isError: true as const, details };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// Activity tracking
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
62
|
+
* Used by both foreground and background paths to avoid duplication.
|
|
63
|
+
*/
|
|
64
|
+
function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
65
|
+
const state: AgentActivity = {
|
|
66
|
+
activeTools: new Map(),
|
|
67
|
+
toolUses: 0,
|
|
68
|
+
turnCount: 1,
|
|
69
|
+
maxTurns,
|
|
70
|
+
responseText: "",
|
|
71
|
+
session: undefined,
|
|
72
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const callbacks = {
|
|
76
|
+
onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
|
|
77
|
+
if (activity.type === "start") {
|
|
78
|
+
state.activeTools.set(`${activity.toolName}_${Date.now()}`, activity.toolName);
|
|
79
|
+
} else {
|
|
80
|
+
for (const [key, name] of state.activeTools) {
|
|
81
|
+
if (name === activity.toolName) { state.activeTools.delete(key); break; }
|
|
82
|
+
}
|
|
83
|
+
state.toolUses++;
|
|
84
|
+
}
|
|
85
|
+
onStreamUpdate?.();
|
|
86
|
+
},
|
|
87
|
+
onTextDelta: (_delta: string, fullText: string) => {
|
|
88
|
+
state.responseText = fullText;
|
|
89
|
+
onStreamUpdate?.();
|
|
90
|
+
},
|
|
91
|
+
onTurnEnd: (turnCount: number) => {
|
|
92
|
+
state.turnCount = turnCount;
|
|
93
|
+
onStreamUpdate?.();
|
|
94
|
+
},
|
|
95
|
+
onSessionCreated: (session: unknown) => {
|
|
96
|
+
state.session = session as Parameters<typeof getSessionContextPercent>[0];
|
|
97
|
+
},
|
|
98
|
+
onAssistantUsage: (usage: LifetimeUsage) => {
|
|
99
|
+
addUsage(state.lifetimeUsage, usage);
|
|
100
|
+
onStreamUpdate?.();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return { state, callbacks };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// Nudge scheduling — batch completion notifications within the hold window
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
export function scheduleNudge(agentId: string): void {
|
|
112
|
+
pendingNudges.add(agentId);
|
|
113
|
+
|
|
114
|
+
if (nudgeTimer) return;
|
|
115
|
+
|
|
116
|
+
nudgeTimer = setTimeout(() => {
|
|
117
|
+
nudgeTimer = null;
|
|
118
|
+
const batch = [...pendingNudges];
|
|
119
|
+
pendingNudges.clear();
|
|
120
|
+
|
|
121
|
+
for (const id of batch) {
|
|
122
|
+
emitIndividualNudge(id, manager?.getRecord(id));
|
|
123
|
+
}
|
|
124
|
+
}, NUDGE_DELAY_MS);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function emitIndividualNudge(agentId: string, record?: AgentRecord): void {
|
|
128
|
+
if (!record) return;
|
|
129
|
+
|
|
130
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
131
|
+
const elapsedMs = record.completedAt
|
|
132
|
+
? record.completedAt - record.startedAt
|
|
133
|
+
: 0;
|
|
134
|
+
|
|
135
|
+
const details: Record<string, unknown> = {
|
|
136
|
+
type: record.type,
|
|
137
|
+
description: record.description,
|
|
138
|
+
status: record.status,
|
|
139
|
+
outputFile: record.outputFile,
|
|
140
|
+
turnCount: record.turnCount ?? agentActivity.get(agentId)?.turnCount,
|
|
141
|
+
maxTurns: record.maxTurns,
|
|
142
|
+
toolUses: record.toolUses,
|
|
143
|
+
tokens: totalTokens,
|
|
144
|
+
cost: record.lifetimeUsage.cost,
|
|
145
|
+
contextPercent: getSessionContextPercent(record.session),
|
|
146
|
+
durationMs: elapsedMs,
|
|
147
|
+
compactions: record.compactionCount,
|
|
148
|
+
modelName: record.invocation?.modelName,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
piInstance.sendMessage(
|
|
152
|
+
{
|
|
153
|
+
customType: "subagent-result",
|
|
154
|
+
content: record.result ?? "",
|
|
155
|
+
details,
|
|
156
|
+
display: true,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
deliverAs: "steer",
|
|
160
|
+
triggerTurn: true,
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// Tool execute handlers
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
export async function executeAgentTool(
|
|
170
|
+
_toolCallId: string,
|
|
171
|
+
params: Record<string, unknown>,
|
|
172
|
+
_signal: AbortSignal | undefined,
|
|
173
|
+
_onUpdate: ((update: any) => void) | undefined,
|
|
174
|
+
ctx: ExtensionContext,
|
|
175
|
+
): Promise<any> {
|
|
176
|
+
const type = (params.agent as string) || "general-purpose";
|
|
177
|
+
const resolvedType = resolveType(type);
|
|
178
|
+
if (!resolvedType) {
|
|
179
|
+
return errorResult(`Unknown agent type: ${type}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const prompt = params.prompt as string;
|
|
183
|
+
const description = params.description as string;
|
|
184
|
+
const resume = params.resume as string | undefined;
|
|
185
|
+
const runInBackground = params.run_in_background as boolean | undefined;
|
|
186
|
+
const isolated = params.isolated as boolean | undefined;
|
|
187
|
+
const maxTurns = params.max_turns as number | undefined ?? getAgentConfig(resolvedType)?.maxTurns;
|
|
188
|
+
const modelStr = params.model as string | undefined;
|
|
189
|
+
const model = findModelInRegistry(modelStr, ctx.modelRegistry, ctx.model);
|
|
190
|
+
const modelKey = model ? `${model.provider}/${model.id}` : undefined;
|
|
191
|
+
|
|
192
|
+
// Determine modelName for invocation (only when different from parent)
|
|
193
|
+
const parentModelId = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
|
|
194
|
+
const modelName = (modelKey && modelKey !== parentModelId)
|
|
195
|
+
? parseModelKey(modelKey)?.modelId
|
|
196
|
+
: undefined;
|
|
197
|
+
|
|
198
|
+
// Resolve thinking: explicit param > agent config (frontmatter) > undefined (inherit)
|
|
199
|
+
const thinkingLevel = parseThinkingLevel(params.thinking as string | undefined)
|
|
200
|
+
?? getAgentConfig(resolvedType)?.thinking;
|
|
201
|
+
|
|
202
|
+
if (resume) {
|
|
203
|
+
return executeResumeAgent(resume, prompt);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const spawnOptions: AgentManagerSpawnOptions = {
|
|
207
|
+
description,
|
|
208
|
+
model,
|
|
209
|
+
maxTurns,
|
|
210
|
+
isolated,
|
|
211
|
+
thinkingLevel,
|
|
212
|
+
modelKey,
|
|
213
|
+
invocation: modelName ? { modelName } : undefined,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (runInBackground || __config.agent.forceBackground) {
|
|
217
|
+
return executeSpawnBackground(resolvedType, prompt, ctx, spawnOptions);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return executeSpawnForeground(resolvedType, prompt, ctx, spawnOptions);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function executeResumeAgent(
|
|
224
|
+
resume: string,
|
|
225
|
+
prompt: string,
|
|
226
|
+
): Promise<any> {
|
|
227
|
+
const record = await manager.resume(resume, prompt);
|
|
228
|
+
if (!record) {
|
|
229
|
+
return errorResult(`Agent not found: ${resume}`);
|
|
230
|
+
}
|
|
231
|
+
return successResult(record.result ?? "");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function executeSpawnBackground(
|
|
235
|
+
resolvedType: string,
|
|
236
|
+
prompt: string,
|
|
237
|
+
ctx: ExtensionContext,
|
|
238
|
+
spawnOptions: AgentManagerSpawnOptions,
|
|
239
|
+
): Promise<any> {
|
|
240
|
+
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(
|
|
241
|
+
spawnOptions.maxTurns,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const agentId = manager.spawn(piInstance, ctx, resolvedType, prompt, {
|
|
245
|
+
...spawnOptions,
|
|
246
|
+
isBackground: true,
|
|
247
|
+
...bgCallbacks,
|
|
248
|
+
});
|
|
249
|
+
backgroundAgentIds.add(agentId);
|
|
250
|
+
agentActivity.set(agentId, bgState);
|
|
251
|
+
widget?.ensureTimer();
|
|
252
|
+
widget?.update();
|
|
253
|
+
|
|
254
|
+
const record = manager.getRecord(agentId);
|
|
255
|
+
if (!record) {
|
|
256
|
+
return errorResult("Failed to create agent");
|
|
257
|
+
}
|
|
258
|
+
const bgDetails: Record<string, unknown> = { type: resolvedType, description: spawnOptions.description };
|
|
259
|
+
if (record.status === "queued") {
|
|
260
|
+
return successResult(`[Agent queued] Concurrency limit reached. It will start automatically when a slot frees up. A notification will arrive when done — User asks you not to poll, but wait for nudge.
|
|
261
|
+
|
|
262
|
+
Agent ID: ${agentId}`, bgDetails);
|
|
263
|
+
}
|
|
264
|
+
return successResult(
|
|
265
|
+
`Agent running in background. A notification will arrive when done — User asks you not to poll, but wait for nudge.\n\nAgent ID: ${agentId}`,
|
|
266
|
+
bgDetails,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function executeSpawnForeground(
|
|
271
|
+
resolvedType: string,
|
|
272
|
+
prompt: string,
|
|
273
|
+
ctx: ExtensionContext,
|
|
274
|
+
spawnOptions: AgentManagerSpawnOptions,
|
|
275
|
+
): Promise<any> {
|
|
276
|
+
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
|
|
277
|
+
spawnOptions.maxTurns,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const fgId = manager.spawn(piInstance, ctx, resolvedType, prompt, {
|
|
281
|
+
...spawnOptions,
|
|
282
|
+
...fgCallbacks,
|
|
283
|
+
isBackground: false,
|
|
284
|
+
});
|
|
285
|
+
agentActivity.set(fgId, fgState);
|
|
286
|
+
widget?.ensureTimer();
|
|
287
|
+
|
|
288
|
+
const record = manager.getRecord(fgId)!;
|
|
289
|
+
await record.promise;
|
|
290
|
+
|
|
291
|
+
agentActivity.delete(fgId);
|
|
292
|
+
widget?.markFinished(fgId);
|
|
293
|
+
widget?.update();
|
|
294
|
+
|
|
295
|
+
const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
296
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
297
|
+
const stats: Record<string, unknown> = {
|
|
298
|
+
type: resolvedType,
|
|
299
|
+
turnCount: fgState.turnCount,
|
|
300
|
+
maxTurns: fgState.maxTurns,
|
|
301
|
+
toolUses: record.toolUses,
|
|
302
|
+
tokens: totalTokens,
|
|
303
|
+
contextPercent: getSessionContextPercent(fgState.session),
|
|
304
|
+
durationMs: elapsedMs,
|
|
305
|
+
description: spawnOptions.description,
|
|
306
|
+
compactions: record.compactionCount,
|
|
307
|
+
modelName: record.invocation?.modelName,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
if (record.status === "error") {
|
|
311
|
+
return errorResult(`Agent failed: ${record.error || "unknown error"}`, stats);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return successResult(record.result ?? "", stats);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ============================================================================
|
|
318
|
+
// Tool_call listener — inject model into Agent tool calls
|
|
319
|
+
// ============================================================================
|
|
320
|
+
|
|
321
|
+
export async function toolCallListener(
|
|
322
|
+
event: ToolCallEvent,
|
|
323
|
+
ctx: ExtensionContext,
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
if (event.toolName !== "Agent") return;
|
|
326
|
+
|
|
327
|
+
const input = event.input;
|
|
328
|
+
const subagentType = input.agent as string | undefined;
|
|
329
|
+
const agentConfig = subagentType ? getAgentConfig(subagentType) : undefined;
|
|
330
|
+
|
|
331
|
+
const parentModelId = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
|
|
332
|
+
|
|
333
|
+
const effectiveModel = resolveModel({
|
|
334
|
+
subagentType: subagentType ?? "general-purpose",
|
|
335
|
+
agentConfig,
|
|
336
|
+
config: __config,
|
|
337
|
+
parentModelId,
|
|
338
|
+
sessionOverrides,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (effectiveModel) {
|
|
342
|
+
input.model = effectiveModel;
|
|
343
|
+
// Inject _modelOverride for renderCall when model differs from parent
|
|
344
|
+
if (effectiveModel !== parentModelId) {
|
|
345
|
+
const parsed = parseModelKey(effectiveModel);
|
|
346
|
+
if (parsed) {
|
|
347
|
+
input._modelOverride = parsed.modelId;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Inject isolated from agent config if not explicitly passed
|
|
353
|
+
if (input.isolated === undefined && agentConfig?.isolated !== undefined) {
|
|
354
|
+
input.isolated = agentConfig.isolated;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Inject thinking from agent config if not explicitly passed
|
|
358
|
+
if (input.thinking === undefined && agentConfig?.thinking !== undefined) {
|
|
359
|
+
input.thinking = agentConfig.thinking;
|
|
360
|
+
}
|
|
361
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface AgentRecord {
|
|
|
66
66
|
outputCleanup?: () => void;
|
|
67
67
|
/**
|
|
68
68
|
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
|
|
69
|
-
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
|
|
69
|
+
* compaction. Total = input + output + cacheWrite + cost (cacheRead deliberately
|
|
70
70
|
* excluded — see issue #38). Initialized to zeros at spawn.
|
|
71
71
|
*/
|
|
72
72
|
lifetimeUsage: LifetimeUsage;
|
|
@@ -94,3 +94,18 @@ export interface EnvInfo {
|
|
|
94
94
|
branch: string | null;
|
|
95
95
|
platform: string;
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
/** Reason for a context compaction event. */
|
|
99
|
+
export type CompactionReason = "manual" | "threshold" | "overflow";
|
|
100
|
+
|
|
101
|
+
/** Info payload emitted when a session compacts successfully. */
|
|
102
|
+
export interface CompactionInfo {
|
|
103
|
+
reason: CompactionReason;
|
|
104
|
+
tokensBefore: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Parsed "provider/model-id" key. */
|
|
108
|
+
export interface ModelKey {
|
|
109
|
+
provider: string;
|
|
110
|
+
modelId: string;
|
|
111
|
+
}
|