@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.
- package/CHANGELOG.md +56 -0
- package/README.md +139 -64
- package/agent-format.md +20 -5
- package/agents/reviewer.md +28 -22
- package/agents/scout.md +3 -3
- package/agents/worker.md +2 -2
- package/{agents.ts → extensions/agents.ts} +95 -11
- package/{index.ts → extensions/index.ts} +314 -265
- package/extensions/model.ts +86 -0
- package/extensions/package.json +3 -0
- package/{render.ts → extensions/render.ts} +9 -9
- package/{runner.ts → extensions/runner.ts} +97 -50
- package/extensions/security.ts +504 -0
- package/extensions/service.ts +90 -0
- package/{thread-viewer.ts → extensions/thread-viewer.ts} +2 -126
- package/{threads.ts → extensions/threads.ts} +1 -11
- package/package.json +47 -27
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TUI rendering for pi-
|
|
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:
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
result.
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
225
|
-
|
|
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 (
|
|
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
|
|
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" ||
|