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.
@@ -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
+ }