@nmzpy/pi-ember-stack 0.1.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/ATTRIBUTION.md +9 -0
- package/README.md +63 -0
- package/package.json +50 -0
- package/src/pi-ember-stack.ts +983 -0
- package/src/questionnaire-tool.ts +287 -0
- package/src/subagent/LICENSE +21 -0
- package/src/subagent/README.md +52 -0
- package/src/subagent/agent-format.md +64 -0
- package/src/subagent/agents/architect.md +56 -0
- package/src/subagent/agents/coder.md +35 -0
- package/src/subagent/agents/general-purpose.md +13 -0
- package/src/subagent/agents/reviewer.md +36 -0
- package/src/subagent/agents/scout.md +44 -0
- package/src/subagent/agents/worker.md +15 -0
- package/src/subagent/extensions/agents.ts +223 -0
- package/src/subagent/extensions/index.ts +1218 -0
- package/src/subagent/extensions/model.ts +86 -0
- package/src/subagent/extensions/package.json +3 -0
- package/src/subagent/extensions/render.ts +278 -0
- package/src/subagent/extensions/runner.ts +317 -0
- package/src/subagent/extensions/service.ts +79 -0
- package/src/subagent/extensions/thread-viewer.ts +393 -0
- package/src/subagent/extensions/threads.ts +116 -0
- package/tsconfig.json +13 -0
|
@@ -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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering for pi-subagent.
|
|
3
|
+
*
|
|
4
|
+
* Renders sub-agent results in collapsed and expanded views.
|
|
5
|
+
* Collapsed: status icon, agent name, last few items, usage stats.
|
|
6
|
+
* Expanded (Ctrl+O): full task text, all tool calls, final markdown output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
13
|
+
import { type SubAgentResult, isFailedResult, getResultOutput } from "./runner.ts";
|
|
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
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Display helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function formatTokens(count: number): string {
|
|
38
|
+
if (count < 1000) return count.toString();
|
|
39
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
40
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
41
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatUsageStats(
|
|
45
|
+
usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens?: number; turns?: number },
|
|
46
|
+
model?: string,
|
|
47
|
+
): string {
|
|
48
|
+
const parts: string[] = [];
|
|
49
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
50
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
51
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
52
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
53
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
54
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
55
|
+
if (usage.contextTokens && usage.contextTokens > 0) {
|
|
56
|
+
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
57
|
+
}
|
|
58
|
+
if (model) parts.push(model);
|
|
59
|
+
return parts.join(" ");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatToolCall(
|
|
63
|
+
toolName: string,
|
|
64
|
+
args: Record<string, unknown>,
|
|
65
|
+
themeFg: (color: string, text: string) => string,
|
|
66
|
+
): string {
|
|
67
|
+
const shortenPath = (p: string) => {
|
|
68
|
+
const home = os.homedir();
|
|
69
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
switch (toolName) {
|
|
73
|
+
case "bash": {
|
|
74
|
+
const command = asString(args.command);
|
|
75
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
76
|
+
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
|
|
77
|
+
}
|
|
78
|
+
case "read": {
|
|
79
|
+
const rawPath = asString(args.file_path ?? args.path);
|
|
80
|
+
const filePath = shortenPath(rawPath);
|
|
81
|
+
const offset = asNumber(args.offset);
|
|
82
|
+
const limit = asNumber(args.limit);
|
|
83
|
+
let text = themeFg("accent", filePath);
|
|
84
|
+
if (offset !== undefined || limit !== undefined) {
|
|
85
|
+
const startLine = offset ?? 1;
|
|
86
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
87
|
+
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
88
|
+
}
|
|
89
|
+
return themeFg("muted", "read ") + text;
|
|
90
|
+
}
|
|
91
|
+
case "write": {
|
|
92
|
+
const rawPath = asString(args.file_path ?? args.path);
|
|
93
|
+
const content = asString(args.content, "");
|
|
94
|
+
const lines = content.split("\n").length;
|
|
95
|
+
let text = themeFg("muted", "write ") + themeFg("accent", shortenPath(rawPath));
|
|
96
|
+
if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
case "edit": {
|
|
100
|
+
const rawPath = asString(args.file_path ?? args.path);
|
|
101
|
+
return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
|
|
102
|
+
}
|
|
103
|
+
case "ls": {
|
|
104
|
+
const rawPath = asString(args.path, ".");
|
|
105
|
+
return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
|
|
106
|
+
}
|
|
107
|
+
case "find": {
|
|
108
|
+
const pattern = asString(args.pattern, "*");
|
|
109
|
+
const rawPath = asString(args.path, ".");
|
|
110
|
+
return (
|
|
111
|
+
themeFg("muted", "find ") +
|
|
112
|
+
themeFg("accent", pattern) +
|
|
113
|
+
themeFg("dim", ` in ${shortenPath(rawPath)}`)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
case "grep": {
|
|
117
|
+
const pattern = asString(args.pattern);
|
|
118
|
+
const rawPath = asString(args.path, ".");
|
|
119
|
+
return (
|
|
120
|
+
themeFg("muted", "grep ") +
|
|
121
|
+
themeFg("accent", `/${pattern}/`) +
|
|
122
|
+
themeFg("dim", ` in ${shortenPath(rawPath)}`)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
default: {
|
|
126
|
+
const argsStr = JSON.stringify(args);
|
|
127
|
+
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
|
128
|
+
return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type DisplayItem =
|
|
134
|
+
| { type: "text"; text: string }
|
|
135
|
+
| { type: "toolCall"; name: string; args: Record<string, unknown> };
|
|
136
|
+
|
|
137
|
+
function getDisplayItems(messages: Message[]): DisplayItem[] {
|
|
138
|
+
const items: DisplayItem[] = [];
|
|
139
|
+
for (const msg of messages) {
|
|
140
|
+
if (msg.role === "assistant") {
|
|
141
|
+
for (const part of msg.content) {
|
|
142
|
+
if (part.type === "text") {
|
|
143
|
+
items.push({ type: "text", text: part.text });
|
|
144
|
+
} else if (part.type === "toolCall") {
|
|
145
|
+
items.push({
|
|
146
|
+
type: "toolCall",
|
|
147
|
+
name: part.name,
|
|
148
|
+
args: asRecord(part.arguments),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return items;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Collapsed renderer
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
const COLLAPSED_ITEM_COUNT = 10;
|
|
162
|
+
|
|
163
|
+
function renderDisplayItems(
|
|
164
|
+
items: DisplayItem[],
|
|
165
|
+
theme: { fg: (c: string, t: string) => string },
|
|
166
|
+
limit?: number,
|
|
167
|
+
): string {
|
|
168
|
+
const toShow = limit ? items.slice(-limit) : items;
|
|
169
|
+
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
170
|
+
let text = "";
|
|
171
|
+
if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
|
|
172
|
+
for (const item of toShow) {
|
|
173
|
+
if (item.type === "text") {
|
|
174
|
+
const preview = item.text.split("\n").slice(0, 3).join("\n");
|
|
175
|
+
text += `${theme.fg("toolOutput", preview)}\n`;
|
|
176
|
+
} else {
|
|
177
|
+
text += `${theme.fg("muted", "→ ")}${formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return text.trimEnd();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Single agent result
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export function renderSingleResult(
|
|
188
|
+
result: SubAgentResult,
|
|
189
|
+
expanded: boolean,
|
|
190
|
+
theme: { fg: (c: any, t: string) => string; bold: (t: string) => string },
|
|
191
|
+
): Container | Text {
|
|
192
|
+
const isError = isFailedResult(result);
|
|
193
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
194
|
+
const displayItems = getDisplayItems(result.messages);
|
|
195
|
+
const finalOutput = getResultOutput(result);
|
|
196
|
+
|
|
197
|
+
if (expanded) {
|
|
198
|
+
const mdTheme = getMarkdownTheme();
|
|
199
|
+
const container = new Container();
|
|
200
|
+
let header = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
|
|
201
|
+
if (isError && result.stopReason) {
|
|
202
|
+
const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
203
|
+
header += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
|
|
204
|
+
}
|
|
205
|
+
container.addChild(new Text(header, 0, 0));
|
|
206
|
+
if (isError && result.errorMessage) {
|
|
207
|
+
const messageColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
208
|
+
container.addChild(new Text(theme.fg(messageColor, `Error: ${result.errorMessage}`), 0, 0));
|
|
209
|
+
}
|
|
210
|
+
container.addChild(new Spacer(1));
|
|
211
|
+
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
212
|
+
container.addChild(new Text(theme.fg("dim", result.task), 0, 0));
|
|
213
|
+
container.addChild(new Spacer(1));
|
|
214
|
+
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
|
215
|
+
if (displayItems.length === 0 && !finalOutput) {
|
|
216
|
+
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
217
|
+
} else {
|
|
218
|
+
for (const item of displayItems) {
|
|
219
|
+
if (item.type === "toolCall") {
|
|
220
|
+
container.addChild(
|
|
221
|
+
new Text(
|
|
222
|
+
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
|
223
|
+
0, 0,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (finalOutput) {
|
|
229
|
+
container.addChild(new Spacer(1));
|
|
230
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const usageStr = formatUsageStats(result.usage, result.model);
|
|
234
|
+
if (usageStr) {
|
|
235
|
+
container.addChild(new Spacer(1));
|
|
236
|
+
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
|
237
|
+
}
|
|
238
|
+
return container;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Collapsed
|
|
242
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
|
|
243
|
+
if (isError && result.stopReason) {
|
|
244
|
+
const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
245
|
+
text += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
|
|
246
|
+
}
|
|
247
|
+
if (isError && result.errorMessage) {
|
|
248
|
+
const messageColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
249
|
+
text += `\n${theme.fg(messageColor, `Error: ${result.errorMessage}`)}`;
|
|
250
|
+
}
|
|
251
|
+
else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
|
252
|
+
else {
|
|
253
|
+
text += `\n${renderDisplayItems(displayItems, theme, COLLAPSED_ITEM_COUNT)}`;
|
|
254
|
+
if (displayItems.length > COLLAPSED_ITEM_COUNT) {
|
|
255
|
+
text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const usageStr = formatUsageStats(result.usage, result.model);
|
|
259
|
+
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
260
|
+
return new Text(text, 0, 0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Aggregate helpers
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
export function aggregateUsage(results: SubAgentResult[]) {
|
|
268
|
+
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
269
|
+
for (const r of results) {
|
|
270
|
+
total.input += r.usage.input;
|
|
271
|
+
total.output += r.usage.output;
|
|
272
|
+
total.cacheRead += r.usage.cacheRead;
|
|
273
|
+
total.cacheWrite += r.usage.cacheWrite;
|
|
274
|
+
total.cost += r.usage.cost;
|
|
275
|
+
total.turns += r.usage.turns;
|
|
276
|
+
}
|
|
277
|
+
return total;
|
|
278
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK-based sub-agent runner for pi-subagent.
|
|
3
|
+
*
|
|
4
|
+
* Creates an in-process AgentSession via the pi SDK instead of spawning a
|
|
5
|
+
* separate `pi` process. This eliminates cold-start overhead and allows
|
|
6
|
+
* fine-grained control over token budget:
|
|
7
|
+
*
|
|
8
|
+
* - Only the agent's system prompt is used (no pi defaults).
|
|
9
|
+
* - No AGENTS.md, no extensions, no skills, no prompt templates loaded.
|
|
10
|
+
* - Thinking disabled, compaction disabled, retry disabled.
|
|
11
|
+
* - In-memory session (no disk I/O).
|
|
12
|
+
* - Shared auth/model infrastructure (no re-connection).
|
|
13
|
+
*
|
|
14
|
+
* Estimated token savings vs process-spawn: ~4-11K tokens per invocation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Message, Model } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
19
|
+
import {
|
|
20
|
+
AuthStorage,
|
|
21
|
+
createAgentSession,
|
|
22
|
+
createExtensionRuntime,
|
|
23
|
+
ModelRegistry,
|
|
24
|
+
type ResourceLoader,
|
|
25
|
+
SessionManager,
|
|
26
|
+
SettingsManager,
|
|
27
|
+
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface UsageStats {
|
|
34
|
+
input: number;
|
|
35
|
+
output: number;
|
|
36
|
+
cacheRead: number;
|
|
37
|
+
cacheWrite: number;
|
|
38
|
+
cost: number;
|
|
39
|
+
contextTokens: number;
|
|
40
|
+
turns: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SubAgentResult {
|
|
44
|
+
agent: string;
|
|
45
|
+
task: string;
|
|
46
|
+
exitCode: number;
|
|
47
|
+
messages: Message[];
|
|
48
|
+
stderr: string;
|
|
49
|
+
usage: UsageStats;
|
|
50
|
+
model?: string;
|
|
51
|
+
stopReason?: string;
|
|
52
|
+
errorMessage?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Public API
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export async function runSubAgent(options: {
|
|
60
|
+
cwd: string;
|
|
61
|
+
systemPrompt: string;
|
|
62
|
+
task: string;
|
|
63
|
+
tools: string[];
|
|
64
|
+
model: Model<any>;
|
|
65
|
+
authStorage: AuthStorage;
|
|
66
|
+
modelRegistry: ModelRegistry;
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
agentName?: string;
|
|
69
|
+
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
70
|
+
onUpdate?: (text: string) => void;
|
|
71
|
+
onMessage?: (partialResult: SubAgentResult) => void;
|
|
72
|
+
}): Promise<SubAgentResult> {
|
|
73
|
+
const {
|
|
74
|
+
cwd,
|
|
75
|
+
systemPrompt,
|
|
76
|
+
task,
|
|
77
|
+
tools,
|
|
78
|
+
model,
|
|
79
|
+
authStorage,
|
|
80
|
+
modelRegistry,
|
|
81
|
+
signal,
|
|
82
|
+
agentName = "subagent",
|
|
83
|
+
thinkingLevel = "off",
|
|
84
|
+
onUpdate,
|
|
85
|
+
onMessage,
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
88
|
+
const result: SubAgentResult = {
|
|
89
|
+
agent: agentName,
|
|
90
|
+
task,
|
|
91
|
+
exitCode: 0,
|
|
92
|
+
messages: [],
|
|
93
|
+
stderr: "",
|
|
94
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
95
|
+
model: `${model.provider}/${model.id}`,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Build a minimal resource loader. The sub-agent sees ONLY the agent's
|
|
99
|
+
// system prompt — no pi defaults, no AGENTS.md, no extensions, no skills.
|
|
100
|
+
const resourceLoader: ResourceLoader = {
|
|
101
|
+
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
|
102
|
+
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
103
|
+
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
104
|
+
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
105
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
106
|
+
getSystemPrompt: () => systemPrompt,
|
|
107
|
+
getAppendSystemPrompt: () => [],
|
|
108
|
+
extendResources: () => {},
|
|
109
|
+
reload: async () => {},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const settingsManager = SettingsManager.inMemory({
|
|
113
|
+
compaction: { enabled: false },
|
|
114
|
+
retry: { enabled: false },
|
|
115
|
+
});
|
|
116
|
+
|
|
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
|
+
|
|
125
|
+
const { session } = await createAgentSession({
|
|
126
|
+
cwd,
|
|
127
|
+
model,
|
|
128
|
+
thinkingLevel,
|
|
129
|
+
authStorage,
|
|
130
|
+
modelRegistry,
|
|
131
|
+
resourceLoader,
|
|
132
|
+
tools,
|
|
133
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
134
|
+
settingsManager,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
let cleanupAbort: (() => void) | undefined;
|
|
138
|
+
let cleanupEventAbort: (() => void) | undefined;
|
|
139
|
+
try {
|
|
140
|
+
// Wire abort signal
|
|
141
|
+
if (signal) {
|
|
142
|
+
const onAbort = () => session.abort();
|
|
143
|
+
if (signal.aborted) {
|
|
144
|
+
// Already aborted — shortcut
|
|
145
|
+
result.exitCode = 1;
|
|
146
|
+
result.stopReason = "aborted";
|
|
147
|
+
result.errorMessage = "Sub-agent aborted before start";
|
|
148
|
+
onAbort();
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
152
|
+
cleanupAbort = () => signal.removeEventListener("abort", onAbort);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Collect all messages and usage stats from events
|
|
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
|
+
|
|
164
|
+
const unsubscribe = session.subscribe((event) => {
|
|
165
|
+
try {
|
|
166
|
+
switch (event.type) {
|
|
167
|
+
case "message_end": {
|
|
168
|
+
const msg = event.message as AgentMessage;
|
|
169
|
+
if (msg.role === "assistant") {
|
|
170
|
+
result.usage.turns++;
|
|
171
|
+
if (msg.usage) {
|
|
172
|
+
result.usage.input += msg.usage.input || 0;
|
|
173
|
+
result.usage.output += msg.usage.output || 0;
|
|
174
|
+
result.usage.cacheRead += msg.usage.cacheRead || 0;
|
|
175
|
+
result.usage.cacheWrite += msg.usage.cacheWrite || 0;
|
|
176
|
+
result.usage.cost += msg.usage.cost?.total || 0;
|
|
177
|
+
result.usage.contextTokens = msg.usage.totalTokens || 0;
|
|
178
|
+
}
|
|
179
|
+
if (!result.model && msg.model) {
|
|
180
|
+
result.model = `${msg.provider || "?"}/${msg.model}`;
|
|
181
|
+
}
|
|
182
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
183
|
+
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
184
|
+
}
|
|
185
|
+
// Collect all messages for extraction
|
|
186
|
+
result.messages.push(msg as unknown as Message);
|
|
187
|
+
if (onMessage) onMessage({ ...result, messages: [...result.messages] });
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
case "agent_end": {
|
|
191
|
+
// agent_end carries all messages; use them if we haven't collected
|
|
192
|
+
if (result.messages.length === 0 && event.messages) {
|
|
193
|
+
result.messages = event.messages as unknown as Message[];
|
|
194
|
+
}
|
|
195
|
+
finish(() => {
|
|
196
|
+
unsubscribe();
|
|
197
|
+
resolve();
|
|
198
|
+
});
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
finish(() => {
|
|
204
|
+
unsubscribe();
|
|
205
|
+
reject(err);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
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
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await Promise.race([
|
|
227
|
+
session.prompt(task),
|
|
228
|
+
eventPromise,
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
if (result.stopReason !== "aborted") {
|
|
232
|
+
result.exitCode = 0;
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
} finally {
|
|
236
|
+
cleanupAbort?.();
|
|
237
|
+
cleanupEventAbort?.();
|
|
238
|
+
try {
|
|
239
|
+
session.dispose();
|
|
240
|
+
} catch {
|
|
241
|
+
// Best-effort cleanup
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
246
|
+
result.exitCode = 1;
|
|
247
|
+
result.errorMessage = message;
|
|
248
|
+
if (!result.stopReason) result.stopReason = "error";
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Helpers
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
export function getFinalOutput(messages: Message[]): string {
|
|
258
|
+
// Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
|
|
259
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
260
|
+
const msg = messages[i];
|
|
261
|
+
if (msg.role !== "assistant") continue;
|
|
262
|
+
const texts: string[] = [];
|
|
263
|
+
let hasToolCalls = false;
|
|
264
|
+
for (const part of msg.content) {
|
|
265
|
+
if (part.type === "text" && part.text.trim()) texts.push(part.text);
|
|
266
|
+
else if (part.type === "toolCall") hasToolCalls = true;
|
|
267
|
+
}
|
|
268
|
+
if (texts.length > 0 && !hasToolCalls) return texts.join("");
|
|
269
|
+
}
|
|
270
|
+
// Fallback: last assistant message with any non-empty text (even if it also has tool calls).
|
|
271
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
272
|
+
const msg = messages[i];
|
|
273
|
+
if (msg.role !== "assistant") continue;
|
|
274
|
+
const texts = msg.content
|
|
275
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text" && p.text.trim().length > 0)
|
|
276
|
+
.map((p) => p.text);
|
|
277
|
+
if (texts.length > 0) return texts.join("");
|
|
278
|
+
}
|
|
279
|
+
return "";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function isFailedResult(result: SubAgentResult): boolean {
|
|
283
|
+
return (
|
|
284
|
+
result.exitCode !== 0 ||
|
|
285
|
+
result.stopReason === "error" ||
|
|
286
|
+
result.stopReason === "aborted" ||
|
|
287
|
+
result.stopReason === "timeout"
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function getResultOutput(result: SubAgentResult): string {
|
|
292
|
+
if (isFailedResult(result)) {
|
|
293
|
+
return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
294
|
+
}
|
|
295
|
+
return getFinalOutput(result.messages) || "(no output)";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Concurrency-limited map. Runs up to `concurrency` async operations at a time. */
|
|
299
|
+
export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
300
|
+
items: TIn[],
|
|
301
|
+
concurrency: number,
|
|
302
|
+
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
303
|
+
): Promise<TOut[]> {
|
|
304
|
+
if (items.length === 0) return [];
|
|
305
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
306
|
+
const results: TOut[] = new Array(items.length);
|
|
307
|
+
let nextIndex = 0;
|
|
308
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
309
|
+
while (true) {
|
|
310
|
+
const current = nextIndex++;
|
|
311
|
+
if (current >= items.length) return;
|
|
312
|
+
results[current] = await fn(items[current], current);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
await Promise.all(workers);
|
|
316
|
+
return results;
|
|
317
|
+
}
|