@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.
- package/README.md +28 -64
- package/agent-format.md +8 -3
- package/agents/reviewer.md +28 -22
- package/agents/scout.md +3 -3
- package/agents/worker.md +2 -2
- package/{agents.ts → extensions/agents.ts} +18 -9
- package/{index.ts → extensions/index.ts} +411 -117
- package/extensions/model.ts +86 -0
- package/extensions/package.json +3 -0
- package/{render.ts → extensions/render.ts} +33 -15
- package/{runner.ts → extensions/runner.ts} +54 -10
- package/extensions/service.ts +79 -0
- package/extensions/thread-viewer.ts +393 -0
- package/extensions/threads.ts +116 -0
- package/package.json +36 -29
|
@@ -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.
|
|
@@ -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
|
|
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
|
|
79
|
+
const rawPath = asString(args.file_path ?? args.path);
|
|
62
80
|
const filePath = shortenPath(rawPath);
|
|
63
|
-
const offset = args.offset
|
|
64
|
-
const limit = args.limit
|
|
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
|
|
75
|
-
const content = (args.content
|
|
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
|
|
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
|
|
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
|
|
91
|
-
const rawPath = (args.path
|
|
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
|
|
100
|
-
const rawPath = (args.path
|
|
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
|
|
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:
|
|
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-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
195
|
+
finish(() => {
|
|
196
|
+
unsubscribe();
|
|
197
|
+
resolve();
|
|
198
|
+
});
|
|
177
199
|
break;
|
|
178
200
|
}
|
|
179
201
|
}
|
|
180
202
|
} catch (err) {
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
188
|
-
|
|
226
|
+
await Promise.race([
|
|
227
|
+
session.prompt(task),
|
|
228
|
+
eventPromise,
|
|
229
|
+
]);
|
|
189
230
|
|
|
190
|
-
result.
|
|
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
|
+
}
|