@kky42/pi-subagents 1.0.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/LICENSE +21 -0
- package/README.md +64 -0
- package/assets/subagents.png +0 -0
- package/index.ts +2 -0
- package/package.json +62 -0
- package/src/pi-subagent.ts +643 -0
- package/src/prompts.ts +63 -0
- package/src/types.ts +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KKY
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pi-subagents
|
|
2
|
+
|
|
3
|
+
Claude Code-style subagents for pi: delegate repo exploration, broad code search, and independent reviews to fresh child agents.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Pi Subagents vs. Claude Code Subagents
|
|
8
|
+
|
|
9
|
+
`pi-subagents` brings the same subagent shape to pi: an `Agent` tool with `description`, `prompt`, and optional `subagent_type`.
|
|
10
|
+
|
|
11
|
+
Available agents:
|
|
12
|
+
|
|
13
|
+
- `general-purpose`: general agent for complex questions, code search, and multi-step investigations.
|
|
14
|
+
- `explorer`: fast read-only search agent for repo maps, file discovery, references, and concise findings.
|
|
15
|
+
|
|
16
|
+
Fresh subagents start with their own conversation and the same working directory. The parent gets the final subagent report back as a tool result, then synthesizes the answer for the user.
|
|
17
|
+
|
|
18
|
+
Recent comparison runs:
|
|
19
|
+
|
|
20
|
+
| Case | Claude haiku | pi deepseek-v4-flash |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| explore this repo | 1 Agent(Explore) | 1 Agent(explorer) |
|
|
23
|
+
| auth multi-repo comparison | 1 Agent | 3 Agent calls |
|
|
24
|
+
| migration second opinion | 1 Agent(Explore) | 2 Agent(explorer) calls |
|
|
25
|
+
| TODO/FIXME/skipped-test audit | 0 Agent, direct grep | 0 Agent, direct bash |
|
|
26
|
+
|
|
27
|
+
This is intentionally close to Claude Code's behavior: delegate when a specialized agent helps, and search directly when a simple grep/read path is clearer.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Install from pi:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pi install npm:@kky42/pi-subagents
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then run pi normally. The extension registers an `Agent` tool automatically.
|
|
38
|
+
|
|
39
|
+
## Example
|
|
40
|
+
|
|
41
|
+
Ask pi:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
explore this repo
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The main agent can launch:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
Agent({
|
|
51
|
+
description: "Explore repo structure",
|
|
52
|
+
subagent_type: "explorer",
|
|
53
|
+
prompt: "Map the project purpose, key directories, important files, scripts, tests, and caveats. Do not edit files."
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The explorer returns a concise repo map, and the main agent relays the useful parts.
|
|
58
|
+
|
|
59
|
+
## Notes
|
|
60
|
+
|
|
61
|
+
- Nested delegation is supported and bounded by the extension.
|
|
62
|
+
- Subagents inherit the caller's current model and thinking level.
|
|
63
|
+
- Subagents do not inherit parent conversation messages or tool results, so prompts should be self-contained.
|
|
64
|
+
- `explorer` is prompted as read-only; pi permissions are still controlled by the active pi runtime.
|
|
Binary file
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kky42/pi-subagents",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Claude Code-style subagents for pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": "./index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.ts",
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"assets"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-extension",
|
|
18
|
+
"subagent"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"check": "tsc --noEmit && vitest run",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-agent-core": "*",
|
|
28
|
+
"@earendil-works/pi-ai": "*",
|
|
29
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
30
|
+
"@earendil-works/pi-tui": "*",
|
|
31
|
+
"typebox": "*"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@earendil-works/pi-agent-core": "0.77.0",
|
|
35
|
+
"@earendil-works/pi-ai": "0.77.0",
|
|
36
|
+
"@earendil-works/pi-coding-agent": "0.77.0",
|
|
37
|
+
"@earendil-works/pi-tui": "0.77.0",
|
|
38
|
+
"@types/node": "^24.12.4",
|
|
39
|
+
"typebox": "1.1.38",
|
|
40
|
+
"typescript": "^5.9.3",
|
|
41
|
+
"vitest": "^3.2.4"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./index.ts"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.19.0"
|
|
50
|
+
},
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/kky42/pi-subagents.git"
|
|
54
|
+
},
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/kky42/pi-subagents/issues"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/kky42/pi-subagents#readme",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAgentSession,
|
|
3
|
+
DefaultResourceLoader,
|
|
4
|
+
defineTool,
|
|
5
|
+
getAgentDir,
|
|
6
|
+
SessionManager,
|
|
7
|
+
SettingsManager,
|
|
8
|
+
type AgentSessionEvent,
|
|
9
|
+
type ExtensionAPI,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
type ExtensionFactory,
|
|
12
|
+
type Theme,
|
|
13
|
+
type ToolDefinition,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Type, type Static } from "typebox";
|
|
17
|
+
import {
|
|
18
|
+
AGENT_PROMPT_GUIDELINES,
|
|
19
|
+
AGENT_PROMPT_SNIPPET,
|
|
20
|
+
buildCoordinatorPrompt,
|
|
21
|
+
getPresetAppendPrompt,
|
|
22
|
+
} from "./prompts.ts";
|
|
23
|
+
import type { SubagentExtensionOptions, SubagentProgressNode, SubagentToolDetails, SubagentType } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_MAX_DEPTH = 2;
|
|
26
|
+
const DEFAULT_MAX_WIDTH = 4;
|
|
27
|
+
const ALLOWED_SUBAGENTS: SubagentType[] = ["general-purpose", "explorer"];
|
|
28
|
+
|
|
29
|
+
const agentToolParameters = Type.Object({
|
|
30
|
+
description: Type.String({
|
|
31
|
+
description: "A short 3-5 word description of the task, used for UI display and routing context.",
|
|
32
|
+
}),
|
|
33
|
+
prompt: Type.String({
|
|
34
|
+
description: "The self-contained task briefing to send to the subagent.",
|
|
35
|
+
}),
|
|
36
|
+
subagent_type: Type.Optional(
|
|
37
|
+
Type.String({
|
|
38
|
+
description: `The preset subagent to use. Allowed values: ${ALLOWED_SUBAGENTS.join(", ")}. Defaults to general-purpose.`,
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type AgentToolParams = Static<typeof agentToolParameters>;
|
|
44
|
+
|
|
45
|
+
interface DelegationState {
|
|
46
|
+
depth: number;
|
|
47
|
+
maxDepth: number;
|
|
48
|
+
maxWidth: number;
|
|
49
|
+
childCount: number;
|
|
50
|
+
progressEnabled: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface CreateAgentToolOptions {
|
|
54
|
+
getThinkingLevel: () => ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type AgentToolResult = ReturnType<typeof textResult>;
|
|
58
|
+
|
|
59
|
+
const MAX_ACTIVITY_LINES = 3;
|
|
60
|
+
const PROGRESS_UPDATE_INTERVAL_MS = 250;
|
|
61
|
+
const PROGRESS_STATUSES: SubagentProgressNode["status"][] = ["running", "completed", "rejected", "error"];
|
|
62
|
+
|
|
63
|
+
function getCliMode(argv = process.argv): string | undefined {
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const arg = argv[i];
|
|
66
|
+
if (arg === "--mode") {
|
|
67
|
+
return argv[i + 1];
|
|
68
|
+
}
|
|
69
|
+
if (arg.startsWith("--mode=")) {
|
|
70
|
+
return arg.slice("--mode=".length);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shouldEnableProgress(ctx: ExtensionContext): boolean {
|
|
77
|
+
const mode = getCliMode();
|
|
78
|
+
return ctx.hasUI && mode !== "json" && mode !== "rpc";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeLimit(value: number | undefined, fallback: number, label: string): number {
|
|
82
|
+
if (value === undefined) {
|
|
83
|
+
return fallback;
|
|
84
|
+
}
|
|
85
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
86
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeSubagentType(value: string | undefined): SubagentType | undefined {
|
|
92
|
+
if (value === undefined || value.trim() === "") {
|
|
93
|
+
return "general-purpose";
|
|
94
|
+
}
|
|
95
|
+
return ALLOWED_SUBAGENTS.includes(value as SubagentType) ? (value as SubagentType) : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function textResult(text: string, details: SubagentToolDetails) {
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: "text" as const, text }],
|
|
101
|
+
details,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
106
|
+
return value !== null && typeof value === "object";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isProgressStatus(value: unknown): value is SubagentProgressNode["status"] {
|
|
110
|
+
return typeof value === "string" && PROGRESS_STATUSES.includes(value as SubagentProgressNode["status"]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isSubagentProgressNode(value: unknown): value is SubagentProgressNode {
|
|
114
|
+
if (!isRecord(value)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const subagentType = value.subagentType;
|
|
118
|
+
return (
|
|
119
|
+
typeof value.id === "string" &&
|
|
120
|
+
typeof value.description === "string" &&
|
|
121
|
+
(subagentType === "unknown" || ALLOWED_SUBAGENTS.includes(subagentType as SubagentType)) &&
|
|
122
|
+
Number.isFinite(value.depth) &&
|
|
123
|
+
isProgressStatus(value.status) &&
|
|
124
|
+
Number.isFinite(value.startedAt) &&
|
|
125
|
+
Array.isArray(value.activity) &&
|
|
126
|
+
value.activity.every((line) => typeof line === "string") &&
|
|
127
|
+
Number.isFinite(value.activityCount) &&
|
|
128
|
+
Array.isArray(value.children)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getProgressFromToolResult(result: unknown): SubagentProgressNode | undefined {
|
|
133
|
+
if (!isRecord(result) || !isRecord(result.details) || !("subagentType" in result.details)) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
return isSubagentProgressNode(result.details.progress) ? result.details.progress : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createProgressNode(
|
|
140
|
+
id: string,
|
|
141
|
+
params: AgentToolParams,
|
|
142
|
+
subagentType: SubagentType,
|
|
143
|
+
depth: number,
|
|
144
|
+
status: SubagentProgressNode["status"] = "running",
|
|
145
|
+
): SubagentProgressNode {
|
|
146
|
+
return {
|
|
147
|
+
id,
|
|
148
|
+
description: params.description,
|
|
149
|
+
subagentType,
|
|
150
|
+
depth,
|
|
151
|
+
status,
|
|
152
|
+
startedAt: Date.now(),
|
|
153
|
+
activity: [],
|
|
154
|
+
activityCount: 0,
|
|
155
|
+
children: [],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function addActivity(progress: SubagentProgressNode, line: string): void {
|
|
160
|
+
const normalized = line.replace(/\s+/g, " ").trim();
|
|
161
|
+
if (!normalized) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
progress.activityCount++;
|
|
165
|
+
progress.activity.push(normalized);
|
|
166
|
+
if (progress.activity.length > MAX_ACTIVITY_LINES) {
|
|
167
|
+
progress.activity.splice(0, progress.activity.length - MAX_ACTIVITY_LINES);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function replaceLatestActivity(progress: SubagentProgressNode, line: string): void {
|
|
172
|
+
const normalized = line.replace(/\s+/g, " ").trim();
|
|
173
|
+
if (!normalized) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (progress.activity.length === 0) {
|
|
177
|
+
addActivity(progress, normalized);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
progress.activity[progress.activity.length - 1] = normalized;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getFirstTextLine(text: string): string {
|
|
184
|
+
return text.split("\n").find((line) => line.trim()) ?? text;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function extractTextContent(content: unknown): string {
|
|
188
|
+
if (typeof content === "string") {
|
|
189
|
+
return content;
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(content)) {
|
|
192
|
+
return "";
|
|
193
|
+
}
|
|
194
|
+
return content
|
|
195
|
+
.map((part) => {
|
|
196
|
+
const block = part as { type?: string; text?: unknown };
|
|
197
|
+
return block.type === "text" && typeof block.text === "string" ? block.text : undefined;
|
|
198
|
+
})
|
|
199
|
+
.filter((part): part is string => part !== undefined)
|
|
200
|
+
.join("\n")
|
|
201
|
+
.trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getToolArgPreview(args: unknown): string {
|
|
205
|
+
if (!args || typeof args !== "object") {
|
|
206
|
+
return "";
|
|
207
|
+
}
|
|
208
|
+
const record = args as Record<string, unknown>;
|
|
209
|
+
const value =
|
|
210
|
+
typeof record.description === "string" ? record.description
|
|
211
|
+
: typeof record.path === "string" ? record.path
|
|
212
|
+
: typeof record.command === "string" ? record.command
|
|
213
|
+
: typeof record.pattern === "string" ? record.pattern
|
|
214
|
+
: typeof record.query === "string" ? record.query
|
|
215
|
+
: typeof record.url === "string" ? record.url
|
|
216
|
+
: "";
|
|
217
|
+
return value.replace(/\s+/g, " ").trim();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function updateProgressFromEvent(progress: SubagentProgressNode, event: AgentSessionEvent): void {
|
|
221
|
+
if (event.type === "tool_execution_start") {
|
|
222
|
+
if (event.toolName === "Agent") {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const preview = getToolArgPreview(event.args);
|
|
226
|
+
addActivity(progress, `${event.toolName}${preview ? ` ${preview}` : ""}`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (event.type === "message_start" && event.message.role === "assistant") {
|
|
231
|
+
addActivity(progress, "Thinking...");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (event.type === "tool_execution_update") {
|
|
236
|
+
const childProgress = event.toolName === "Agent" ? getProgressFromToolResult(event.partialResult) : undefined;
|
|
237
|
+
if (childProgress) {
|
|
238
|
+
progress.children = mergeChildProgress(progress.children, childProgress);
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (event.type === "tool_execution_end") {
|
|
244
|
+
const childProgress = event.toolName === "Agent" ? getProgressFromToolResult(event.result) : undefined;
|
|
245
|
+
if (childProgress) {
|
|
246
|
+
progress.children = mergeChildProgress(progress.children, childProgress);
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (event.type === "message_update") {
|
|
252
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
253
|
+
const content =
|
|
254
|
+
"partial" in assistantEvent ? assistantEvent.partial.content
|
|
255
|
+
: "message" in assistantEvent ? assistantEvent.message.content
|
|
256
|
+
: "error" in assistantEvent ? assistantEvent.error.content
|
|
257
|
+
: undefined;
|
|
258
|
+
const text = extractTextContent(content);
|
|
259
|
+
if (text) {
|
|
260
|
+
replaceLatestActivity(progress, getFirstTextLine(text));
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
266
|
+
const text = extractTextContent(event.message.content);
|
|
267
|
+
if (text) {
|
|
268
|
+
replaceLatestActivity(progress, getFirstTextLine(text));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function mergeChildProgress(
|
|
274
|
+
children: SubagentProgressNode[],
|
|
275
|
+
child: SubagentProgressNode,
|
|
276
|
+
): SubagentProgressNode[] {
|
|
277
|
+
const index = children.findIndex((candidate) => candidate.id === child.id);
|
|
278
|
+
if (index === -1) {
|
|
279
|
+
return [...children, child];
|
|
280
|
+
}
|
|
281
|
+
const next = children.slice();
|
|
282
|
+
next[index] = child;
|
|
283
|
+
return next;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function getDisplayLabel(subagentType: SubagentType | "unknown"): string {
|
|
287
|
+
return subagentType;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function formatProgressTitle(node: SubagentProgressNode): string {
|
|
291
|
+
const label = getDisplayLabel(node.subagentType);
|
|
292
|
+
const description = node.description.trim();
|
|
293
|
+
return description ? `Agent(${label}: ${description})` : `Agent(${label})`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function formatDuration(ms: number): string {
|
|
297
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
298
|
+
const seconds = totalSeconds % 60;
|
|
299
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
300
|
+
const minutes = totalMinutes % 60;
|
|
301
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
302
|
+
if (hours > 0) {
|
|
303
|
+
return `${hours}h${minutes}m${seconds}s`;
|
|
304
|
+
}
|
|
305
|
+
if (minutes > 0) {
|
|
306
|
+
return `${minutes}m${seconds}s`;
|
|
307
|
+
}
|
|
308
|
+
return `${seconds}s`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderProgressNode(
|
|
312
|
+
node: SubagentProgressNode,
|
|
313
|
+
theme: Theme,
|
|
314
|
+
depth = 0,
|
|
315
|
+
): Container {
|
|
316
|
+
const container = new Container();
|
|
317
|
+
const indent = " ".repeat(depth);
|
|
318
|
+
const status = node.status === "completed" ? "done" : node.status;
|
|
319
|
+
const elapsed = formatDuration((node.endedAt ?? Date.now()) - node.startedAt);
|
|
320
|
+
container.addChild(
|
|
321
|
+
new Text(
|
|
322
|
+
`${indent}${theme.bold(formatProgressTitle(node))} ${theme.fg("dim", `${status} ${elapsed}`)}`,
|
|
323
|
+
0,
|
|
324
|
+
0,
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const skipped = node.activityCount - node.activity.length;
|
|
329
|
+
if (skipped > 0) {
|
|
330
|
+
container.addChild(new Text(`${indent} ${theme.fg("muted", `... +${skipped} earlier events`)}`, 0, 0));
|
|
331
|
+
}
|
|
332
|
+
for (const line of node.activity) {
|
|
333
|
+
container.addChild(new Text(`${indent} ${theme.fg("muted", line)}`, 0, 0));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const child of node.children) {
|
|
337
|
+
container.addChild(renderProgressNode(child, theme, depth + 1));
|
|
338
|
+
}
|
|
339
|
+
if (node.error) {
|
|
340
|
+
container.addChild(new Text(`${indent} ${theme.fg("error", node.error)}`, 0, 0));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return container;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function extractFinalAssistantText(messages: readonly unknown[]): string {
|
|
347
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
348
|
+
const message = messages[i] as { role?: string; content?: unknown };
|
|
349
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const textParts = message.content
|
|
353
|
+
.map((part) => {
|
|
354
|
+
const block = part as { type?: string; text?: unknown };
|
|
355
|
+
return block.type === "text" && typeof block.text === "string" ? block.text : undefined;
|
|
356
|
+
})
|
|
357
|
+
.filter((part): part is string => part !== undefined);
|
|
358
|
+
if (textParts.length > 0) {
|
|
359
|
+
return textParts.join("\n").trim();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return "";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function runSubagent(
|
|
366
|
+
toolCallId: string,
|
|
367
|
+
params: AgentToolParams,
|
|
368
|
+
subagentType: SubagentType,
|
|
369
|
+
state: DelegationState,
|
|
370
|
+
options: CreateAgentToolOptions,
|
|
371
|
+
signal: AbortSignal | undefined,
|
|
372
|
+
ctx: ExtensionContext,
|
|
373
|
+
onProgress: ((result: AgentToolResult) => void) | undefined,
|
|
374
|
+
): Promise<ReturnType<typeof textResult>> {
|
|
375
|
+
const progressDepth = state.depth + 1;
|
|
376
|
+
const progress =
|
|
377
|
+
state.progressEnabled ? createProgressNode(toolCallId, params, subagentType, progressDepth) : undefined;
|
|
378
|
+
|
|
379
|
+
if (!ctx.model) {
|
|
380
|
+
return textResult("Cannot launch subagent: no model is selected.", {
|
|
381
|
+
description: params.description,
|
|
382
|
+
subagentType,
|
|
383
|
+
depth: progressDepth,
|
|
384
|
+
status: "rejected",
|
|
385
|
+
error: "No model is selected",
|
|
386
|
+
...(progress ? { progress: { ...progress, status: "rejected", error: "No model is selected" } } : {}),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const agentDir = getAgentDir();
|
|
391
|
+
const cwd = ctx.cwd;
|
|
392
|
+
const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
393
|
+
const appendPrompts = [
|
|
394
|
+
getPresetAppendPrompt(subagentType),
|
|
395
|
+
buildCoordinatorPrompt(state.maxDepth, state.maxWidth),
|
|
396
|
+
].filter((prompt): prompt is string => Boolean(prompt));
|
|
397
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
398
|
+
cwd,
|
|
399
|
+
agentDir,
|
|
400
|
+
settingsManager,
|
|
401
|
+
appendSystemPrompt: appendPrompts,
|
|
402
|
+
});
|
|
403
|
+
await resourceLoader.reload();
|
|
404
|
+
|
|
405
|
+
const childState: DelegationState = {
|
|
406
|
+
depth: progressDepth,
|
|
407
|
+
maxDepth: state.maxDepth,
|
|
408
|
+
maxWidth: state.maxWidth,
|
|
409
|
+
childCount: 0,
|
|
410
|
+
progressEnabled: state.progressEnabled,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const nestedAgentTool = createAgentTool(childState, options);
|
|
414
|
+
const { session } = await createAgentSession({
|
|
415
|
+
cwd,
|
|
416
|
+
agentDir,
|
|
417
|
+
model: ctx.model,
|
|
418
|
+
thinkingLevel: options.getThinkingLevel(),
|
|
419
|
+
modelRegistry: ctx.modelRegistry,
|
|
420
|
+
settingsManager,
|
|
421
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
422
|
+
resourceLoader,
|
|
423
|
+
customTools: [nestedAgentTool as ToolDefinition],
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
let abortHandler: (() => void) | undefined;
|
|
427
|
+
if (signal) {
|
|
428
|
+
abortHandler = () => {
|
|
429
|
+
void session.abort();
|
|
430
|
+
};
|
|
431
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let lastProgressEmit = 0;
|
|
435
|
+
let pendingProgressTimer: ReturnType<typeof setTimeout> | undefined;
|
|
436
|
+
const emitProgress = () => {
|
|
437
|
+
if (!progress || !onProgress) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (pendingProgressTimer) {
|
|
441
|
+
clearTimeout(pendingProgressTimer);
|
|
442
|
+
pendingProgressTimer = undefined;
|
|
443
|
+
}
|
|
444
|
+
lastProgressEmit = Date.now();
|
|
445
|
+
onProgress(textResult(`Subagent "${params.description}" (${subagentType}) is running.`, {
|
|
446
|
+
description: params.description,
|
|
447
|
+
subagentType,
|
|
448
|
+
depth: progressDepth,
|
|
449
|
+
status: progress.status,
|
|
450
|
+
result: progress.result,
|
|
451
|
+
error: progress.error,
|
|
452
|
+
progress,
|
|
453
|
+
}));
|
|
454
|
+
};
|
|
455
|
+
const emitProgressSoon = () => {
|
|
456
|
+
const elapsed = Date.now() - lastProgressEmit;
|
|
457
|
+
if (elapsed >= PROGRESS_UPDATE_INTERVAL_MS) {
|
|
458
|
+
emitProgress();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!pendingProgressTimer) {
|
|
462
|
+
pendingProgressTimer = setTimeout(() => {
|
|
463
|
+
pendingProgressTimer = undefined;
|
|
464
|
+
emitProgress();
|
|
465
|
+
}, PROGRESS_UPDATE_INTERVAL_MS - elapsed);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const unsubscribe = progress
|
|
470
|
+
? session.subscribe((event) => {
|
|
471
|
+
updateProgressFromEvent(progress, event);
|
|
472
|
+
emitProgressSoon();
|
|
473
|
+
})
|
|
474
|
+
: undefined;
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await session.bindExtensions({});
|
|
478
|
+
emitProgress();
|
|
479
|
+
await session.prompt(params.prompt, { source: "extension" });
|
|
480
|
+
const result = extractFinalAssistantText(session.messages) || "(no final text output)";
|
|
481
|
+
if (progress) {
|
|
482
|
+
progress.status = "completed";
|
|
483
|
+
progress.result = result;
|
|
484
|
+
progress.endedAt = Date.now();
|
|
485
|
+
}
|
|
486
|
+
return textResult(`Subagent "${params.description}" (${subagentType}) completed:\n\n${result}`, {
|
|
487
|
+
description: params.description,
|
|
488
|
+
subagentType,
|
|
489
|
+
depth: childState.depth,
|
|
490
|
+
status: "completed",
|
|
491
|
+
result,
|
|
492
|
+
...(progress ? { progress } : {}),
|
|
493
|
+
});
|
|
494
|
+
} catch (error) {
|
|
495
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
496
|
+
if (progress) {
|
|
497
|
+
progress.status = "error";
|
|
498
|
+
progress.error = message;
|
|
499
|
+
progress.endedAt = Date.now();
|
|
500
|
+
}
|
|
501
|
+
return textResult(`Subagent "${params.description}" (${subagentType}) failed: ${message}`, {
|
|
502
|
+
description: params.description,
|
|
503
|
+
subagentType,
|
|
504
|
+
depth: childState.depth,
|
|
505
|
+
status: "error",
|
|
506
|
+
error: message,
|
|
507
|
+
...(progress ? { progress } : {}),
|
|
508
|
+
});
|
|
509
|
+
} finally {
|
|
510
|
+
if (pendingProgressTimer) {
|
|
511
|
+
clearTimeout(pendingProgressTimer);
|
|
512
|
+
}
|
|
513
|
+
unsubscribe?.();
|
|
514
|
+
if (signal && abortHandler) {
|
|
515
|
+
signal.removeEventListener("abort", abortHandler);
|
|
516
|
+
}
|
|
517
|
+
session.dispose();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function createAgentTool(
|
|
522
|
+
state: DelegationState,
|
|
523
|
+
options: CreateAgentToolOptions,
|
|
524
|
+
): ToolDefinition<typeof agentToolParameters, SubagentToolDetails> {
|
|
525
|
+
return defineTool({
|
|
526
|
+
name: "Agent",
|
|
527
|
+
label: "Agent",
|
|
528
|
+
description: `Launch a fresh subagent. Available agents: ${ALLOWED_SUBAGENTS.join(", ")}. Prompts must be self-contained.`,
|
|
529
|
+
promptSnippet: AGENT_PROMPT_SNIPPET,
|
|
530
|
+
promptGuidelines: AGENT_PROMPT_GUIDELINES,
|
|
531
|
+
parameters: agentToolParameters,
|
|
532
|
+
executionMode: "parallel",
|
|
533
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
534
|
+
const effectiveState: DelegationState = {
|
|
535
|
+
...state,
|
|
536
|
+
progressEnabled: state.progressEnabled || shouldEnableProgress(ctx),
|
|
537
|
+
};
|
|
538
|
+
const subagentType = normalizeSubagentType(params.subagent_type);
|
|
539
|
+
if (!subagentType) {
|
|
540
|
+
return textResult(
|
|
541
|
+
`Unknown subagent_type "${params.subagent_type}". Allowed values: ${ALLOWED_SUBAGENTS.join(", ")}.`,
|
|
542
|
+
{
|
|
543
|
+
description: params.description,
|
|
544
|
+
subagentType: "unknown",
|
|
545
|
+
depth: effectiveState.depth + 1,
|
|
546
|
+
status: "rejected",
|
|
547
|
+
error: "Unknown subagent_type",
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (effectiveState.depth >= effectiveState.maxDepth) {
|
|
553
|
+
return textResult(
|
|
554
|
+
`Maximum subagent depth reached. Current depth: ${effectiveState.depth}; maxDepth: ${effectiveState.maxDepth}.`,
|
|
555
|
+
{
|
|
556
|
+
description: params.description,
|
|
557
|
+
subagentType,
|
|
558
|
+
depth: effectiveState.depth + 1,
|
|
559
|
+
status: "rejected",
|
|
560
|
+
error: "Maximum subagent depth reached",
|
|
561
|
+
},
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (state.childCount >= effectiveState.maxWidth) {
|
|
566
|
+
return textResult(
|
|
567
|
+
`Maximum subagent width reached for this agent run. maxWidth: ${effectiveState.maxWidth}.`,
|
|
568
|
+
{
|
|
569
|
+
description: params.description,
|
|
570
|
+
subagentType,
|
|
571
|
+
depth: effectiveState.depth + 1,
|
|
572
|
+
status: "rejected",
|
|
573
|
+
error: "Maximum subagent width reached",
|
|
574
|
+
},
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
state.childCount++;
|
|
579
|
+
return runSubagent(
|
|
580
|
+
toolCallId,
|
|
581
|
+
params,
|
|
582
|
+
subagentType,
|
|
583
|
+
effectiveState,
|
|
584
|
+
options,
|
|
585
|
+
signal,
|
|
586
|
+
ctx,
|
|
587
|
+
effectiveState.progressEnabled ? onUpdate : undefined,
|
|
588
|
+
);
|
|
589
|
+
},
|
|
590
|
+
renderCall(args, theme, context) {
|
|
591
|
+
if (context.executionStarted) {
|
|
592
|
+
return new Text("", 0, 0);
|
|
593
|
+
}
|
|
594
|
+
const subagentType = normalizeSubagentType(args.subagent_type) ?? "unknown";
|
|
595
|
+
const description = typeof args.description === "string" ? args.description.trim() : "";
|
|
596
|
+
return new Text(
|
|
597
|
+
`${theme.bold("Agent")} ${theme.fg("muted", subagentType)}${description ? ` ${theme.fg("dim", description)}` : ""}`,
|
|
598
|
+
0,
|
|
599
|
+
0,
|
|
600
|
+
);
|
|
601
|
+
},
|
|
602
|
+
renderResult(result, _options, theme) {
|
|
603
|
+
const details = result.details;
|
|
604
|
+
if (details.progress) {
|
|
605
|
+
return renderProgressNode(details.progress, theme);
|
|
606
|
+
}
|
|
607
|
+
return new Text(
|
|
608
|
+
`${theme.bold("Agent")} ${theme.fg("muted", details.subagentType)} ${theme.fg("dim", details.description)} ${theme.fg("dim", details.status)}`,
|
|
609
|
+
0,
|
|
610
|
+
0,
|
|
611
|
+
);
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function createSubagentExtension(options: SubagentExtensionOptions = {}): ExtensionFactory {
|
|
617
|
+
const maxDepth = normalizeLimit(options.maxDepth, DEFAULT_MAX_DEPTH, "maxDepth");
|
|
618
|
+
const maxWidth = normalizeLimit(options.maxWidth, DEFAULT_MAX_WIDTH, "maxWidth");
|
|
619
|
+
|
|
620
|
+
return function subagentExtension(pi: ExtensionAPI) {
|
|
621
|
+
const rootState: DelegationState = {
|
|
622
|
+
depth: 0,
|
|
623
|
+
maxDepth,
|
|
624
|
+
maxWidth,
|
|
625
|
+
childCount: 0,
|
|
626
|
+
progressEnabled: false,
|
|
627
|
+
};
|
|
628
|
+
const toolOptions: CreateAgentToolOptions = {
|
|
629
|
+
getThinkingLevel: () => pi.getThinkingLevel(),
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
pi.registerTool(createAgentTool(rootState, toolOptions));
|
|
633
|
+
|
|
634
|
+
pi.on("before_agent_start", (event) => {
|
|
635
|
+
rootState.childCount = 0;
|
|
636
|
+
return {
|
|
637
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildCoordinatorPrompt(maxDepth, maxWidth)}`,
|
|
638
|
+
};
|
|
639
|
+
});
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export default createSubagentExtension();
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SubagentType } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const PRESET_DESCRIPTIONS: Record<SubagentType, string> = {
|
|
4
|
+
"general-purpose":
|
|
5
|
+
"General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks.",
|
|
6
|
+
explorer:
|
|
7
|
+
"Fast read-only search agent for locating code, mapping repositories, tracing references, and reporting concise findings.",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const AGENT_PROMPT_SNIPPET =
|
|
11
|
+
"Launch a fresh subagent when the task matches an available agent, can run independently, or would read across several files.";
|
|
12
|
+
|
|
13
|
+
export const AGENT_PROMPT_GUIDELINES = [
|
|
14
|
+
"Reach for Agent when the task matches an available agent, when you have independent work to run in parallel, or when answering would mean reading across several files.",
|
|
15
|
+
"Use explorer for repository reconnaissance, locating files, grepping symbols or keywords, tracing references, and concise read-only findings.",
|
|
16
|
+
"Use general-purpose for researching complex questions, broader multi-step investigations, or independent second opinions.",
|
|
17
|
+
"For a single-fact lookup where you already know the file, symbol, or value, search directly instead of spawning a subagent.",
|
|
18
|
+
"Once you delegate a search, do not also run the same search yourself; wait for the result and keep the conclusion, not raw file dumps.",
|
|
19
|
+
"If the user asks to explore or survey a repo, use explorer to produce a concise map before doing detailed follow-up yourself.",
|
|
20
|
+
"If the user asks for parallel work, launch multiple Agent calls in the same assistant response.",
|
|
21
|
+
"Write self-contained subagent prompts: fresh subagents do not inherit parent conversation, tool results, or reasoning.",
|
|
22
|
+
"Clearly tell the subagent whether you expect read-only research or code changes.",
|
|
23
|
+
"The Agent final message is returned to you as the tool result and is not shown to the user; relay what matters.",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function formatAvailableAgents(agentDescriptions: Record<string, string>): string {
|
|
27
|
+
return Object.entries(agentDescriptions)
|
|
28
|
+
.map(([name, description]) => `- ${name}: ${description}`)
|
|
29
|
+
.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getPresetAppendPrompt(subagentType: SubagentType): string | undefined {
|
|
33
|
+
if (subagentType === "general-purpose") {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return `# Explorer Subagent Role
|
|
38
|
+
|
|
39
|
+
You are a file search specialist. Your job is to find and analyze existing project files efficiently, then report clear findings to the caller.
|
|
40
|
+
|
|
41
|
+
This is a read-only exploration task by role. Do not create, edit, delete, move, copy, or install anything. Do not use shell redirects, heredocs, or commands that change project state.
|
|
42
|
+
|
|
43
|
+
Use dedicated file tools for search and reading when available. Use shell commands only for read-only inspection such as listing files, checking git status, viewing diffs, or printing command output.
|
|
44
|
+
|
|
45
|
+
Adapt your search breadth to the caller's prompt. For targeted lookups, be fast and direct. For broad investigations, search across multiple names, paths, and conventions before concluding.
|
|
46
|
+
|
|
47
|
+
Return a concise final report with the relevant files, symbols, and caveats. Do not create documentation files.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildCoordinatorPrompt(_maxDepth: number, _maxWidth: number): string {
|
|
51
|
+
return `# Subagent Delegation
|
|
52
|
+
|
|
53
|
+
Available agents:
|
|
54
|
+
${formatAvailableAgents(PRESET_DESCRIPTIONS)}
|
|
55
|
+
|
|
56
|
+
Use Agent with specialized agents when the task at hand matches the agent's description. Subagents are valuable for parallelizing independent queries or protecting the main context window from excessive results, but they should not be used excessively when not needed.
|
|
57
|
+
|
|
58
|
+
Example usage:
|
|
59
|
+
- User asks "explore this repo": use Agent with subagent_type "explorer" and ask it to map the project purpose, key directories, important files, scripts, tests, and caveats without editing files.
|
|
60
|
+
- User asks for a second opinion on a risky change: use Agent with subagent_type "general-purpose" and give it enough context to review independently.
|
|
61
|
+
|
|
62
|
+
Nested subagents are allowed, but delegation depth and width are bounded by the extension. If a limit is reached, the Agent tool will reject the call.`;
|
|
63
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type SubagentType = "general-purpose" | "explorer";
|
|
2
|
+
|
|
3
|
+
export interface SubagentExtensionOptions {
|
|
4
|
+
maxDepth?: number;
|
|
5
|
+
maxWidth?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SubagentProgressNode {
|
|
9
|
+
id: string;
|
|
10
|
+
description: string;
|
|
11
|
+
subagentType: SubagentType | "unknown";
|
|
12
|
+
depth: number;
|
|
13
|
+
status: "running" | "completed" | "rejected" | "error";
|
|
14
|
+
startedAt: number;
|
|
15
|
+
endedAt?: number;
|
|
16
|
+
activity: string[];
|
|
17
|
+
activityCount: number;
|
|
18
|
+
children: SubagentProgressNode[];
|
|
19
|
+
result?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SubagentToolDetails {
|
|
24
|
+
description: string;
|
|
25
|
+
subagentType: SubagentType | "unknown";
|
|
26
|
+
depth: number;
|
|
27
|
+
status: "running" | "completed" | "rejected" | "error";
|
|
28
|
+
result?: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
progress?: SubagentProgressNode;
|
|
31
|
+
}
|