@bacnh85/pi-subagent 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/README.md +88 -0
- package/agents/reviewer.md +30 -0
- package/agents/scout.md +44 -0
- package/agents/worker.md +15 -0
- package/agents.ts +174 -0
- package/index.ts +701 -0
- package/package.json +46 -0
- package/render.ts +250 -0
- package/runner.ts +258 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bacnh85/pi-subagent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agents with isolated context using the pi SDK in-process.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/bacnh85/skills#readme",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/bacnh85/skills.git",
|
|
14
|
+
"directory": "extensions/pi-subagent"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/bacnh85/skills/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"pi-package",
|
|
21
|
+
"pi-extension",
|
|
22
|
+
"subagent",
|
|
23
|
+
"sub-agent",
|
|
24
|
+
"delegation",
|
|
25
|
+
"parallel"
|
|
26
|
+
],
|
|
27
|
+
"files": [
|
|
28
|
+
"README.md",
|
|
29
|
+
"index.ts",
|
|
30
|
+
"agents.ts",
|
|
31
|
+
"runner.ts",
|
|
32
|
+
"render.ts",
|
|
33
|
+
"agents/"
|
|
34
|
+
],
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./index.ts"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"@earendil-works/pi-ai": "*",
|
|
43
|
+
"@earendil-works/pi-agent-core": "*",
|
|
44
|
+
"@earendil-works/pi-tui": "*"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/render.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering for pi-sugagents.
|
|
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
|
+
// Display helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function formatTokens(count: number): string {
|
|
20
|
+
if (count < 1000) return count.toString();
|
|
21
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
22
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
23
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatUsageStats(
|
|
27
|
+
usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens?: number; turns?: number },
|
|
28
|
+
model?: string,
|
|
29
|
+
): string {
|
|
30
|
+
const parts: string[] = [];
|
|
31
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
32
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
33
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
34
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
35
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
36
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
37
|
+
if (usage.contextTokens && usage.contextTokens > 0) {
|
|
38
|
+
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
39
|
+
}
|
|
40
|
+
if (model) parts.push(model);
|
|
41
|
+
return parts.join(" ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatToolCall(
|
|
45
|
+
toolName: string,
|
|
46
|
+
args: Record<string, unknown>,
|
|
47
|
+
themeFg: (color: string, text: string) => string,
|
|
48
|
+
): string {
|
|
49
|
+
const shortenPath = (p: string) => {
|
|
50
|
+
const home = os.homedir();
|
|
51
|
+
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
switch (toolName) {
|
|
55
|
+
case "bash": {
|
|
56
|
+
const command = (args.command as string) || "...";
|
|
57
|
+
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
58
|
+
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
|
|
59
|
+
}
|
|
60
|
+
case "read": {
|
|
61
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
62
|
+
const filePath = shortenPath(rawPath);
|
|
63
|
+
const offset = args.offset as number | undefined;
|
|
64
|
+
const limit = args.limit as number | undefined;
|
|
65
|
+
let text = themeFg("accent", filePath);
|
|
66
|
+
if (offset !== undefined || limit !== undefined) {
|
|
67
|
+
const startLine = offset ?? 1;
|
|
68
|
+
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
69
|
+
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
return themeFg("muted", "read ") + text;
|
|
72
|
+
}
|
|
73
|
+
case "write": {
|
|
74
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
75
|
+
const content = (args.content || "") as string;
|
|
76
|
+
const lines = content.split("\n").length;
|
|
77
|
+
let text = themeFg("muted", "write ") + themeFg("accent", shortenPath(rawPath));
|
|
78
|
+
if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
case "edit": {
|
|
82
|
+
const rawPath = (args.file_path || args.path || "...") as string;
|
|
83
|
+
return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
|
|
84
|
+
}
|
|
85
|
+
case "ls": {
|
|
86
|
+
const rawPath = (args.path || ".") as string;
|
|
87
|
+
return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
|
|
88
|
+
}
|
|
89
|
+
case "find": {
|
|
90
|
+
const pattern = (args.pattern || "*") as string;
|
|
91
|
+
const rawPath = (args.path || ".") as string;
|
|
92
|
+
return (
|
|
93
|
+
themeFg("muted", "find ") +
|
|
94
|
+
themeFg("accent", pattern) +
|
|
95
|
+
themeFg("dim", ` in ${shortenPath(rawPath)}`)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
case "grep": {
|
|
99
|
+
const pattern = (args.pattern || "") as string;
|
|
100
|
+
const rawPath = (args.path || ".") as string;
|
|
101
|
+
return (
|
|
102
|
+
themeFg("muted", "grep ") +
|
|
103
|
+
themeFg("accent", `/${pattern}/`) +
|
|
104
|
+
themeFg("dim", ` in ${shortenPath(rawPath)}`)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
default: {
|
|
108
|
+
const argsStr = JSON.stringify(args);
|
|
109
|
+
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
|
110
|
+
return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type DisplayItem =
|
|
116
|
+
| { type: "text"; text: string }
|
|
117
|
+
| { type: "toolCall"; name: string; args: Record<string, unknown> };
|
|
118
|
+
|
|
119
|
+
function getDisplayItems(messages: Message[]): DisplayItem[] {
|
|
120
|
+
const items: DisplayItem[] = [];
|
|
121
|
+
for (const msg of messages) {
|
|
122
|
+
if (msg.role === "assistant") {
|
|
123
|
+
for (const part of msg.content) {
|
|
124
|
+
if (part.type === "text") {
|
|
125
|
+
items.push({ type: "text", text: part.text });
|
|
126
|
+
} else if (part.type === "toolCall") {
|
|
127
|
+
items.push({
|
|
128
|
+
type: "toolCall",
|
|
129
|
+
name: part.name,
|
|
130
|
+
args: part.arguments as Record<string, unknown>,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return items;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Collapsed renderer
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
const COLLAPSED_ITEM_COUNT = 10;
|
|
144
|
+
|
|
145
|
+
function renderDisplayItems(
|
|
146
|
+
items: DisplayItem[],
|
|
147
|
+
theme: { fg: (c: string, t: string) => string },
|
|
148
|
+
limit?: number,
|
|
149
|
+
): string {
|
|
150
|
+
const toShow = limit ? items.slice(-limit) : items;
|
|
151
|
+
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
152
|
+
let text = "";
|
|
153
|
+
if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
|
|
154
|
+
for (const item of toShow) {
|
|
155
|
+
if (item.type === "text") {
|
|
156
|
+
const preview = item.text.split("\n").slice(0, 3).join("\n");
|
|
157
|
+
text += `${theme.fg("toolOutput", preview)}\n`;
|
|
158
|
+
} else {
|
|
159
|
+
text += `${theme.fg("muted", "→ ")}${formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return text.trimEnd();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Single agent result
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export function renderSingleResult(
|
|
170
|
+
result: SubAgentResult,
|
|
171
|
+
expanded: boolean,
|
|
172
|
+
theme: { fg: (c: string, t: string) => string; bold: (t: string) => string },
|
|
173
|
+
): Container | Text {
|
|
174
|
+
const isError = isFailedResult(result);
|
|
175
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
176
|
+
const displayItems = getDisplayItems(result.messages);
|
|
177
|
+
const finalOutput = getResultOutput(result);
|
|
178
|
+
|
|
179
|
+
if (expanded) {
|
|
180
|
+
const mdTheme = getMarkdownTheme();
|
|
181
|
+
const container = new Container();
|
|
182
|
+
let header = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
|
|
183
|
+
if (isError && result.stopReason) header += ` ${theme.fg("error", `[${result.stopReason}]`)}`;
|
|
184
|
+
container.addChild(new Text(header, 0, 0));
|
|
185
|
+
if (isError && result.errorMessage) {
|
|
186
|
+
container.addChild(new Text(theme.fg("error", `Error: ${result.errorMessage}`), 0, 0));
|
|
187
|
+
}
|
|
188
|
+
container.addChild(new Spacer(1));
|
|
189
|
+
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
190
|
+
container.addChild(new Text(theme.fg("dim", result.task), 0, 0));
|
|
191
|
+
container.addChild(new Spacer(1));
|
|
192
|
+
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
|
193
|
+
if (displayItems.length === 0 && !finalOutput) {
|
|
194
|
+
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
195
|
+
} else {
|
|
196
|
+
for (const item of displayItems) {
|
|
197
|
+
if (item.type === "toolCall") {
|
|
198
|
+
container.addChild(
|
|
199
|
+
new Text(
|
|
200
|
+
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
|
201
|
+
0, 0,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (finalOutput) {
|
|
207
|
+
container.addChild(new Spacer(1));
|
|
208
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const usageStr = formatUsageStats(result.usage, result.model);
|
|
212
|
+
if (usageStr) {
|
|
213
|
+
container.addChild(new Spacer(1));
|
|
214
|
+
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
|
215
|
+
}
|
|
216
|
+
return container;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Collapsed
|
|
220
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
|
|
221
|
+
if (isError && result.stopReason) text += ` ${theme.fg("error", `[${result.stopReason}]`)}`;
|
|
222
|
+
if (isError && result.errorMessage) text += `\n${theme.fg("error", `Error: ${result.errorMessage}`)}`;
|
|
223
|
+
else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
|
224
|
+
else {
|
|
225
|
+
text += `\n${renderDisplayItems(displayItems, theme, COLLAPSED_ITEM_COUNT)}`;
|
|
226
|
+
if (displayItems.length > COLLAPSED_ITEM_COUNT) {
|
|
227
|
+
text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const usageStr = formatUsageStats(result.usage, result.model);
|
|
231
|
+
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
232
|
+
return new Text(text, 0, 0);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Aggregate helpers
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
export function aggregateUsage(results: SubAgentResult[]) {
|
|
240
|
+
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
241
|
+
for (const r of results) {
|
|
242
|
+
total.input += r.usage.input;
|
|
243
|
+
total.output += r.usage.output;
|
|
244
|
+
total.cacheRead += r.usage.cacheRead;
|
|
245
|
+
total.cacheWrite += r.usage.cacheWrite;
|
|
246
|
+
total.cost += r.usage.cost;
|
|
247
|
+
total.turns += r.usage.turns;
|
|
248
|
+
}
|
|
249
|
+
return total;
|
|
250
|
+
}
|
package/runner.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK-based sub-agent runner for pi-sugagents.
|
|
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;
|
|
65
|
+
authStorage: AuthStorage;
|
|
66
|
+
modelRegistry: ModelRegistry;
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
agentName?: string;
|
|
69
|
+
}): Promise<SubAgentResult> {
|
|
70
|
+
const {
|
|
71
|
+
cwd,
|
|
72
|
+
systemPrompt,
|
|
73
|
+
task,
|
|
74
|
+
tools,
|
|
75
|
+
model,
|
|
76
|
+
authStorage,
|
|
77
|
+
modelRegistry,
|
|
78
|
+
signal,
|
|
79
|
+
agentName = "subagent",
|
|
80
|
+
} = options;
|
|
81
|
+
|
|
82
|
+
const result: SubAgentResult = {
|
|
83
|
+
agent: agentName,
|
|
84
|
+
task,
|
|
85
|
+
exitCode: 0,
|
|
86
|
+
messages: [],
|
|
87
|
+
stderr: "",
|
|
88
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
89
|
+
model: `${model.provider}/${model.id}`,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Build a minimal resource loader. The sub-agent sees ONLY the agent's
|
|
93
|
+
// system prompt — no pi defaults, no AGENTS.md, no extensions, no skills.
|
|
94
|
+
const resourceLoader: ResourceLoader = {
|
|
95
|
+
getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
|
|
96
|
+
getSkills: () => ({ skills: [], diagnostics: [] }),
|
|
97
|
+
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
98
|
+
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
99
|
+
getAgentsFiles: () => ({ agentsFiles: [] }),
|
|
100
|
+
getSystemPrompt: () => systemPrompt,
|
|
101
|
+
getAppendSystemPrompt: () => [],
|
|
102
|
+
extendResources: () => {},
|
|
103
|
+
reload: async () => {},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const settingsManager = SettingsManager.inMemory({
|
|
107
|
+
compaction: { enabled: false },
|
|
108
|
+
retry: { enabled: false },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const { session } = await createAgentSession({
|
|
113
|
+
cwd,
|
|
114
|
+
model,
|
|
115
|
+
thinkingLevel: "off", // no reasoning token overhead
|
|
116
|
+
authStorage,
|
|
117
|
+
modelRegistry,
|
|
118
|
+
resourceLoader,
|
|
119
|
+
tools,
|
|
120
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
121
|
+
settingsManager,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Wire abort signal
|
|
126
|
+
if (signal) {
|
|
127
|
+
const onAbort = () => session.abort();
|
|
128
|
+
if (signal.aborted) {
|
|
129
|
+
// Already aborted — shortcut
|
|
130
|
+
result.exitCode = 1;
|
|
131
|
+
result.stopReason = "aborted";
|
|
132
|
+
result.errorMessage = "Sub-agent aborted before start";
|
|
133
|
+
onAbort();
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
137
|
+
|
|
138
|
+
// Cleanup listener on completion
|
|
139
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
140
|
+
// We'll clean up in finally via a flag
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Collect all messages and usage stats from events
|
|
144
|
+
const eventPromise = new Promise<void>((resolve, reject) => {
|
|
145
|
+
const unsubscribe = session.subscribe((event) => {
|
|
146
|
+
try {
|
|
147
|
+
switch (event.type) {
|
|
148
|
+
case "message_end": {
|
|
149
|
+
const msg = event.message as AgentMessage;
|
|
150
|
+
if (msg.role === "assistant") {
|
|
151
|
+
result.usage.turns++;
|
|
152
|
+
if (msg.usage) {
|
|
153
|
+
result.usage.input += msg.usage.input || 0;
|
|
154
|
+
result.usage.output += msg.usage.output || 0;
|
|
155
|
+
result.usage.cacheRead += msg.usage.cacheRead || 0;
|
|
156
|
+
result.usage.cacheWrite += msg.usage.cacheWrite || 0;
|
|
157
|
+
result.usage.cost += msg.usage.cost?.total || 0;
|
|
158
|
+
result.usage.contextTokens = msg.usage.totalTokens || 0;
|
|
159
|
+
}
|
|
160
|
+
if (!result.model && msg.model) {
|
|
161
|
+
result.model = `${msg.provider || "?"}/${msg.model}`;
|
|
162
|
+
}
|
|
163
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
164
|
+
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
165
|
+
}
|
|
166
|
+
// Collect all messages for extraction
|
|
167
|
+
result.messages.push(msg as unknown as Message);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case "agent_end": {
|
|
171
|
+
// agent_end carries all messages; use them if we haven't collected
|
|
172
|
+
if (result.messages.length === 0 && event.messages) {
|
|
173
|
+
result.messages = event.messages as unknown as Message[];
|
|
174
|
+
}
|
|
175
|
+
unsubscribe();
|
|
176
|
+
resolve();
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
unsubscribe();
|
|
182
|
+
reject(err);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await session.prompt(task);
|
|
188
|
+
await eventPromise;
|
|
189
|
+
|
|
190
|
+
result.exitCode = 0;
|
|
191
|
+
return result;
|
|
192
|
+
} finally {
|
|
193
|
+
try {
|
|
194
|
+
session.dispose();
|
|
195
|
+
} catch {
|
|
196
|
+
// Best-effort cleanup
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
201
|
+
result.exitCode = 1;
|
|
202
|
+
result.errorMessage = message;
|
|
203
|
+
if (!result.stopReason) result.stopReason = "error";
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Helpers
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export function getFinalOutput(messages: Message[]): string {
|
|
213
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
214
|
+
const msg = messages[i];
|
|
215
|
+
if (msg.role === "assistant") {
|
|
216
|
+
for (const part of msg.content) {
|
|
217
|
+
if (part.type === "text") return part.text;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function isFailedResult(result: SubAgentResult): boolean {
|
|
225
|
+
return (
|
|
226
|
+
result.exitCode !== 0 ||
|
|
227
|
+
result.stopReason === "error" ||
|
|
228
|
+
result.stopReason === "aborted"
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function getResultOutput(result: SubAgentResult): string {
|
|
233
|
+
if (isFailedResult(result)) {
|
|
234
|
+
return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
235
|
+
}
|
|
236
|
+
return getFinalOutput(result.messages) || "(no output)";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Concurrency-limited map. Runs up to `concurrency` async operations at a time. */
|
|
240
|
+
export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
241
|
+
items: TIn[],
|
|
242
|
+
concurrency: number,
|
|
243
|
+
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
244
|
+
): Promise<TOut[]> {
|
|
245
|
+
if (items.length === 0) return [];
|
|
246
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
247
|
+
const results: TOut[] = new Array(items.length);
|
|
248
|
+
let nextIndex = 0;
|
|
249
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
250
|
+
while (true) {
|
|
251
|
+
const current = nextIndex++;
|
|
252
|
+
if (current >= items.length) return;
|
|
253
|
+
results[current] = await fn(items[current], current);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
await Promise.all(workers);
|
|
257
|
+
return results;
|
|
258
|
+
}
|