@bacnh85/pi-subagent 0.3.1 → 0.5.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.
@@ -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.
@@ -12,6 +12,24 @@ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
12
12
  import type { Message } from "@earendil-works/pi-ai";
13
13
  import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.ts";
14
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // Safe type guards
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function asString(value: unknown, fallback = "..."): string {
20
+ return typeof value === "string" ? value : fallback;
21
+ }
22
+
23
+ function asNumber(value: unknown): number | undefined {
24
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
25
+ }
26
+
27
+ function asRecord(value: unknown): Record<string, unknown> {
28
+ return value && typeof value === "object" && !Array.isArray(value)
29
+ ? (value as Record<string, unknown>)
30
+ : {};
31
+ }
32
+
15
33
  // ---------------------------------------------------------------------------
16
34
  // Display helpers
17
35
  // ---------------------------------------------------------------------------
@@ -53,15 +71,15 @@ function formatToolCall(
53
71
 
54
72
  switch (toolName) {
55
73
  case "bash": {
56
- const command = (args.command as string) || "...";
74
+ const command = asString(args.command);
57
75
  const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
58
76
  return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
59
77
  }
60
78
  case "read": {
61
- const rawPath = (args.file_path || args.path || "...") as string;
79
+ const rawPath = asString(args.file_path ?? args.path);
62
80
  const filePath = shortenPath(rawPath);
63
- const offset = args.offset as number | undefined;
64
- const limit = args.limit as number | undefined;
81
+ const offset = asNumber(args.offset);
82
+ const limit = asNumber(args.limit);
65
83
  let text = themeFg("accent", filePath);
66
84
  if (offset !== undefined || limit !== undefined) {
67
85
  const startLine = offset ?? 1;
@@ -71,24 +89,24 @@ function formatToolCall(
71
89
  return themeFg("muted", "read ") + text;
72
90
  }
73
91
  case "write": {
74
- const rawPath = (args.file_path || args.path || "...") as string;
75
- const content = (args.content || "") as string;
92
+ const rawPath = asString(args.file_path ?? args.path);
93
+ const content = asString(args.content, "");
76
94
  const lines = content.split("\n").length;
77
95
  let text = themeFg("muted", "write ") + themeFg("accent", shortenPath(rawPath));
78
96
  if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
79
97
  return text;
80
98
  }
81
99
  case "edit": {
82
- const rawPath = (args.file_path || args.path || "...") as string;
100
+ const rawPath = asString(args.file_path ?? args.path);
83
101
  return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
84
102
  }
85
103
  case "ls": {
86
- const rawPath = (args.path || ".") as string;
104
+ const rawPath = asString(args.path, ".");
87
105
  return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
88
106
  }
89
107
  case "find": {
90
- const pattern = (args.pattern || "*") as string;
91
- const rawPath = (args.path || ".") as string;
108
+ const pattern = asString(args.pattern, "*");
109
+ const rawPath = asString(args.path, ".");
92
110
  return (
93
111
  themeFg("muted", "find ") +
94
112
  themeFg("accent", pattern) +
@@ -96,8 +114,8 @@ function formatToolCall(
96
114
  );
97
115
  }
98
116
  case "grep": {
99
- const pattern = (args.pattern || "") as string;
100
- const rawPath = (args.path || ".") as string;
117
+ const pattern = asString(args.pattern);
118
+ const rawPath = asString(args.path, ".");
101
119
  return (
102
120
  themeFg("muted", "grep ") +
103
121
  themeFg("accent", `/${pattern}/`) +
@@ -127,7 +145,7 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
127
145
  items.push({
128
146
  type: "toolCall",
129
147
  name: part.name,
130
- args: part.arguments as Record<string, unknown>,
148
+ args: asRecord(part.arguments),
131
149
  });
132
150
  }
133
151
  }
@@ -169,7 +187,7 @@ function renderDisplayItems(
169
187
  export function renderSingleResult(
170
188
  result: SubAgentResult,
171
189
  expanded: boolean,
172
- theme: { fg: (c: string, t: string) => string; bold: (t: string) => string },
190
+ theme: { fg: (c: any, t: string) => string; bold: (t: string) => string },
173
191
  ): Container | Text {
174
192
  const isError = isFailedResult(result);
175
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
@@ -61,12 +61,14 @@ export async function runSubAgent(options: {
61
61
  systemPrompt: string;
62
62
  task: string;
63
63
  tools: string[];
64
- model: Model;
64
+ model: Model<any>;
65
65
  authStorage: AuthStorage;
66
66
  modelRegistry: ModelRegistry;
67
67
  signal?: AbortSignal;
68
68
  agentName?: string;
69
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
69
70
  onUpdate?: (text: string) => void;
71
+ onMessage?: (partialResult: SubAgentResult) => void;
70
72
  }): Promise<SubAgentResult> {
71
73
  const {
72
74
  cwd,
@@ -78,7 +80,9 @@ export async function runSubAgent(options: {
78
80
  modelRegistry,
79
81
  signal,
80
82
  agentName = "subagent",
83
+ thinkingLevel = "off",
81
84
  onUpdate,
85
+ onMessage,
82
86
  } = options;
83
87
 
84
88
  const result: SubAgentResult = {
@@ -111,10 +115,17 @@ export async function runSubAgent(options: {
111
115
  });
112
116
 
113
117
  try {
118
+ if (signal?.aborted) {
119
+ result.exitCode = 1;
120
+ result.stopReason = "aborted";
121
+ result.errorMessage = "Sub-agent aborted before start";
122
+ return result;
123
+ }
124
+
114
125
  const { session } = await createAgentSession({
115
126
  cwd,
116
127
  model,
117
- thinkingLevel: "off", // no reasoning token overhead
128
+ thinkingLevel,
118
129
  authStorage,
119
130
  modelRegistry,
120
131
  resourceLoader,
@@ -124,6 +135,7 @@ export async function runSubAgent(options: {
124
135
  });
125
136
 
126
137
  let cleanupAbort: (() => void) | undefined;
138
+ let cleanupEventAbort: (() => void) | undefined;
127
139
  try {
128
140
  // Wire abort signal
129
141
  if (signal) {
@@ -142,6 +154,13 @@ export async function runSubAgent(options: {
142
154
 
143
155
  // Collect all messages and usage stats from events
144
156
  const eventPromise = new Promise<void>((resolve, reject) => {
157
+ let settled = false;
158
+ const finish = (fn: () => void) => {
159
+ if (settled) return;
160
+ settled = true;
161
+ fn();
162
+ };
163
+
145
164
  const unsubscribe = session.subscribe((event) => {
146
165
  try {
147
166
  switch (event.type) {
@@ -165,6 +184,7 @@ export async function runSubAgent(options: {
165
184
  }
166
185
  // Collect all messages for extraction
167
186
  result.messages.push(msg as unknown as Message);
187
+ if (onMessage) onMessage({ ...result, messages: [...result.messages] });
168
188
  break;
169
189
  }
170
190
  case "agent_end": {
@@ -172,25 +192,49 @@ export async function runSubAgent(options: {
172
192
  if (result.messages.length === 0 && event.messages) {
173
193
  result.messages = event.messages as unknown as Message[];
174
194
  }
175
- unsubscribe();
176
- resolve();
195
+ finish(() => {
196
+ unsubscribe();
197
+ resolve();
198
+ });
177
199
  break;
178
200
  }
179
201
  }
180
202
  } catch (err) {
181
- unsubscribe();
182
- reject(err);
203
+ finish(() => {
204
+ unsubscribe();
205
+ reject(err);
206
+ });
183
207
  }
184
208
  });
209
+
210
+ // Resolve on abort so the eventPromise doesn't hang
211
+ if (signal) {
212
+ const onAbortResolve = () => {
213
+ finish(() => {
214
+ result.exitCode = 1;
215
+ result.stopReason = "aborted";
216
+ if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
217
+ unsubscribe();
218
+ resolve();
219
+ });
220
+ };
221
+ signal.addEventListener("abort", onAbortResolve, { once: true });
222
+ cleanupEventAbort = () => signal.removeEventListener("abort", onAbortResolve);
223
+ }
185
224
  });
186
225
 
187
- await session.prompt(task);
188
- await eventPromise;
226
+ await Promise.race([
227
+ session.prompt(task),
228
+ eventPromise,
229
+ ]);
189
230
 
190
- result.exitCode = 0;
231
+ if (result.stopReason !== "aborted") {
232
+ result.exitCode = 0;
233
+ }
191
234
  return result;
192
235
  } finally {
193
236
  cleanupAbort?.();
237
+ cleanupEventAbort?.();
194
238
  try {
195
239
  session.dispose();
196
240
  } catch {
@@ -0,0 +1,79 @@
1
+ import { AuthStorage, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentConfig } from "./agents.ts";
3
+ import { runSubAgent, type SubAgentResult } from "./runner.ts";
4
+ import { resolveModel } from "./model.ts";
5
+
6
+ export const SUBAGENT_REQUEST_EVENT = "pi-subagent:run";
7
+
8
+ export interface SubagentRunRequest {
9
+ id: string;
10
+ agent: string;
11
+ task: string;
12
+ cwd?: string;
13
+ timeout?: number;
14
+ instructions?: string;
15
+ readOnly?: boolean;
16
+ signal?: AbortSignal;
17
+ accept?: () => boolean;
18
+ respond: (response: SubagentRunResponse) => void;
19
+ }
20
+
21
+ export type SubagentRunResponse =
22
+ | { id: string; ok: true; result: SubAgentResult }
23
+ | { id: string; ok: false; error: string };
24
+
25
+ export async function runNamedAgent(options: {
26
+ agent: AgentConfig;
27
+ task: string;
28
+ cwd: string;
29
+ ctx: ExtensionContext;
30
+ timeout?: number;
31
+ instructions?: string;
32
+ signal?: AbortSignal;
33
+ onMessage?: (result: SubAgentResult) => void;
34
+ }): Promise<SubAgentResult> {
35
+ const { model, attempted } = resolveModel(options.agent.model, options.ctx.model, options.ctx.modelRegistry);
36
+ if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
37
+
38
+ const authStorage = AuthStorage.inMemory();
39
+ const modelRegistry = options.ctx.modelRegistry;
40
+ const auth = await options.ctx.modelRegistry.getApiKeyAndHeaders(model);
41
+ if (auth.ok) {
42
+ if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
43
+ // ponytail: env and headers stay on the parent modelRegistry — reuse it directly.
44
+ }
45
+
46
+ const timeoutController = options.timeout && options.timeout > 0 ? new AbortController() : undefined;
47
+ const timeoutId = timeoutController ? setTimeout(() => timeoutController.abort(), options.timeout) : undefined;
48
+ const signals = [options.signal, timeoutController?.signal].filter((value): value is AbortSignal => Boolean(value));
49
+ const signal = signals.length > 1
50
+ ? typeof (AbortSignal as any).any === "function"
51
+ ? (AbortSignal as any).any(signals)
52
+ : signals[0]
53
+ : signals[0];
54
+ const contract = options.instructions?.slice(0, 16 * 1024);
55
+
56
+ try {
57
+ const result = await runSubAgent({
58
+ cwd: options.cwd,
59
+ systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
60
+ task: options.task,
61
+ tools: (options.agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"]).filter((tool) => tool !== "subagent"),
62
+ model,
63
+ authStorage,
64
+ modelRegistry,
65
+ signal,
66
+ agentName: options.agent.name,
67
+ thinkingLevel: options.agent.thinking,
68
+ onMessage: options.onMessage,
69
+ });
70
+ if (timeoutController?.signal.aborted && !options.signal?.aborted) {
71
+ result.exitCode = 1;
72
+ result.stopReason = "timeout";
73
+ result.errorMessage ||= `Timeout after ${options.timeout}ms`;
74
+ }
75
+ return result;
76
+ } finally {
77
+ if (timeoutId) clearTimeout(timeoutId);
78
+ }
79
+ }