@bacnh85/pi-subagent 0.10.0 → 0.11.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.
@@ -17,16 +17,16 @@
17
17
  import type { Message, Model } from "@earendil-works/pi-ai";
18
18
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
19
  import {
20
- createAgentSession,
21
- createExtensionRuntime,
22
- type ResourceLoader,
23
- SessionManager,
24
- SettingsManager,
20
+ createAgentSession,
21
+ createExtensionRuntime,
22
+ type ResourceLoader,
23
+ SessionManager,
24
+ SettingsManager,
25
25
  } from "@earendil-works/pi-coding-agent";
26
26
  import {
27
- classifyStopReason,
28
- createCombinedAbortSignal,
29
- type SubagentStatus,
27
+ classifyStopReason,
28
+ createCombinedAbortSignal,
29
+ type SubagentStatus,
30
30
  } from "./security.ts";
31
31
 
32
32
  // ---------------------------------------------------------------------------
@@ -34,39 +34,39 @@ import {
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
36
  export interface UsageStats {
37
- input: number;
38
- output: number;
39
- cacheRead: number;
40
- cacheWrite: number;
41
- cost: number;
42
- contextTokens: number;
43
- turns: number;
37
+ input: number;
38
+ output: number;
39
+ cacheRead: number;
40
+ cacheWrite: number;
41
+ cost: number;
42
+ contextTokens: number;
43
+ turns: number;
44
44
  }
45
45
 
46
46
  export const DEFAULT_INACTIVITY_TIMEOUT_MS = 3 * 60 * 1000;
47
47
  export const HARD_TIMEOUT_MS = 20 * 60 * 1000;
48
48
 
49
49
  export interface SubAgentProgress {
50
- label: string;
51
- at: number;
52
- elapsedMs: number;
53
- inactivityDeadline: number;
54
- hardDeadline: number;
55
- result: SubAgentResult;
50
+ label: string;
51
+ at: number;
52
+ elapsedMs: number;
53
+ inactivityDeadline: number;
54
+ hardDeadline: number;
55
+ result: SubAgentResult;
56
56
  }
57
57
 
58
58
  export interface SubAgentResult {
59
- agent: string;
60
- task: string;
61
- exitCode: number;
62
- messages: Message[];
63
- stderr: string;
64
- usage: UsageStats;
65
- model?: string;
66
- stopReason?: string;
67
- errorMessage?: string;
68
- /** Canonical result status (added in 0.6.0). */
69
- status?: SubagentStatus;
59
+ agent: string;
60
+ task: string;
61
+ exitCode: number;
62
+ messages: Message[];
63
+ stderr: string;
64
+ usage: UsageStats;
65
+ model?: string;
66
+ stopReason?: string;
67
+ errorMessage?: string;
68
+ /** Canonical result status (added in 0.6.0). */
69
+ status?: SubagentStatus;
70
70
  }
71
71
 
72
72
  // ---------------------------------------------------------------------------
@@ -74,136 +74,136 @@ export interface SubAgentResult {
74
74
  // ---------------------------------------------------------------------------
75
75
 
76
76
  export function startHeartbeat(onHeartbeat: () => void, intervalMs = 30_000): () => void {
77
- const timer = setInterval(onHeartbeat, intervalMs);
78
- timer.unref?.();
79
- return () => clearInterval(timer);
77
+ const timer = setInterval(onHeartbeat, intervalMs);
78
+ timer.unref?.();
79
+ return () => clearInterval(timer);
80
80
  }
81
81
 
82
82
  export async function runSubAgent(options: {
83
- cwd: string;
84
- systemPrompt: string;
85
- task: string;
86
- tools: string[];
87
- model: Model<any>;
88
- /** Pi 0.80.10's canonical credential/model runtime. */
89
- modelRuntime?: unknown;
90
- /** Legacy Pi SDK session options retained for 0.80.6 tests and hosts. */
91
- authStorage?: unknown;
92
- modelRegistry?: unknown;
93
- signal?: AbortSignal;
94
- agentName?: string;
95
- thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
96
- onMessage?: (partialResult: SubAgentResult) => void;
97
- onProgress?: (progress: SubAgentProgress) => void;
98
- timeoutMs?: number;
99
- hardTimeoutMs?: number;
83
+ cwd: string;
84
+ systemPrompt: string;
85
+ task: string;
86
+ tools: string[];
87
+ model: Model<any>;
88
+ /** Pi 0.80.10's canonical credential/model runtime. */
89
+ modelRuntime?: unknown;
90
+ /** Legacy Pi SDK session options retained for 0.80.6 tests and hosts. */
91
+ authStorage?: unknown;
92
+ modelRegistry?: unknown;
93
+ signal?: AbortSignal;
94
+ agentName?: string;
95
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
96
+ onMessage?: (partialResult: SubAgentResult) => void;
97
+ onProgress?: (progress: SubAgentProgress) => void;
98
+ timeoutMs?: number;
99
+ hardTimeoutMs?: number;
100
100
  }): Promise<SubAgentResult> {
101
- const {
102
- cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
103
- agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
104
- timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
105
- } = options;
106
- const result: SubAgentResult = {
107
- agent: agentName, task, exitCode: 0, messages: [], stderr: "",
108
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
109
- model: `${model.provider}/${model.id}`, status: undefined,
110
- };
111
- const resourceLoader: ResourceLoader = {
112
- getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
113
- getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
114
- getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
115
- getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {},
116
- };
117
- const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: true, maxRetries: 1 } });
118
- const startedAt = Date.now();
119
- let inactivityDeadline = startedAt + timeoutMs;
120
- const hardDeadline = startedAt + hardTimeoutMs;
121
- let timeoutKind: "idle" | "hard" | undefined;
122
- const timeoutController = new AbortController();
123
- let idleTimer: ReturnType<typeof setTimeout> | undefined;
124
- let hardTimer: ReturnType<typeof setTimeout> | undefined;
125
- let cleanupCombined: (() => void) | undefined;
126
- const clearTimers = () => { if (idleTimer) clearTimeout(idleTimer); if (hardTimer) clearTimeout(hardTimer); };
127
- const armIdle = () => {
128
- if (idleTimer) clearTimeout(idleTimer);
129
- inactivityDeadline = Date.now() + timeoutMs;
130
- idleTimer = setTimeout(() => { timeoutKind = "idle"; timeoutController.abort(new Error(`Idle timeout after ${timeoutMs}ms`)); }, timeoutMs);
131
- };
132
- const snapshot = (label: string): SubAgentProgress => ({ label, at: Date.now(), elapsedMs: Date.now() - startedAt, inactivityDeadline, hardDeadline, result: { ...result, messages: [...result.messages] } });
133
- try {
134
- armIdle();
135
- hardTimer = setTimeout(() => { timeoutKind = "hard"; timeoutController.abort(new Error(`Hard timeout after ${hardTimeoutMs}ms`)); }, hardTimeoutMs);
136
- const { signal: combinedSignal, cleanup } = createCombinedAbortSignal([signal, timeoutController.signal]);
137
- cleanupCombined = cleanup;
138
- if (combinedSignal.aborted) {
139
- result.exitCode = 1;
140
- const timedOut = timeoutController.signal.aborted && !signal?.aborted;
141
- result.stopReason = timedOut ? "timeout" : "aborted";
142
- result.errorMessage = timedOut ? `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms` : "Sub-agent aborted before start";
143
- result.status = classifyStopReason(result.stopReason, !timedOut, timedOut);
144
- return result;
145
- }
146
- const { session } = await createAgentSession({
147
- cwd, model, thinkingLevel, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager,
148
- ...(modelRuntime ? { modelRuntime } : {}),
149
- // Pi 0.80.10 owns credentials in ModelRuntime; older SDKs still accept these.
150
- ...(authStorage ? { authStorage, modelRegistry } : {}),
151
- } as any);
152
- let unsubscribe: (() => void) | undefined;
153
- let removeAbort: (() => void) | undefined;
154
- try {
155
- const eventDone = new Promise<void>((resolve, reject) => {
156
- let done = false;
157
- const finish = (fn: () => void) => { if (!done) { done = true; unsubscribe?.(); fn(); } };
158
- unsubscribe = session.subscribe((event) => {
159
- try {
160
- // Any SDK session lifecycle event is actual child activity, unlike a parent heartbeat.
161
- armIdle(); onProgress?.(snapshot(event.type));
162
- if (event.type === "message_end") {
163
- const msg = event.message as AgentMessage;
164
- if (msg.role === "assistant") {
165
- result.usage.turns++;
166
- if (msg.usage) { result.usage.input += msg.usage.input || 0; result.usage.output += msg.usage.output || 0; result.usage.cacheRead += msg.usage.cacheRead || 0; result.usage.cacheWrite += msg.usage.cacheWrite || 0; result.usage.cost += msg.usage.cost?.total || 0; result.usage.contextTokens = msg.usage.totalTokens || 0; }
167
- if (!result.model && msg.model) result.model = `${msg.provider || "?"}/${msg.model}`;
168
- if (msg.stopReason) result.stopReason = msg.stopReason;
169
- result.errorMessage = msg.errorMessage;
170
- }
171
- result.messages.push(msg as unknown as Message);
172
- onMessage?.({ ...result, messages: [...result.messages] });
173
- } else if (event.type === "agent_end" && !event.willRetry) {
174
- if (!result.messages.length && event.messages) result.messages = event.messages as unknown as Message[];
175
- finish(resolve);
176
- }
177
- } catch (error) { finish(() => reject(error)); }
178
- });
179
- const abort = () => finish(resolve);
180
- combinedSignal.addEventListener("abort", abort, { once: true });
181
- removeAbort = () => combinedSignal.removeEventListener("abort", abort);
182
- });
183
- const abortSession = () => session.abort();
184
- combinedSignal.addEventListener("abort", abortSession, { once: true });
185
- const removeSessionAbort = () => combinedSignal.removeEventListener("abort", abortSession);
186
- await Promise.race([session.prompt(task), eventDone]);
187
- removeSessionAbort();
188
- const timedOut = timeoutController.signal.aborted && !signal?.aborted;
189
- if (timedOut) { result.stopReason = "timeout"; result.errorMessage = `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms`; }
190
- else if (combinedSignal.aborted) { result.stopReason = "aborted"; result.errorMessage ||= "Sub-agent aborted"; }
191
- result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
192
- result.exitCode = result.status === "success" || result.status === "partial" ? 0 : 1;
193
- return result;
194
- } finally {
195
- unsubscribe?.(); removeAbort?.();
196
- try { session.dispose(); } catch { /* best effort */ }
197
- }
198
- } catch (error) {
199
- result.exitCode = 1;
200
- result.errorMessage = error instanceof Error ? error.message : String(error);
201
- result.stopReason ||= "error";
202
- result.status = classifyStopReason(result.stopReason, false, false);
203
- return result;
204
- } finally {
205
- clearTimers(); cleanupCombined?.();
206
- }
101
+ const {
102
+ cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
103
+ agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
104
+ timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
105
+ } = options;
106
+ const result: SubAgentResult = {
107
+ agent: agentName, task, exitCode: 0, messages: [], stderr: "",
108
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
109
+ model: `${model.provider}/${model.id}`, status: undefined,
110
+ };
111
+ const resourceLoader: ResourceLoader = {
112
+ getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
113
+ getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }),
114
+ getThemes: () => ({ themes: [], diagnostics: [] }), getAgentsFiles: () => ({ agentsFiles: [] }),
115
+ getSystemPrompt: () => systemPrompt, getAppendSystemPrompt: () => [], extendResources: () => {}, reload: async () => {},
116
+ };
117
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: true, maxRetries: 1 } });
118
+ const startedAt = Date.now();
119
+ let inactivityDeadline = startedAt + timeoutMs;
120
+ const hardDeadline = startedAt + hardTimeoutMs;
121
+ let timeoutKind: "idle" | "hard" | undefined;
122
+ const timeoutController = new AbortController();
123
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
124
+ let hardTimer: ReturnType<typeof setTimeout> | undefined;
125
+ let cleanupCombined: (() => void) | undefined;
126
+ const clearTimers = () => { if (idleTimer) clearTimeout(idleTimer); if (hardTimer) clearTimeout(hardTimer); };
127
+ const armIdle = () => {
128
+ if (idleTimer) clearTimeout(idleTimer);
129
+ inactivityDeadline = Date.now() + timeoutMs;
130
+ idleTimer = setTimeout(() => { timeoutKind = "idle"; timeoutController.abort(new Error(`Idle timeout after ${timeoutMs}ms`)); }, timeoutMs);
131
+ };
132
+ const snapshot = (label: string): SubAgentProgress => ({ label, at: Date.now(), elapsedMs: Date.now() - startedAt, inactivityDeadline, hardDeadline, result: { ...result, messages: [...result.messages] } });
133
+ try {
134
+ armIdle();
135
+ hardTimer = setTimeout(() => { timeoutKind = "hard"; timeoutController.abort(new Error(`Hard timeout after ${hardTimeoutMs}ms`)); }, hardTimeoutMs);
136
+ const { signal: combinedSignal, cleanup } = createCombinedAbortSignal([signal, timeoutController.signal]);
137
+ cleanupCombined = cleanup;
138
+ if (combinedSignal.aborted) {
139
+ result.exitCode = 1;
140
+ const timedOut = timeoutController.signal.aborted && !signal?.aborted;
141
+ result.stopReason = timedOut ? "timeout" : "aborted";
142
+ result.errorMessage = timedOut ? `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms` : "Sub-agent aborted before start";
143
+ result.status = classifyStopReason(result.stopReason, !timedOut, timedOut);
144
+ return result;
145
+ }
146
+ const { session } = await createAgentSession({
147
+ cwd, model, thinkingLevel, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager,
148
+ ...(modelRuntime ? { modelRuntime } : {}),
149
+ // Pi 0.80.10 owns credentials in ModelRuntime; older SDKs still accept these.
150
+ ...(authStorage ? { authStorage, modelRegistry } : {}),
151
+ } as any);
152
+ let unsubscribe: (() => void) | undefined;
153
+ let removeAbort: (() => void) | undefined;
154
+ try {
155
+ const eventDone = new Promise<void>((resolve, reject) => {
156
+ let done = false;
157
+ const finish = (fn: () => void) => { if (!done) { done = true; unsubscribe?.(); fn(); } };
158
+ unsubscribe = session.subscribe((event) => {
159
+ try {
160
+ // Any SDK session lifecycle event is actual child activity, unlike a parent heartbeat.
161
+ armIdle(); onProgress?.(snapshot(event.type));
162
+ if (event.type === "message_end") {
163
+ const msg = event.message as AgentMessage;
164
+ if (msg.role === "assistant") {
165
+ result.usage.turns++;
166
+ if (msg.usage) { result.usage.input += msg.usage.input || 0; result.usage.output += msg.usage.output || 0; result.usage.cacheRead += msg.usage.cacheRead || 0; result.usage.cacheWrite += msg.usage.cacheWrite || 0; result.usage.cost += msg.usage.cost?.total || 0; result.usage.contextTokens = msg.usage.totalTokens || 0; }
167
+ if (!result.model && msg.model) result.model = `${msg.provider || "?"}/${msg.model}`;
168
+ if (msg.stopReason) result.stopReason = msg.stopReason;
169
+ result.errorMessage = msg.errorMessage;
170
+ }
171
+ result.messages.push(msg as unknown as Message);
172
+ onMessage?.({ ...result, messages: [...result.messages] });
173
+ } else if (event.type === "agent_end" && !event.willRetry) {
174
+ if (!result.messages.length && event.messages) result.messages = event.messages as unknown as Message[];
175
+ finish(resolve);
176
+ }
177
+ } catch (error) { finish(() => reject(error)); }
178
+ });
179
+ const abort = () => finish(resolve);
180
+ combinedSignal.addEventListener("abort", abort, { once: true });
181
+ removeAbort = () => combinedSignal.removeEventListener("abort", abort);
182
+ });
183
+ const abortSession = () => session.abort();
184
+ combinedSignal.addEventListener("abort", abortSession, { once: true });
185
+ const removeSessionAbort = () => combinedSignal.removeEventListener("abort", abortSession);
186
+ await Promise.race([session.prompt(task), eventDone]);
187
+ removeSessionAbort();
188
+ const timedOut = timeoutController.signal.aborted && !signal?.aborted;
189
+ if (timedOut) { result.stopReason = "timeout"; result.errorMessage = `${timeoutKind === "idle" ? "Idle" : "Hard"} timeout after ${timeoutKind === "idle" ? timeoutMs : hardTimeoutMs}ms`; }
190
+ else if (combinedSignal.aborted) { result.stopReason = "aborted"; result.errorMessage ||= "Sub-agent aborted"; }
191
+ result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
192
+ result.exitCode = result.status === "success" || result.status === "partial" ? 0 : 1;
193
+ return result;
194
+ } finally {
195
+ unsubscribe?.(); removeAbort?.();
196
+ try { session.dispose(); } catch { /* best effort */ }
197
+ }
198
+ } catch (error) {
199
+ result.exitCode = 1;
200
+ result.errorMessage = error instanceof Error ? error.message : String(error);
201
+ result.stopReason ||= "error";
202
+ result.status = classifyStopReason(result.stopReason, false, false);
203
+ return result;
204
+ } finally {
205
+ clearTimers(); cleanupCombined?.();
206
+ }
207
207
  }
208
208
 
209
209
  // ---------------------------------------------------------------------------
@@ -211,57 +211,57 @@ export async function runSubAgent(options: {
211
211
  // ---------------------------------------------------------------------------
212
212
 
213
213
  export function getFinalOutput(messages: Message[]): string {
214
- for (let i = messages.length - 1; i >= 0; i--) {
215
- const msg = messages[i];
216
- if (msg.role !== "assistant") continue;
217
- const texts: string[] = [];
218
- for (const part of msg.content) {
219
- if (part.type === "text" && part.text.trim()) texts.push(part.text);
220
- }
221
- if (texts.length === 0) continue;
222
- return texts.join("");
223
- }
224
- return "";
214
+ for (let i = messages.length - 1; i >= 0; i--) {
215
+ const msg = messages[i];
216
+ if (msg.role !== "assistant") continue;
217
+ const texts: string[] = [];
218
+ for (const part of msg.content) {
219
+ if (part.type === "text" && part.text.trim()) texts.push(part.text);
220
+ }
221
+ if (texts.length === 0) continue;
222
+ return texts.join("");
223
+ }
224
+ return "";
225
225
  }
226
226
 
227
227
  export function isFailedResult(result: SubAgentResult): boolean {
228
- // Use canonical status if available.
229
- if (result.status) {
230
- return result.status === "error" || result.status === "aborted" || result.status === "timeout";
231
- }
232
- // Fall back to legacy heuristics.
233
- return (
234
- result.exitCode !== 0 ||
235
- result.stopReason === "error" ||
236
- result.stopReason === "aborted" ||
237
- result.stopReason === "timeout"
238
- );
228
+ // Use canonical status if available.
229
+ if (result.status) {
230
+ return result.status === "error" || result.status === "aborted" || result.status === "timeout";
231
+ }
232
+ // Fall back to legacy heuristics.
233
+ return (
234
+ result.exitCode !== 0 ||
235
+ result.stopReason === "error" ||
236
+ result.stopReason === "aborted" ||
237
+ result.stopReason === "timeout"
238
+ );
239
239
  }
240
240
 
241
241
  export function getResultOutput(result: SubAgentResult): string {
242
- if (isFailedResult(result)) {
243
- return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
244
- }
245
- return getFinalOutput(result.messages) || "(no output)";
242
+ if (isFailedResult(result)) {
243
+ return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
244
+ }
245
+ return getFinalOutput(result.messages) || "(no output)";
246
246
  }
247
247
 
248
248
  /** Concurrency-limited map. Runs up to `concurrency` async operations at a time. */
249
249
  export async function mapWithConcurrencyLimit<TIn, TOut>(
250
- items: TIn[],
251
- concurrency: number,
252
- fn: (item: TIn, index: number) => Promise<TOut>,
250
+ items: TIn[],
251
+ concurrency: number,
252
+ fn: (item: TIn, index: number) => Promise<TOut>,
253
253
  ): Promise<TOut[]> {
254
- if (items.length === 0) return [];
255
- const limit = Math.max(1, Math.min(concurrency, items.length));
256
- const results: TOut[] = new Array(items.length);
257
- let nextIndex = 0;
258
- const workers = new Array(limit).fill(null).map(async () => {
259
- while (true) {
260
- const current = nextIndex++;
261
- if (current >= items.length) return;
262
- results[current] = await fn(items[current], current);
263
- }
264
- });
265
- await Promise.all(workers);
266
- return results;
254
+ if (items.length === 0) return [];
255
+ const limit = Math.max(1, Math.min(concurrency, items.length));
256
+ const results: TOut[] = new Array(items.length);
257
+ let nextIndex = 0;
258
+ const workers = new Array(limit).fill(null).map(async () => {
259
+ while (true) {
260
+ const current = nextIndex++;
261
+ if (current >= items.length) return;
262
+ results[current] = await fn(items[current], current);
263
+ }
264
+ });
265
+ await Promise.all(workers);
266
+ return results;
267
267
  }