@bacnh85/pi-subagent 0.4.1 → 0.6.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,86 @@
1
+ /**
2
+ * Shared model resolution for pi-subagent.
3
+ *
4
+ * Provides a single canonical resolveModel() used by both the tool handler
5
+ * (index.ts) and the event-driven service path (service.ts), ensuring
6
+ * consistent error reporting across all sub-agent invocation paths.
7
+ *
8
+ * Queries the parent ModelRegistry first (catches custom-configured models
9
+ * with overridden base URLs, headers, compatibility settings). Falls back
10
+ * to the built-in registry for unconfigured models.
11
+ * For unqualified names (no provider prefix), known naming conventions
12
+ * are tried before assuming Anthropic.
13
+ */
14
+
15
+ import { getModel } from "@earendil-works/pi-ai/compat";
16
+ import type { Model } from "@earendil-works/pi-ai";
17
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
18
+
19
+ export interface ResolvedModel {
20
+ model: Model<any> | null;
21
+ attempted: string[];
22
+ }
23
+
24
+ /** Known provider prefixes for unqualified model names. */
25
+ const KNOWN_PROVIDERS: [string, RegExp][] = [
26
+ ["openai", /^gpt-/i],
27
+ ["anthropic", /^claude-/i],
28
+ ["google", /^gemini-/i],
29
+ ["cohere", /^command-/i],
30
+ ["deepseek", /^(deepseek-|ds-)/i],
31
+ ["mistral", /^mistral-/i],
32
+ ["groq", /^(groq-|llama-)/i],
33
+ ];
34
+
35
+ function tryGetModel(
36
+ provider: string,
37
+ id: string,
38
+ modelRegistry?: ModelRegistry,
39
+ ): Model<any> | null {
40
+ // Query parent ModelRegistry first — it includes custom-configured models
41
+ // (overridden base URLs, headers, compatibility settings, per-model overrides).
42
+ // Fall back to built-in registry for unconfigured models.
43
+ if (modelRegistry) {
44
+ const found = modelRegistry.find(provider as any, id as any) ?? null;
45
+ if (found) return found;
46
+ }
47
+ const builtIn = getModel(provider as any, id as any) ?? null;
48
+ if (builtIn) return builtIn;
49
+ return null;
50
+ }
51
+
52
+ export function resolveModel(
53
+ modelName: string | undefined,
54
+ parentModel: Model<any> | undefined,
55
+ modelRegistry?: ModelRegistry,
56
+ ): ResolvedModel {
57
+ const attempted: string[] = [];
58
+ if (modelName) {
59
+ const idx = modelName.indexOf("/");
60
+ if (idx > 0) {
61
+ // Provider-qualified: "openai/gpt-4o" or "openrouter/anthropic/claude-3.5"
62
+ const provider = modelName.slice(0, idx);
63
+ const id = modelName.slice(idx + 1);
64
+ attempted.push(modelName);
65
+ const found = tryGetModel(provider, id, modelRegistry);
66
+ if (found) return { model: found, attempted };
67
+ } else {
68
+ // Unqualified: try known providers by naming convention
69
+ for (const [provider, pattern] of KNOWN_PROVIDERS) {
70
+ if (pattern.test(modelName)) {
71
+ attempted.push(`${provider}/${modelName}`);
72
+ const found = tryGetModel(provider, modelName, modelRegistry);
73
+ if (found) return { model: found, attempted };
74
+ }
75
+ }
76
+ // Fall back to Anthropic shorthand (backward compat)
77
+ attempted.push(`anthropic/${modelName}`);
78
+ const found = tryGetModel("anthropic", modelName, modelRegistry);
79
+ if (found) return { model: found, attempted };
80
+ }
81
+ } else if (parentModel) {
82
+ attempted.push(`${parentModel.provider}/${parentModel.id}`);
83
+ return { model: parentModel, attempted };
84
+ }
85
+ return { model: null, attempted };
86
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * TUI rendering for pi-sugagents.
2
+ * TUI rendering for pi-subagent.
3
3
  *
4
4
  * Renders sub-agent results in collapsed and expanded views.
5
5
  * Collapsed: status icon, agent name, last few items, usage stats.
@@ -16,15 +16,15 @@ import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.t
16
16
  // Safe type guards
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
- function asString(value: unknown, fallback = "..."): string {
19
+ export function asString(value: unknown, fallback = "..."): string {
20
20
  return typeof value === "string" ? value : fallback;
21
21
  }
22
22
 
23
- function asNumber(value: unknown): number | undefined {
23
+ export function asNumber(value: unknown): number | undefined {
24
24
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
25
25
  }
26
26
 
27
- function asRecord(value: unknown): Record<string, unknown> {
27
+ export function asRecord(value: unknown): Record<string, unknown> {
28
28
  return value && typeof value === "object" && !Array.isArray(value)
29
29
  ? (value as Record<string, unknown>)
30
30
  : {};
@@ -59,7 +59,7 @@ export function formatUsageStats(
59
59
  return parts.join(" ");
60
60
  }
61
61
 
62
- function formatToolCall(
62
+ export function formatToolCall(
63
63
  toolName: string,
64
64
  args: Record<string, unknown>,
65
65
  themeFg: (color: string, text: string) => string,
@@ -130,16 +130,16 @@ function formatToolCall(
130
130
  }
131
131
  }
132
132
 
133
- type DisplayItem =
133
+ export type DisplayItem =
134
134
  | { type: "text"; text: string }
135
135
  | { type: "toolCall"; name: string; args: Record<string, unknown> };
136
136
 
137
- function getDisplayItems(messages: Message[]): DisplayItem[] {
137
+ export function getDisplayItems(messages: Message[]): DisplayItem[] {
138
138
  const items: DisplayItem[] = [];
139
139
  for (const msg of messages) {
140
140
  if (msg.role === "assistant") {
141
141
  for (const part of msg.content) {
142
- if (part.type === "text") {
142
+ if (part.type === "text" && part.text.trim()) {
143
143
  items.push({ type: "text", text: part.text });
144
144
  } else if (part.type === "toolCall") {
145
145
  items.push({
@@ -187,7 +187,7 @@ function renderDisplayItems(
187
187
  export function renderSingleResult(
188
188
  result: SubAgentResult,
189
189
  expanded: boolean,
190
- theme: { fg: (c: string, t: string) => string; bold: (t: string) => string },
190
+ theme: { fg: (c: any, t: string) => string; bold: (t: string) => string },
191
191
  ): Container | Text {
192
192
  const isError = isFailedResult(result);
193
193
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SDK-based sub-agent runner for pi-sugagents.
2
+ * SDK-based sub-agent runner for pi-subagent.
3
3
  *
4
4
  * Creates an in-process AgentSession via the pi SDK instead of spawning a
5
5
  * separate `pi` process. This eliminates cold-start overhead and allows
@@ -25,6 +25,11 @@ import {
25
25
  SessionManager,
26
26
  SettingsManager,
27
27
  } from "@earendil-works/pi-coding-agent";
28
+ import {
29
+ classifyStopReason,
30
+ createCombinedAbortSignal,
31
+ type SubagentStatus,
32
+ } from "./security.ts";
28
33
 
29
34
  // ---------------------------------------------------------------------------
30
35
  // Types
@@ -50,6 +55,8 @@ export interface SubAgentResult {
50
55
  model?: string;
51
56
  stopReason?: string;
52
57
  errorMessage?: string;
58
+ /** Canonical result status (added in 0.6.0). */
59
+ status?: SubagentStatus;
53
60
  }
54
61
 
55
62
  // ---------------------------------------------------------------------------
@@ -61,13 +68,15 @@ export async function runSubAgent(options: {
61
68
  systemPrompt: string;
62
69
  task: string;
63
70
  tools: string[];
64
- model: Model;
71
+ model: Model<any>;
65
72
  authStorage: AuthStorage;
66
73
  modelRegistry: ModelRegistry;
67
74
  signal?: AbortSignal;
68
75
  agentName?: string;
69
- onUpdate?: (text: string) => void;
76
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
70
77
  onMessage?: (partialResult: SubAgentResult) => void;
78
+ /** Pre-validated timeout in ms. When provided, an abort signal will be created. */
79
+ timeoutMs?: number;
71
80
  }): Promise<SubAgentResult> {
72
81
  const {
73
82
  cwd,
@@ -79,8 +88,9 @@ export async function runSubAgent(options: {
79
88
  modelRegistry,
80
89
  signal,
81
90
  agentName = "subagent",
82
- onUpdate,
91
+ thinkingLevel = "off",
83
92
  onMessage,
93
+ timeoutMs,
84
94
  } = options;
85
95
 
86
96
  const result: SubAgentResult = {
@@ -91,6 +101,7 @@ export async function runSubAgent(options: {
91
101
  stderr: "",
92
102
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
93
103
  model: `${model.provider}/${model.id}`,
104
+ status: undefined,
94
105
  };
95
106
 
96
107
  // Build a minimal resource loader. The sub-agent sees ONLY the agent's
@@ -112,18 +123,38 @@ export async function runSubAgent(options: {
112
123
  retry: { enabled: false },
113
124
  });
114
125
 
126
+ // Hoisted so the outer catch can clean up on early failure.
127
+ let timeoutController: AbortController | undefined;
128
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
129
+ let cleanupCombined: (() => void) | undefined;
130
+
115
131
  try {
116
- if (signal?.aborted) {
132
+ // Build combined signal from parent signal and timeout
133
+ const signalsToCombine: (AbortSignal | undefined | null | false)[] = [signal];
134
+
135
+ // Create timeout controller
136
+ if (timeoutMs && timeoutMs > 0) {
137
+ timeoutController = new AbortController();
138
+ timeoutId = setTimeout(() => timeoutController!.abort(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
139
+ signalsToCombine.push(timeoutController.signal);
140
+ }
141
+
142
+ const { signal: combinedSignal, cleanup: cleanupCb } = createCombinedAbortSignal(signalsToCombine);
143
+ cleanupCombined = cleanupCb;
144
+
145
+ if (combinedSignal.aborted) {
117
146
  result.exitCode = 1;
118
- result.stopReason = "aborted";
119
- result.errorMessage = "Sub-agent aborted before start";
147
+ const isTimeout = timeoutController?.signal.aborted === true && signal?.aborted !== true;
148
+ result.stopReason = isTimeout ? "timeout" : "aborted";
149
+ result.errorMessage = combinedSignal.reason instanceof Error ? combinedSignal.reason.message : "Sub-agent aborted before start";
150
+ result.status = classifyStopReason(result.stopReason, !isTimeout, isTimeout);
120
151
  return result;
121
152
  }
122
153
 
123
154
  const { session } = await createAgentSession({
124
155
  cwd,
125
156
  model,
126
- thinkingLevel: "off", // no reasoning token overhead
157
+ thinkingLevel,
127
158
  authStorage,
128
159
  modelRegistry,
129
160
  resourceLoader,
@@ -134,21 +165,22 @@ export async function runSubAgent(options: {
134
165
 
135
166
  let cleanupAbort: (() => void) | undefined;
136
167
  let cleanupEventAbort: (() => void) | undefined;
168
+ let abortedBySignal = false;
169
+ let timedOut = false;
170
+
137
171
  try {
138
- // Wire abort signal
139
- if (signal) {
140
- const onAbort = () => session.abort();
141
- if (signal.aborted) {
142
- // Already aborted — shortcut
143
- result.exitCode = 1;
144
- result.stopReason = "aborted";
145
- result.errorMessage = "Sub-agent aborted before start";
146
- onAbort();
147
- return result;
148
- }
149
- signal.addEventListener("abort", onAbort, { once: true });
150
- cleanupAbort = () => signal.removeEventListener("abort", onAbort);
172
+ // Wire combined abort signal to session
173
+ const onAbort = () => {
174
+ session.abort();
175
+ };
176
+ if (combinedSignal.aborted) {
177
+ abortedBySignal = true;
178
+ timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
179
+ onAbort();
180
+ return result;
151
181
  }
182
+ combinedSignal.addEventListener("abort", onAbort, { once: true });
183
+ cleanupAbort = () => combinedSignal.removeEventListener("abort", onAbort);
152
184
 
153
185
  // Collect all messages and usage stats from events
154
186
  const eventPromise = new Promise<void>((resolve, reject) => {
@@ -206,31 +238,48 @@ export async function runSubAgent(options: {
206
238
  });
207
239
 
208
240
  // Resolve on abort so the eventPromise doesn't hang
209
- if (signal) {
210
- const onAbortResolve = () => {
211
- finish(() => {
212
- result.exitCode = 1;
213
- result.stopReason = "aborted";
214
- if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
215
- unsubscribe();
216
- resolve();
217
- });
218
- };
219
- signal.addEventListener("abort", onAbortResolve, { once: true });
220
- cleanupEventAbort = () => signal.removeEventListener("abort", onAbortResolve);
221
- }
241
+ const onAbortResolve = () => {
242
+ finish(() => {
243
+ result.exitCode = 1;
244
+ if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
245
+ unsubscribe();
246
+ resolve();
247
+ });
248
+ };
249
+ combinedSignal.addEventListener("abort", onAbortResolve, { once: true });
250
+ cleanupEventAbort = () => combinedSignal.removeEventListener("abort", onAbortResolve);
222
251
  });
223
252
 
224
- await session.prompt(task);
225
- await eventPromise;
253
+ await Promise.race([
254
+ session.prompt(task),
255
+ eventPromise,
256
+ ]);
257
+
258
+ // Detect timeout vs. parent abort.
259
+ timedOut = timeoutController?.signal.aborted === true && signal?.aborted !== true;
260
+ abortedBySignal = combinedSignal.aborted && !timedOut;
226
261
 
227
- if (result.stopReason !== "aborted") {
262
+ if (timedOut) {
263
+ result.exitCode = 1;
264
+ result.stopReason = "timeout";
265
+ result.errorMessage ||= `Timeout after ${timeoutMs}ms`;
266
+ } else if (abortedBySignal) {
267
+ result.exitCode = 1;
268
+ result.stopReason = "aborted";
269
+ result.errorMessage ||= "Sub-agent aborted";
270
+ } else {
228
271
  result.exitCode = 0;
229
272
  }
273
+
274
+ // Classify canonical status.
275
+ result.status = classifyStopReason(result.stopReason, result.stopReason === "aborted", result.stopReason === "timeout");
276
+
230
277
  return result;
231
278
  } finally {
232
279
  cleanupAbort?.();
233
280
  cleanupEventAbort?.();
281
+ cleanupCombined();
282
+ if (timeoutId) clearTimeout(timeoutId);
234
283
  try {
235
284
  session.dispose();
236
285
  } catch {
@@ -242,6 +291,10 @@ export async function runSubAgent(options: {
242
291
  result.exitCode = 1;
243
292
  result.errorMessage = message;
244
293
  if (!result.stopReason) result.stopReason = "error";
294
+ result.status = classifyStopReason("error", false, false);
295
+ // Ensure cleanup runs even when the outer try fails before the inner finally.
296
+ cleanupCombined?.();
297
+ if (timeoutId) clearTimeout(timeoutId);
245
298
  return result;
246
299
  }
247
300
  }
@@ -251,31 +304,25 @@ export async function runSubAgent(options: {
251
304
  // ---------------------------------------------------------------------------
252
305
 
253
306
  export function getFinalOutput(messages: Message[]): string {
254
- // Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
255
307
  for (let i = messages.length - 1; i >= 0; i--) {
256
308
  const msg = messages[i];
257
309
  if (msg.role !== "assistant") continue;
258
310
  const texts: string[] = [];
259
- let hasToolCalls = false;
260
311
  for (const part of msg.content) {
261
312
  if (part.type === "text" && part.text.trim()) texts.push(part.text);
262
- else if (part.type === "toolCall") hasToolCalls = true;
263
313
  }
264
- if (texts.length > 0 && !hasToolCalls) return texts.join("");
265
- }
266
- // Fallback: last assistant message with any non-empty text (even if it also has tool calls).
267
- for (let i = messages.length - 1; i >= 0; i--) {
268
- const msg = messages[i];
269
- if (msg.role !== "assistant") continue;
270
- const texts = msg.content
271
- .filter((p): p is { type: "text"; text: string } => p.type === "text" && p.text.trim().length > 0)
272
- .map((p) => p.text);
273
- if (texts.length > 0) return texts.join("");
314
+ if (texts.length === 0) continue;
315
+ return texts.join("");
274
316
  }
275
317
  return "";
276
318
  }
277
319
 
278
320
  export function isFailedResult(result: SubAgentResult): boolean {
321
+ // Use canonical status if available.
322
+ if (result.status) {
323
+ return result.status === "error" || result.status === "aborted" || result.status === "timeout";
324
+ }
325
+ // Fall back to legacy heuristics.
279
326
  return (
280
327
  result.exitCode !== 0 ||
281
328
  result.stopReason === "error" ||