@narumitw/pi-subagents 0.13.0 → 0.13.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/README.md +3 -2
- package/package.json +1 -1
- package/src/config-ui.ts +271 -0
- package/src/execution.ts +406 -0
- package/src/params.ts +59 -0
- package/src/render.ts +521 -0
- package/src/runner.ts +368 -0
- package/src/settings.ts +120 -0
- package/src/subagents.ts +23 -1679
package/README.md
CHANGED
|
@@ -256,14 +256,15 @@ Subagents are separate Pi processes and may use the tools allowed by their agent
|
|
|
256
256
|
```txt
|
|
257
257
|
extensions/pi-subagents/
|
|
258
258
|
├── src/
|
|
259
|
-
│
|
|
259
|
+
│ ├── subagents.ts # Pi entrypoint and tool schema
|
|
260
|
+
│ └── *.ts # Package-local discovery, execution, rendering, and config modules
|
|
260
261
|
├── README.md
|
|
261
262
|
├── LICENSE
|
|
262
263
|
├── tsconfig.json
|
|
263
264
|
└── package.json
|
|
264
265
|
```
|
|
265
266
|
|
|
266
|
-
The package exposes its Pi extension through `package.json`:
|
|
267
|
+
Only `subagents.ts` is a Pi entrypoint; the other source modules are internal. The package exposes its Pi extension through `package.json`:
|
|
267
268
|
|
|
268
269
|
```json
|
|
269
270
|
{
|
package/package.json
CHANGED
package/src/config-ui.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
Container,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
SelectList,
|
|
8
|
+
type SelectItem,
|
|
9
|
+
Spacer,
|
|
10
|
+
Text,
|
|
11
|
+
truncateToWidth,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
import { discoverAgents, type SubagentSettings } from "./agents.js";
|
|
14
|
+
import {
|
|
15
|
+
hasAnyAgentOverride,
|
|
16
|
+
hasOwn,
|
|
17
|
+
readSubagentSettings,
|
|
18
|
+
sameToolSet,
|
|
19
|
+
saveSubagentConfig,
|
|
20
|
+
uniqueToolNames,
|
|
21
|
+
} from "./settings.js";
|
|
22
|
+
|
|
23
|
+
class ToolToggleList {
|
|
24
|
+
private items: { name: string; selected: boolean }[];
|
|
25
|
+
private cursor = 0;
|
|
26
|
+
private cachedWidth?: number;
|
|
27
|
+
private cachedLines?: string[];
|
|
28
|
+
onDone?: (selected: string[]) => void;
|
|
29
|
+
onCancel?: () => void;
|
|
30
|
+
|
|
31
|
+
constructor(tools: string[], selected: Set<string>) {
|
|
32
|
+
this.items = tools.map((name) => ({ name, selected: selected.has(name) }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private getSelectedNames(): string[] {
|
|
36
|
+
return this.items.filter((i) => i.selected).map((i) => i.name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
handleInput(data: string): void {
|
|
40
|
+
if (matchesKey(data, Key.escape)) {
|
|
41
|
+
this.onCancel?.();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (data === "s" || data === "S") {
|
|
45
|
+
this.onDone?.(this.getSelectedNames());
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (this.items.length === 0) return;
|
|
49
|
+
|
|
50
|
+
if (matchesKey(data, Key.up) && this.cursor > 0) {
|
|
51
|
+
this.cursor--;
|
|
52
|
+
this.invalidate();
|
|
53
|
+
} else if (matchesKey(data, Key.down) && this.cursor < this.items.length - 1) {
|
|
54
|
+
this.cursor++;
|
|
55
|
+
this.invalidate();
|
|
56
|
+
} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
|
|
57
|
+
this.items[this.cursor].selected = !this.items[this.cursor].selected;
|
|
58
|
+
this.invalidate();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
render(width: number): string[] {
|
|
63
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
64
|
+
this.cachedWidth = width;
|
|
65
|
+
this.cachedLines = this.items.map((item, i) => {
|
|
66
|
+
const pointer = i === this.cursor ? ">" : " ";
|
|
67
|
+
const check = item.selected ? "✓" : "○";
|
|
68
|
+
return truncateToWidth(`${pointer} ${check} ${item.name}`, width);
|
|
69
|
+
});
|
|
70
|
+
return this.cachedLines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
invalidate(): void {
|
|
74
|
+
this.cachedWidth = undefined;
|
|
75
|
+
this.cachedLines = undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function registerSubagentConfigCommand(pi: ExtensionAPI) {
|
|
80
|
+
pi.registerCommand("subagents:config", {
|
|
81
|
+
description: "Configure which tools each subagent can use",
|
|
82
|
+
handler: async (_args, ctx) => {
|
|
83
|
+
if (!ctx.hasUI) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get current settings
|
|
88
|
+
const currentSettings = readSubagentSettings() ?? {};
|
|
89
|
+
const currentAgents = currentSettings.agents ?? {};
|
|
90
|
+
|
|
91
|
+
// Discover agents to show which ones are available
|
|
92
|
+
const discovery = discoverAgents(ctx.cwd, "user", currentSettings);
|
|
93
|
+
const agents = discovery.agents;
|
|
94
|
+
|
|
95
|
+
if (agents.length === 0) {
|
|
96
|
+
ctx.ui.notify("No agents found", "warning");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Loop: agent selection → tool toggle (Esc in tools returns here)
|
|
101
|
+
while (true) {
|
|
102
|
+
// Step 1: pick an agent to configure
|
|
103
|
+
const agentItems: SelectItem[] = agents.map((a) => {
|
|
104
|
+
const cfg = currentAgents[a.name];
|
|
105
|
+
const hasToolsOverride = cfg ? hasOwn(cfg, "tools") : false;
|
|
106
|
+
const toolSummary = hasToolsOverride
|
|
107
|
+
? cfg?.tools && cfg.tools.length > 0
|
|
108
|
+
? cfg.tools.join(", ")
|
|
109
|
+
: "none"
|
|
110
|
+
: "defaults";
|
|
111
|
+
return {
|
|
112
|
+
value: a.name,
|
|
113
|
+
label: a.name,
|
|
114
|
+
description: `${a.source} · tools: ${toolSummary}`,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const agentName = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
119
|
+
const container = new Container();
|
|
120
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
121
|
+
container.addChild(
|
|
122
|
+
new Text(theme.fg("accent", theme.bold("Subagent Tool Configuration")), 1, 0),
|
|
123
|
+
);
|
|
124
|
+
container.addChild(new Spacer(1));
|
|
125
|
+
container.addChild(
|
|
126
|
+
new Text(theme.fg("muted", "Select an agent to configure its allowed tools:"), 1, 0),
|
|
127
|
+
);
|
|
128
|
+
container.addChild(new Spacer(1));
|
|
129
|
+
const selectList = new SelectList(agentItems, Math.min(agentItems.length + 2, 15), {
|
|
130
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
131
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
132
|
+
description: (t: string) => theme.fg("muted", t),
|
|
133
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
134
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
135
|
+
});
|
|
136
|
+
selectList.onSelect = (item) => done(item.value);
|
|
137
|
+
selectList.onCancel = () => done(null);
|
|
138
|
+
container.addChild(selectList);
|
|
139
|
+
container.addChild(
|
|
140
|
+
new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 1, 0),
|
|
141
|
+
);
|
|
142
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
143
|
+
return {
|
|
144
|
+
render: (w: number) => container.render(w),
|
|
145
|
+
invalidate: () => container.invalidate(),
|
|
146
|
+
handleInput: (data: string) => {
|
|
147
|
+
selectList.handleInput(data);
|
|
148
|
+
tui.requestRender();
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!agentName) return;
|
|
154
|
+
|
|
155
|
+
const agent = agents.find((a) => a.name === agentName);
|
|
156
|
+
if (!agent) return;
|
|
157
|
+
|
|
158
|
+
// Step 2: toggle tools for the selected agent
|
|
159
|
+
// Discover without overrides to get original built-in/frontmatter defaults.
|
|
160
|
+
// The main discovery above applies saved overrides, so agent.tools is already
|
|
161
|
+
// overridden — using it for the reset-to-default comparison would match the
|
|
162
|
+
// override against itself and silently delete it on a no-op save.
|
|
163
|
+
const defaultDiscovery = discoverAgents(ctx.cwd, "user");
|
|
164
|
+
const defaultTools = defaultDiscovery.agents.find((a) => a.name === agentName)?.tools;
|
|
165
|
+
const currentAgentSettings = currentAgents[agentName];
|
|
166
|
+
const configuredTools =
|
|
167
|
+
currentAgentSettings && hasOwn(currentAgentSettings, "tools")
|
|
168
|
+
? (currentAgentSettings.tools ?? [])
|
|
169
|
+
: undefined;
|
|
170
|
+
|
|
171
|
+
// Get all available tools from pi's registry
|
|
172
|
+
const allTools = uniqueToolNames(pi.getAllTools().map((t) => t.name)).sort((a, b) =>
|
|
173
|
+
a.localeCompare(b),
|
|
174
|
+
);
|
|
175
|
+
const currentTools = uniqueToolNames(configuredTools ?? defaultTools ?? allTools);
|
|
176
|
+
// Sort: currently selected tools first, then rest alphabetically. Preserve
|
|
177
|
+
// unavailable configured tools so saving does not silently drop them.
|
|
178
|
+
const currentSet = new Set(currentTools);
|
|
179
|
+
const selectedFirst = [...currentTools, ...allTools.filter((t) => !currentSet.has(t))];
|
|
180
|
+
|
|
181
|
+
const selectedTools = await ctx.ui.custom<string[] | null>((tui, theme, _kb, done) => {
|
|
182
|
+
const toggleList = new ToolToggleList(selectedFirst, currentSet);
|
|
183
|
+
|
|
184
|
+
const container = new Container();
|
|
185
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
186
|
+
container.addChild(
|
|
187
|
+
new Text(
|
|
188
|
+
theme.fg("accent", theme.bold(`${agentName} tools`)) +
|
|
189
|
+
theme.fg("muted", ` (${agent.source})`),
|
|
190
|
+
1,
|
|
191
|
+
0,
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
container.addChild(new Spacer(1));
|
|
195
|
+
container.addChild(
|
|
196
|
+
new Text(theme.fg("muted", "Toggle tools with Enter/Space. S to save, Esc to cancel."), 1, 0),
|
|
197
|
+
);
|
|
198
|
+
container.addChild(new Spacer(1));
|
|
199
|
+
|
|
200
|
+
const listContainer = new Container();
|
|
201
|
+
listContainer.addChild({
|
|
202
|
+
render: (w: number) => toggleList.render(w),
|
|
203
|
+
invalidate: () => toggleList.invalidate(),
|
|
204
|
+
});
|
|
205
|
+
container.addChild(listContainer);
|
|
206
|
+
|
|
207
|
+
container.addChild(new Spacer(1));
|
|
208
|
+
container.addChild(
|
|
209
|
+
new Text(theme.fg("dim", "↑↓ navigate · enter/space toggle · S save · esc cancel"), 1, 0),
|
|
210
|
+
);
|
|
211
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
212
|
+
|
|
213
|
+
toggleList.onDone = (tools) => done(tools);
|
|
214
|
+
toggleList.onCancel = () => done(null);
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
render: (w: number) => container.render(w),
|
|
218
|
+
invalidate: () => container.invalidate(),
|
|
219
|
+
handleInput: (data: string) => {
|
|
220
|
+
toggleList.handleInput(data);
|
|
221
|
+
tui.requestRender();
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// null means user cancelled — loop back to agent selection
|
|
227
|
+
if (selectedTools === null) continue;
|
|
228
|
+
|
|
229
|
+
// Save to global settings
|
|
230
|
+
const updatedAgents = { ...currentAgents };
|
|
231
|
+
let restoredDefaults = false;
|
|
232
|
+
|
|
233
|
+
const isSameAsDefault =
|
|
234
|
+
defaultTools === undefined
|
|
235
|
+
? sameToolSet(selectedTools, allTools)
|
|
236
|
+
: sameToolSet(selectedTools, defaultTools);
|
|
237
|
+
|
|
238
|
+
if (isSameAsDefault) {
|
|
239
|
+
// Tools match defaults — remove only the tools override.
|
|
240
|
+
// Keep other settings (model, timeoutMs) if present.
|
|
241
|
+
const existing = updatedAgents[agentName];
|
|
242
|
+
if (existing) {
|
|
243
|
+
const nextConfig = { ...existing };
|
|
244
|
+
delete nextConfig.tools;
|
|
245
|
+
if (hasAnyAgentOverride(nextConfig)) updatedAgents[agentName] = nextConfig;
|
|
246
|
+
else delete updatedAgents[agentName];
|
|
247
|
+
}
|
|
248
|
+
restoredDefaults = true;
|
|
249
|
+
} else {
|
|
250
|
+
updatedAgents[agentName] = {
|
|
251
|
+
...updatedAgents[agentName],
|
|
252
|
+
tools: selectedTools,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const newSettings: SubagentSettings = {
|
|
257
|
+
...currentSettings,
|
|
258
|
+
agents: Object.keys(updatedAgents).length > 0 ? updatedAgents : undefined,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
saveSubagentConfig(newSettings);
|
|
262
|
+
const message = restoredDefaults
|
|
263
|
+
? `${agentName}: defaults restored`
|
|
264
|
+
: `${agentName}: ${selectedTools.length} tool${selectedTools.length !== 1 ? "s" : ""} configured`;
|
|
265
|
+
ctx.ui.notify(message, "info");
|
|
266
|
+
// Saved — exit the loop
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
}
|
package/src/execution.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
discoverAgents,
|
|
5
|
+
type AgentConfig,
|
|
6
|
+
type AgentScope,
|
|
7
|
+
type SubagentThinkingLevel,
|
|
8
|
+
} from "./agents.js";
|
|
9
|
+
import {
|
|
10
|
+
buildFanInContext,
|
|
11
|
+
getResultFinalOutput,
|
|
12
|
+
mapWithConcurrencyLimit,
|
|
13
|
+
runSingleAgent,
|
|
14
|
+
type OnUpdateCallback,
|
|
15
|
+
type SingleResult,
|
|
16
|
+
type SubagentDetails,
|
|
17
|
+
} from "./runner.js";
|
|
18
|
+
import type { SubagentParams } from "./params.js";
|
|
19
|
+
import { readSubagentSettings, resolveSubagentThinkingLevel } from "./settings.js";
|
|
20
|
+
|
|
21
|
+
const MAX_PARALLEL_TASKS = 8;
|
|
22
|
+
const MAX_CONCURRENCY = 4;
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = parsePositiveInteger(process.env.PI_SUBAGENT_TIMEOUT_MS) ?? 10 * 60 * 1000;
|
|
24
|
+
const STATUS_KEY = "subagents";
|
|
25
|
+
const activeStatuses = new Map<string, string>();
|
|
26
|
+
|
|
27
|
+
export function parsePositiveInteger(value: string | undefined): number | undefined {
|
|
28
|
+
if (!value) return undefined;
|
|
29
|
+
const parsed = Number.parseInt(value, 10);
|
|
30
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface StatusContext {
|
|
34
|
+
ui: { setStatus: (key: string, value: string | undefined) => void };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function startSubagentStatus(ctx: StatusContext, toolCallId: string, status: string) {
|
|
38
|
+
let cleared = false;
|
|
39
|
+
|
|
40
|
+
const update = (nextStatus: string) => {
|
|
41
|
+
if (cleared) return;
|
|
42
|
+
activeStatuses.set(toolCallId, nextStatus);
|
|
43
|
+
publishSubagentStatus(ctx);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
update(status);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
update,
|
|
50
|
+
clear() {
|
|
51
|
+
if (cleared) return;
|
|
52
|
+
cleared = true;
|
|
53
|
+
activeStatuses.delete(toolCallId);
|
|
54
|
+
publishSubagentStatus(ctx);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function publishSubagentStatus(ctx: StatusContext) {
|
|
60
|
+
const statuses = [...activeStatuses.values()];
|
|
61
|
+
if (statuses.length === 0) {
|
|
62
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const suffix = statuses.length > 1 ? ` +${statuses.length - 1}` : "";
|
|
67
|
+
ctx.ui.setStatus(STATUS_KEY, `${statuses[0]}${suffix}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function singleStatus(agent: string): string {
|
|
71
|
+
return `${agent}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function chainStatus(step: number, total: number, agent?: string): string {
|
|
75
|
+
return `chain ${step}/${total}${agent ? ` ${agent}` : ""}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parallelStatus(done: number, total: number, running: number): string {
|
|
79
|
+
return `parallel ${done}/${total} done${running > 0 ? ` ${running} running` : ""}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fanInStatus(agent: string): string {
|
|
83
|
+
return `fan-in ${agent}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function executeSubagent(
|
|
87
|
+
toolCallId: string,
|
|
88
|
+
params: SubagentParams,
|
|
89
|
+
signal: AbortSignal | undefined,
|
|
90
|
+
onUpdate: AgentToolUpdateCallback<SubagentDetails> | undefined,
|
|
91
|
+
ctx: ExtensionContext,
|
|
92
|
+
): Promise<AgentToolResult<SubagentDetails> & { isError?: boolean }> {
|
|
93
|
+
const agentScope: AgentScope = params.agentScope ?? "user";
|
|
94
|
+
const config = readSubagentSettings();
|
|
95
|
+
const discovery = discoverAgents(ctx.cwd, agentScope, config);
|
|
96
|
+
const agents = discovery.agents;
|
|
97
|
+
const confirmProjectAgents = params.confirmProjectAgents ?? true;
|
|
98
|
+
const resolveTimeoutMs = (agentName: string, localTimeoutMs?: number) =>
|
|
99
|
+
localTimeoutMs ??
|
|
100
|
+
params.timeoutMs ??
|
|
101
|
+
agents.find((agent) => agent.name === agentName)?.timeoutMs ??
|
|
102
|
+
DEFAULT_TIMEOUT_MS;
|
|
103
|
+
const resolveThinkingLevel = (agentName: string, localThinkingLevel?: SubagentThinkingLevel) =>
|
|
104
|
+
resolveSubagentThinkingLevel(agents, agentName, params.thinkingLevel, localThinkingLevel);
|
|
105
|
+
|
|
106
|
+
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
107
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
108
|
+
const hasSingle = Boolean(params.agent && params.task);
|
|
109
|
+
const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
|
|
110
|
+
|
|
111
|
+
const makeDetails =
|
|
112
|
+
(mode: "single" | "parallel" | "chain") =>
|
|
113
|
+
(results: SingleResult[], aggregator?: SingleResult): SubagentDetails => ({
|
|
114
|
+
mode,
|
|
115
|
+
agentScope,
|
|
116
|
+
projectAgentsDir: discovery.projectAgentsDir,
|
|
117
|
+
results,
|
|
118
|
+
aggregator,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (modeCount !== 1 || (params.aggregator && !hasTasks)) {
|
|
122
|
+
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
123
|
+
const reason =
|
|
124
|
+
modeCount !== 1
|
|
125
|
+
? "Provide exactly one mode."
|
|
126
|
+
: "Aggregator is only valid with parallel tasks.";
|
|
127
|
+
return {
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: "text",
|
|
131
|
+
text: `Invalid parameters. ${reason}\nAvailable agents: ${available}`,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
details: makeDetails("single")([]),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
|
|
139
|
+
const requestedAgentNames = new Set<string>();
|
|
140
|
+
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
|
|
141
|
+
if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
|
|
142
|
+
if (params.aggregator) requestedAgentNames.add(params.aggregator.agent);
|
|
143
|
+
if (params.agent) requestedAgentNames.add(params.agent);
|
|
144
|
+
|
|
145
|
+
const projectAgentsRequested = Array.from(requestedAgentNames)
|
|
146
|
+
.map((name) => agents.find((a) => a.name === name))
|
|
147
|
+
.filter((a): a is AgentConfig => a?.source === "project");
|
|
148
|
+
|
|
149
|
+
if (projectAgentsRequested.length > 0) {
|
|
150
|
+
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
|
151
|
+
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
|
152
|
+
const ok = await ctx.ui.confirm(
|
|
153
|
+
"Run project-local agents?",
|
|
154
|
+
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
|
155
|
+
);
|
|
156
|
+
if (!ok)
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
|
|
159
|
+
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (params.chain && params.chain.length > 0) {
|
|
165
|
+
const results: SingleResult[] = [];
|
|
166
|
+
let previousOutput = "";
|
|
167
|
+
const status = startSubagentStatus(ctx, toolCallId, chainStatus(0, params.chain.length));
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
for (let i = 0; i < params.chain.length; i++) {
|
|
171
|
+
const step = params.chain[i];
|
|
172
|
+
status.update(chainStatus(i + 1, params.chain.length, step.agent));
|
|
173
|
+
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
|
174
|
+
|
|
175
|
+
// Create update callback that includes all previous results
|
|
176
|
+
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
|
177
|
+
? (partial) => {
|
|
178
|
+
// Combine completed results with current streaming result
|
|
179
|
+
const currentResult = partial.details?.results[0];
|
|
180
|
+
if (currentResult) {
|
|
181
|
+
const allResults = [...results, currentResult];
|
|
182
|
+
onUpdate({
|
|
183
|
+
content: partial.content,
|
|
184
|
+
details: makeDetails("chain")(allResults),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
: undefined;
|
|
189
|
+
|
|
190
|
+
const result = await runSingleAgent(
|
|
191
|
+
ctx.cwd,
|
|
192
|
+
agents,
|
|
193
|
+
step.agent,
|
|
194
|
+
taskWithContext,
|
|
195
|
+
step.cwd,
|
|
196
|
+
i + 1,
|
|
197
|
+
signal,
|
|
198
|
+
resolveThinkingLevel(step.agent, step.thinkingLevel),
|
|
199
|
+
resolveTimeoutMs(step.agent, step.timeoutMs),
|
|
200
|
+
chainUpdate,
|
|
201
|
+
makeDetails("chain"),
|
|
202
|
+
);
|
|
203
|
+
results.push(result);
|
|
204
|
+
|
|
205
|
+
const isError =
|
|
206
|
+
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
207
|
+
if (isError) {
|
|
208
|
+
const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
|
|
209
|
+
return {
|
|
210
|
+
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
|
|
211
|
+
details: makeDetails("chain")(results),
|
|
212
|
+
isError: true,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
previousOutput = getResultFinalOutput(result);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
content: [{ type: "text", text: getResultFinalOutput(results[results.length - 1]) || "(no output)" }],
|
|
219
|
+
details: makeDetails("chain")(results),
|
|
220
|
+
};
|
|
221
|
+
} finally {
|
|
222
|
+
status.clear();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
227
|
+
if (params.tasks.length > MAX_PARALLEL_TASKS)
|
|
228
|
+
return {
|
|
229
|
+
content: [
|
|
230
|
+
{
|
|
231
|
+
type: "text",
|
|
232
|
+
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
details: makeDetails("parallel")([]),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const status = startSubagentStatus(ctx, toolCallId, parallelStatus(0, params.tasks.length, params.tasks.length));
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
// Track all results for streaming updates
|
|
242
|
+
const allResults: SingleResult[] = new Array(params.tasks.length);
|
|
243
|
+
|
|
244
|
+
// Initialize placeholder results
|
|
245
|
+
for (let i = 0; i < params.tasks.length; i++) {
|
|
246
|
+
allResults[i] = {
|
|
247
|
+
agent: params.tasks[i].agent,
|
|
248
|
+
agentSource: "unknown",
|
|
249
|
+
task: params.tasks[i].task,
|
|
250
|
+
exitCode: -1, // -1 = still running
|
|
251
|
+
messages: [],
|
|
252
|
+
stderr: "",
|
|
253
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
254
|
+
thinkingLevel: resolveThinkingLevel(params.tasks[i].agent, params.tasks[i].thinkingLevel),
|
|
255
|
+
finalOutput: "",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let doneCount = 0;
|
|
260
|
+
let runningCount = params.tasks.length;
|
|
261
|
+
|
|
262
|
+
const emitParallelUpdate = () => {
|
|
263
|
+
status.update(parallelStatus(doneCount, allResults.length, runningCount));
|
|
264
|
+
if (onUpdate) {
|
|
265
|
+
onUpdate({
|
|
266
|
+
content: [
|
|
267
|
+
{
|
|
268
|
+
type: "text",
|
|
269
|
+
text: `Parallel: ${doneCount}/${allResults.length} done, ${runningCount} running...`,
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
details: makeDetails("parallel")([...allResults]),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
278
|
+
const result = await runSingleAgent(
|
|
279
|
+
ctx.cwd,
|
|
280
|
+
agents,
|
|
281
|
+
t.agent,
|
|
282
|
+
t.task,
|
|
283
|
+
t.cwd,
|
|
284
|
+
undefined,
|
|
285
|
+
signal,
|
|
286
|
+
resolveThinkingLevel(t.agent, t.thinkingLevel),
|
|
287
|
+
resolveTimeoutMs(t.agent, t.timeoutMs),
|
|
288
|
+
// Per-task update callback
|
|
289
|
+
(partial) => {
|
|
290
|
+
if (partial.details?.results[0]) {
|
|
291
|
+
allResults[index] = { ...partial.details.results[0], exitCode: -1 };
|
|
292
|
+
emitParallelUpdate();
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
makeDetails("parallel"),
|
|
296
|
+
);
|
|
297
|
+
allResults[index] = result;
|
|
298
|
+
doneCount += 1;
|
|
299
|
+
runningCount -= 1;
|
|
300
|
+
emitParallelUpdate();
|
|
301
|
+
return result;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
let aggregatorResult: SingleResult | undefined;
|
|
305
|
+
if (params.aggregator) {
|
|
306
|
+
const aggregator = params.aggregator;
|
|
307
|
+
status.update(fanInStatus(aggregator.agent));
|
|
308
|
+
const fanInContext = buildFanInContext(results);
|
|
309
|
+
const aggregatorTask = aggregator.task.includes("{previous}")
|
|
310
|
+
? aggregator.task.replace(/\{previous\}/g, fanInContext)
|
|
311
|
+
: `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`;
|
|
312
|
+
aggregatorResult = await runSingleAgent(
|
|
313
|
+
ctx.cwd,
|
|
314
|
+
agents,
|
|
315
|
+
aggregator.agent,
|
|
316
|
+
aggregatorTask,
|
|
317
|
+
aggregator.cwd,
|
|
318
|
+
undefined,
|
|
319
|
+
signal,
|
|
320
|
+
resolveThinkingLevel(aggregator.agent, aggregator.thinkingLevel),
|
|
321
|
+
resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
|
|
322
|
+
(partial) => {
|
|
323
|
+
status.update(fanInStatus(aggregator.agent));
|
|
324
|
+
if (onUpdate && partial.details?.results[0]) {
|
|
325
|
+
onUpdate({
|
|
326
|
+
content: partial.content,
|
|
327
|
+
details: makeDetails("parallel")(results, partial.details.results[0]),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
makeDetails("parallel"),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const successCount = results.filter((r) => r.exitCode === 0).length;
|
|
336
|
+
const summaries = results.map((r) => {
|
|
337
|
+
const output = getResultFinalOutput(r);
|
|
338
|
+
const error = r.errorMessage || r.stderr.trim();
|
|
339
|
+
const summaryText = output || error;
|
|
340
|
+
const preview = summaryText.slice(0, 160) + (summaryText.length > 160 ? "..." : "");
|
|
341
|
+
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
|
|
342
|
+
});
|
|
343
|
+
const aggregatorOutput = aggregatorResult ? getResultFinalOutput(aggregatorResult) : "";
|
|
344
|
+
const aggregatorError = aggregatorResult?.errorMessage || aggregatorResult?.stderr.trim() || "";
|
|
345
|
+
return {
|
|
346
|
+
content: [
|
|
347
|
+
{
|
|
348
|
+
type: "text",
|
|
349
|
+
text: aggregatorResult
|
|
350
|
+
? aggregatorOutput || aggregatorError || `(aggregator ${aggregatorResult.agent} produced no output)`
|
|
351
|
+
: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
details: makeDetails("parallel")(results, aggregatorResult),
|
|
355
|
+
isError: aggregatorResult
|
|
356
|
+
? aggregatorResult.exitCode !== 0 ||
|
|
357
|
+
aggregatorResult.stopReason === "error" ||
|
|
358
|
+
aggregatorResult.stopReason === "aborted"
|
|
359
|
+
: undefined,
|
|
360
|
+
};
|
|
361
|
+
} finally {
|
|
362
|
+
status.clear();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (params.agent && params.task) {
|
|
367
|
+
const status = startSubagentStatus(ctx, toolCallId, singleStatus(params.agent));
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const result = await runSingleAgent(
|
|
371
|
+
ctx.cwd,
|
|
372
|
+
agents,
|
|
373
|
+
params.agent,
|
|
374
|
+
params.task,
|
|
375
|
+
params.cwd,
|
|
376
|
+
undefined,
|
|
377
|
+
signal,
|
|
378
|
+
resolveThinkingLevel(params.agent, params.thinkingLevel),
|
|
379
|
+
resolveTimeoutMs(params.agent, params.timeoutMs),
|
|
380
|
+
onUpdate,
|
|
381
|
+
makeDetails("single"),
|
|
382
|
+
);
|
|
383
|
+
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
384
|
+
if (isError) {
|
|
385
|
+
const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
|
|
386
|
+
return {
|
|
387
|
+
content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
|
|
388
|
+
details: makeDetails("single")([result]),
|
|
389
|
+
isError: true,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
content: [{ type: "text", text: getResultFinalOutput(result) || "(no output)" }],
|
|
394
|
+
details: makeDetails("single")([result]),
|
|
395
|
+
};
|
|
396
|
+
} finally {
|
|
397
|
+
status.clear();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
402
|
+
return {
|
|
403
|
+
content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
|
|
404
|
+
details: makeDetails("single")([]),
|
|
405
|
+
};
|
|
406
|
+
}
|