@agent-controller/runtime 0.3.1 → 0.3.2
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/dist/adapter.js
CHANGED
|
@@ -504,13 +504,23 @@ export async function runSession(spec, emit) {
|
|
|
504
504
|
}
|
|
505
505
|
}
|
|
506
506
|
}
|
|
507
|
-
// Resolve the vendored subagent extension
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
507
|
+
// Resolve the vendored subagent extension. Try the source-tree layout
|
|
508
|
+
// FIRST (../../extensions/subagent relative to runtime/dist) so a
|
|
509
|
+
// developer's edits to extensions/subagent/* take effect on the next
|
|
510
|
+
// run without rebuilding; only fall back to the in-package bundled
|
|
511
|
+
// copy (dist/extensions/subagent, populated by
|
|
512
|
+
// scripts/copy-vendored-extensions.mjs) when the source-tree path
|
|
513
|
+
// doesn't exist — which is the case for npm-installed adapters where
|
|
514
|
+
// only dist/ ships. Push the in-package path if neither exists; Pi
|
|
515
|
+
// surfaces a clear file-not-found at session start. Codex passes 3+4
|
|
516
|
+
// of slice 4.2 caught this.
|
|
511
517
|
const __filename = fileURLToPath(import.meta.url);
|
|
512
518
|
const __dirname = dirname(__filename);
|
|
513
|
-
const
|
|
519
|
+
const subagentSourceTree = resolve(__dirname, "..", "..", "extensions", "subagent", "entrypoint.ts");
|
|
520
|
+
const subagentInPackage = resolve(__dirname, "extensions", "subagent", "entrypoint.ts");
|
|
521
|
+
const subagentExtPath = existsSync(subagentSourceTree)
|
|
522
|
+
? subagentSourceTree
|
|
523
|
+
: subagentInPackage;
|
|
514
524
|
entrypointPaths.push(subagentExtPath);
|
|
515
525
|
}
|
|
516
526
|
// Populate the extension-config env var BEFORE constructing the loader
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent discovery and configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
export type AgentScope = "user" | "project" | "both";
|
|
10
|
+
|
|
11
|
+
export interface AgentConfig {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
tools?: string[];
|
|
15
|
+
model?: string;
|
|
16
|
+
systemPrompt: string;
|
|
17
|
+
source: "user" | "project";
|
|
18
|
+
filePath: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AgentDiscoveryResult {
|
|
22
|
+
agents: AgentConfig[];
|
|
23
|
+
projectAgentsDir: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
|
27
|
+
const agents: AgentConfig[] = [];
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(dir)) {
|
|
30
|
+
return agents;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let entries: fs.Dirent[];
|
|
34
|
+
try {
|
|
35
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return agents;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
42
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
43
|
+
|
|
44
|
+
const filePath = path.join(dir, entry.name);
|
|
45
|
+
let content: string;
|
|
46
|
+
try {
|
|
47
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
53
|
+
|
|
54
|
+
if (!frontmatter.name || !frontmatter.description) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const tools = frontmatter.tools
|
|
59
|
+
?.split(",")
|
|
60
|
+
.map((t: string) => t.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
|
|
63
|
+
agents.push({
|
|
64
|
+
name: frontmatter.name,
|
|
65
|
+
description: frontmatter.description,
|
|
66
|
+
tools: tools && tools.length > 0 ? tools : undefined,
|
|
67
|
+
model: frontmatter.model,
|
|
68
|
+
systemPrompt: body,
|
|
69
|
+
source,
|
|
70
|
+
filePath,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return agents;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isDirectory(p: string): boolean {
|
|
78
|
+
try {
|
|
79
|
+
return fs.statSync(p).isDirectory();
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
86
|
+
let currentDir = cwd;
|
|
87
|
+
while (true) {
|
|
88
|
+
const candidate = path.join(currentDir, ".pi", "agents");
|
|
89
|
+
if (isDirectory(candidate)) return candidate;
|
|
90
|
+
|
|
91
|
+
const parentDir = path.dirname(currentDir);
|
|
92
|
+
if (parentDir === currentDir) return null;
|
|
93
|
+
currentDir = parentDir;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
98
|
+
const userDir = path.join(getAgentDir(), "agents");
|
|
99
|
+
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
100
|
+
|
|
101
|
+
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
102
|
+
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
|
103
|
+
|
|
104
|
+
const agentMap = new Map<string, AgentConfig>();
|
|
105
|
+
|
|
106
|
+
if (scope === "both") {
|
|
107
|
+
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
108
|
+
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
109
|
+
} else if (scope === "user") {
|
|
110
|
+
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
111
|
+
} else {
|
|
112
|
+
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
|
119
|
+
if (agents.length === 0) return { text: "none", remaining: 0 };
|
|
120
|
+
const listed = agents.slice(0, maxItems);
|
|
121
|
+
const remaining = agents.length - listed.length;
|
|
122
|
+
return {
|
|
123
|
+
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
|
|
124
|
+
remaining,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent Tool - Delegate tasks to specialized agents
|
|
3
|
+
*
|
|
4
|
+
* Vendored from @earendil-works/pi-coding-agent/examples/extensions/subagent/index.ts
|
|
5
|
+
* Adaptations:
|
|
6
|
+
* - Removed @earendil-works/pi-tui imports (Container, Markdown, Spacer, Text)
|
|
7
|
+
* since those packages are not available outside pi-coding-agent's own node_modules.
|
|
8
|
+
* - renderCall/renderResult return plain strings instead of TUI components.
|
|
9
|
+
* - AgentToolResult imported from @earendil-works/pi-coding-agent (it re-exports it).
|
|
10
|
+
* - typebox import changed to @sinclair/typebox (available in runtime node_modules).
|
|
11
|
+
* - All core execution logic (spawn, JSON mode, chain/parallel/single) preserved verbatim.
|
|
12
|
+
*
|
|
13
|
+
* Spawns a separate `pi` process for each subagent invocation,
|
|
14
|
+
* giving it an isolated context window.
|
|
15
|
+
*
|
|
16
|
+
* Supports three modes:
|
|
17
|
+
* - Single: { agent: "name", task: "..." }
|
|
18
|
+
* - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
|
|
19
|
+
* - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
|
|
20
|
+
*
|
|
21
|
+
* Uses JSON mode to capture structured output from subagents.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as os from "node:os";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
30
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
31
|
+
import { type ExtensionAPI, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import { Type } from "@sinclair/typebox";
|
|
33
|
+
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts";
|
|
34
|
+
|
|
35
|
+
const MAX_PARALLEL_TASKS = 8;
|
|
36
|
+
const MAX_CONCURRENCY = 4;
|
|
37
|
+
const PER_TASK_OUTPUT_CAP = 50 * 1024;
|
|
38
|
+
|
|
39
|
+
interface UsageStats {
|
|
40
|
+
input: number;
|
|
41
|
+
output: number;
|
|
42
|
+
cacheRead: number;
|
|
43
|
+
cacheWrite: number;
|
|
44
|
+
cost: number;
|
|
45
|
+
contextTokens: number;
|
|
46
|
+
turns: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SingleResult {
|
|
50
|
+
agent: string;
|
|
51
|
+
agentSource: "user" | "project" | "unknown";
|
|
52
|
+
task: string;
|
|
53
|
+
exitCode: number;
|
|
54
|
+
messages: Message[];
|
|
55
|
+
stderr: string;
|
|
56
|
+
usage: UsageStats;
|
|
57
|
+
model?: string;
|
|
58
|
+
stopReason?: string;
|
|
59
|
+
errorMessage?: string;
|
|
60
|
+
step?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SubagentDetails {
|
|
64
|
+
mode: "single" | "parallel" | "chain";
|
|
65
|
+
agentScope: AgentScope;
|
|
66
|
+
projectAgentsDir: string | null;
|
|
67
|
+
results: SingleResult[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getFinalOutput(messages: Message[]): string {
|
|
71
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
72
|
+
const msg = messages[i];
|
|
73
|
+
if (msg.role === "assistant") {
|
|
74
|
+
for (const part of msg.content) {
|
|
75
|
+
if (part.type === "text") return part.text;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isFailedResult(result: SingleResult): boolean {
|
|
83
|
+
return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getResultOutput(result: SingleResult): string {
|
|
87
|
+
if (isFailedResult(result)) {
|
|
88
|
+
return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
89
|
+
}
|
|
90
|
+
return getFinalOutput(result.messages) || "(no output)";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function truncateParallelOutput(output: string): string {
|
|
94
|
+
const byteLength = Buffer.byteLength(output, "utf8");
|
|
95
|
+
if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
|
|
96
|
+
|
|
97
|
+
let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
|
|
98
|
+
while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
|
|
99
|
+
truncated = truncated.slice(0, -1);
|
|
100
|
+
}
|
|
101
|
+
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
105
|
+
items: TIn[],
|
|
106
|
+
concurrency: number,
|
|
107
|
+
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
108
|
+
): Promise<TOut[]> {
|
|
109
|
+
if (items.length === 0) return [];
|
|
110
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
111
|
+
const results: TOut[] = new Array(items.length);
|
|
112
|
+
let nextIndex = 0;
|
|
113
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
114
|
+
while (true) {
|
|
115
|
+
const current = nextIndex++;
|
|
116
|
+
if (current >= items.length) return;
|
|
117
|
+
results[current] = await fn(items[current], current);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
await Promise.all(workers);
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
125
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
|
|
126
|
+
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
127
|
+
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
128
|
+
await withFileMutationQueue(filePath, async () => {
|
|
129
|
+
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
130
|
+
});
|
|
131
|
+
return { dir: tmpDir, filePath };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
135
|
+
// In agent-controller, the extension always runs inside our runtime process
|
|
136
|
+
// (dist/index.js), not as standalone `pi`. The original logic would re-invoke
|
|
137
|
+
// dist/index.js (which expects a CompiledSpec on stdin) instead of vanilla pi.
|
|
138
|
+
// Use the pi CLI resolved by the adapter (AC_PI_BIN), falling back to `pi`
|
|
139
|
+
// on PATH. AC_PI_BIN is set to the absolute path of the pi CLI entry in
|
|
140
|
+
// runtime's node_modules so the child process works even when `pi` is not
|
|
141
|
+
// globally installed.
|
|
142
|
+
const piBin = process.env.AC_PI_BIN ?? "pi";
|
|
143
|
+
if (piBin.endsWith(".js")) {
|
|
144
|
+
// Invoke as: node <piBin> <args>
|
|
145
|
+
return { command: process.execPath, args: [piBin, ...args] };
|
|
146
|
+
}
|
|
147
|
+
return { command: piBin, args };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
|
151
|
+
|
|
152
|
+
async function runSingleAgent(
|
|
153
|
+
defaultCwd: string,
|
|
154
|
+
agents: AgentConfig[],
|
|
155
|
+
agentName: string,
|
|
156
|
+
task: string,
|
|
157
|
+
cwd: string | undefined,
|
|
158
|
+
step: number | undefined,
|
|
159
|
+
signal: AbortSignal | undefined,
|
|
160
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
161
|
+
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
162
|
+
): Promise<SingleResult> {
|
|
163
|
+
const agent = agents.find((a) => a.name === agentName);
|
|
164
|
+
|
|
165
|
+
if (!agent) {
|
|
166
|
+
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
|
|
167
|
+
return {
|
|
168
|
+
agent: agentName,
|
|
169
|
+
agentSource: "unknown",
|
|
170
|
+
task,
|
|
171
|
+
exitCode: 1,
|
|
172
|
+
messages: [],
|
|
173
|
+
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
|
|
174
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
175
|
+
step,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
180
|
+
if (agent.model) args.push("--model", agent.model);
|
|
181
|
+
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
182
|
+
|
|
183
|
+
let tmpPromptDir: string | null = null;
|
|
184
|
+
let tmpPromptPath: string | null = null;
|
|
185
|
+
|
|
186
|
+
const currentResult: SingleResult = {
|
|
187
|
+
agent: agentName,
|
|
188
|
+
agentSource: agent.source,
|
|
189
|
+
task,
|
|
190
|
+
exitCode: 0,
|
|
191
|
+
messages: [],
|
|
192
|
+
stderr: "",
|
|
193
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
194
|
+
model: agent.model,
|
|
195
|
+
step,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const emitUpdate = () => {
|
|
199
|
+
if (onUpdate) {
|
|
200
|
+
onUpdate({
|
|
201
|
+
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
|
|
202
|
+
details: makeDetails([currentResult]),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
if (agent.systemPrompt.trim()) {
|
|
209
|
+
const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
210
|
+
tmpPromptDir = tmp.dir;
|
|
211
|
+
tmpPromptPath = tmp.filePath;
|
|
212
|
+
args.push("--append-system-prompt", tmpPromptPath);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
args.push(`Task: ${task}`);
|
|
216
|
+
let wasAborted = false;
|
|
217
|
+
|
|
218
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
219
|
+
const invocation = getPiInvocation(args);
|
|
220
|
+
// If agent-controller wrote a local models.json to override the
|
|
221
|
+
// Anthropic gateway URL, inherit it by pointing PI_CODING_AGENT_DIR
|
|
222
|
+
// at that directory. Without this child pi processes hit the hardcoded
|
|
223
|
+
// api.anthropic.com instead of the configured gateway.
|
|
224
|
+
const childEnv: Record<string, string> = { ...process.env } as Record<string, string>;
|
|
225
|
+
const localAgentDir = process.env.AC_SUBAGENT_AGENT_DIR;
|
|
226
|
+
if (localAgentDir) {
|
|
227
|
+
childEnv["PI_CODING_AGENT_DIR"] = localAgentDir;
|
|
228
|
+
}
|
|
229
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
230
|
+
cwd: cwd ?? defaultCwd,
|
|
231
|
+
shell: false,
|
|
232
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
233
|
+
env: childEnv,
|
|
234
|
+
});
|
|
235
|
+
let buffer = "";
|
|
236
|
+
|
|
237
|
+
const processLine = (line: string) => {
|
|
238
|
+
if (!line.trim()) return;
|
|
239
|
+
let event: any;
|
|
240
|
+
try {
|
|
241
|
+
event = JSON.parse(line);
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (event.type === "message_end" && event.message) {
|
|
247
|
+
const msg = event.message as Message;
|
|
248
|
+
currentResult.messages.push(msg);
|
|
249
|
+
|
|
250
|
+
if (msg.role === "assistant") {
|
|
251
|
+
currentResult.usage.turns++;
|
|
252
|
+
const usage = msg.usage;
|
|
253
|
+
if (usage) {
|
|
254
|
+
currentResult.usage.input += usage.input || 0;
|
|
255
|
+
currentResult.usage.output += usage.output || 0;
|
|
256
|
+
currentResult.usage.cacheRead += usage.cacheRead || 0;
|
|
257
|
+
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
|
|
258
|
+
currentResult.usage.cost += usage.cost?.total || 0;
|
|
259
|
+
currentResult.usage.contextTokens = usage.totalTokens || 0;
|
|
260
|
+
}
|
|
261
|
+
if (!currentResult.model && msg.model) currentResult.model = msg.model;
|
|
262
|
+
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
|
|
263
|
+
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
|
|
264
|
+
}
|
|
265
|
+
emitUpdate();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (event.type === "tool_result_end" && event.message) {
|
|
269
|
+
currentResult.messages.push(event.message as Message);
|
|
270
|
+
emitUpdate();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
proc.stdout.on("data", (data) => {
|
|
275
|
+
buffer += data.toString();
|
|
276
|
+
const lines = buffer.split("\n");
|
|
277
|
+
buffer = lines.pop() || "";
|
|
278
|
+
for (const line of lines) processLine(line);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
proc.stderr.on("data", (data) => {
|
|
282
|
+
currentResult.stderr += data.toString();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
proc.on("close", (code) => {
|
|
286
|
+
if (buffer.trim()) processLine(buffer);
|
|
287
|
+
resolve(code ?? 0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
proc.on("error", () => {
|
|
291
|
+
resolve(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (signal) {
|
|
295
|
+
const killProc = () => {
|
|
296
|
+
wasAborted = true;
|
|
297
|
+
proc.kill("SIGTERM");
|
|
298
|
+
setTimeout(() => {
|
|
299
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
300
|
+
}, 5000);
|
|
301
|
+
};
|
|
302
|
+
if (signal.aborted) killProc();
|
|
303
|
+
else signal.addEventListener("abort", killProc, { once: true });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
currentResult.exitCode = exitCode;
|
|
308
|
+
if (wasAborted) throw new Error("Subagent was aborted");
|
|
309
|
+
return currentResult;
|
|
310
|
+
} finally {
|
|
311
|
+
if (tmpPromptPath)
|
|
312
|
+
try {
|
|
313
|
+
fs.unlinkSync(tmpPromptPath);
|
|
314
|
+
} catch {
|
|
315
|
+
/* ignore */
|
|
316
|
+
}
|
|
317
|
+
if (tmpPromptDir)
|
|
318
|
+
try {
|
|
319
|
+
fs.rmdirSync(tmpPromptDir);
|
|
320
|
+
} catch {
|
|
321
|
+
/* ignore */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const TaskItem = Type.Object({
|
|
327
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
328
|
+
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
329
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const ChainItem = Type.Object({
|
|
333
|
+
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
334
|
+
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
335
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
339
|
+
description: 'Which agent directories to use. Default: "project" (only ADL-declared agents in .pi/agents/).',
|
|
340
|
+
default: "project",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const SubagentParams = Type.Object({
|
|
344
|
+
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
|
|
345
|
+
task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
|
|
346
|
+
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
|
347
|
+
chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
|
|
348
|
+
agentScope: Type.Optional(AgentScopeSchema),
|
|
349
|
+
confirmProjectAgents: Type.Optional(
|
|
350
|
+
Type.Boolean({ description: "Prompt before running project-local agents. Default: false.", default: false }),
|
|
351
|
+
),
|
|
352
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
export default function (pi: ExtensionAPI) {
|
|
356
|
+
pi.registerTool({
|
|
357
|
+
name: "subagent",
|
|
358
|
+
label: "Subagent",
|
|
359
|
+
description: [
|
|
360
|
+
"Delegate tasks to specialized subagents with isolated context.",
|
|
361
|
+
"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
|
|
362
|
+
'Agent scope defaults to "project" — only ADL-declared agents materialized in .pi/agents/ are available.',
|
|
363
|
+
].join(" "),
|
|
364
|
+
parameters: SubagentParams,
|
|
365
|
+
|
|
366
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
367
|
+
// Force "project" scope so only agents explicitly materialized by
|
|
368
|
+
// agent-controller into <cwd>/.pi/agents/ are visible. This enforces
|
|
369
|
+
// the ADL allowlist: the parent can only call agents declared in
|
|
370
|
+
// spec.subagents[], not arbitrary user agents from ~/.pi/agent/agents/.
|
|
371
|
+
//
|
|
372
|
+
// We DELIBERATELY ignore params.agentScope here. Honoring it would
|
|
373
|
+
// let the parent LLM pass agentScope: "user" or "both" and reach
|
|
374
|
+
// ~/.pi/agent/agents/, defeating the allowlist. The schema still
|
|
375
|
+
// accepts the field (so prompts/extensions can target it) but the
|
|
376
|
+
// value is silently dropped at runtime.
|
|
377
|
+
const agentScope: AgentScope = "project";
|
|
378
|
+
const discovery = discoverAgents(ctx.cwd, agentScope);
|
|
379
|
+
const agents = discovery.agents;
|
|
380
|
+
// Never prompt for confirmation in non-interactive (JSON/headless) mode.
|
|
381
|
+
const confirmProjectAgents = (params.confirmProjectAgents ?? false) && Boolean(ctx.hasUI);
|
|
382
|
+
|
|
383
|
+
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
384
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
385
|
+
const hasSingle = Boolean(params.agent && params.task);
|
|
386
|
+
const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
|
|
387
|
+
|
|
388
|
+
const makeDetails =
|
|
389
|
+
(mode: "single" | "parallel" | "chain") =>
|
|
390
|
+
(results: SingleResult[]): SubagentDetails => ({
|
|
391
|
+
mode,
|
|
392
|
+
agentScope,
|
|
393
|
+
projectAgentsDir: discovery.projectAgentsDir,
|
|
394
|
+
results,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (modeCount !== 1) {
|
|
398
|
+
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
399
|
+
return {
|
|
400
|
+
content: [
|
|
401
|
+
{
|
|
402
|
+
type: "text",
|
|
403
|
+
text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`,
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
details: makeDetails("single")([]),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents) {
|
|
411
|
+
const requestedAgentNames = new Set<string>();
|
|
412
|
+
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
|
|
413
|
+
if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
|
|
414
|
+
if (params.agent) requestedAgentNames.add(params.agent);
|
|
415
|
+
|
|
416
|
+
const projectAgentsRequested = Array.from(requestedAgentNames)
|
|
417
|
+
.map((name) => agents.find((a) => a.name === name))
|
|
418
|
+
.filter((a): a is AgentConfig => a?.source === "project");
|
|
419
|
+
|
|
420
|
+
if (projectAgentsRequested.length > 0) {
|
|
421
|
+
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
|
422
|
+
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
|
423
|
+
const ok = await (ctx as any).ui.confirm(
|
|
424
|
+
"Run project-local agents?",
|
|
425
|
+
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
|
426
|
+
);
|
|
427
|
+
if (!ok)
|
|
428
|
+
return {
|
|
429
|
+
content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
|
|
430
|
+
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (params.chain && params.chain.length > 0) {
|
|
436
|
+
const results: SingleResult[] = [];
|
|
437
|
+
let previousOutput = "";
|
|
438
|
+
|
|
439
|
+
for (let i = 0; i < params.chain.length; i++) {
|
|
440
|
+
const step = params.chain[i];
|
|
441
|
+
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
|
442
|
+
|
|
443
|
+
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
|
444
|
+
? (partial) => {
|
|
445
|
+
const currentResult = partial.details?.results[0];
|
|
446
|
+
if (currentResult) {
|
|
447
|
+
const allResults = [...results, currentResult];
|
|
448
|
+
onUpdate({
|
|
449
|
+
content: partial.content,
|
|
450
|
+
details: makeDetails("chain")(allResults),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
: undefined;
|
|
455
|
+
|
|
456
|
+
const result = await runSingleAgent(
|
|
457
|
+
ctx.cwd,
|
|
458
|
+
agents,
|
|
459
|
+
step.agent,
|
|
460
|
+
taskWithContext,
|
|
461
|
+
step.cwd,
|
|
462
|
+
i + 1,
|
|
463
|
+
signal,
|
|
464
|
+
chainUpdate,
|
|
465
|
+
makeDetails("chain"),
|
|
466
|
+
);
|
|
467
|
+
results.push(result);
|
|
468
|
+
|
|
469
|
+
const isError = isFailedResult(result);
|
|
470
|
+
if (isError) {
|
|
471
|
+
const errorMsg = getResultOutput(result);
|
|
472
|
+
return {
|
|
473
|
+
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
|
|
474
|
+
details: makeDetails("chain")(results),
|
|
475
|
+
isError: true,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
previousOutput = getFinalOutput(result.messages);
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
|
|
482
|
+
details: makeDetails("chain")(results),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (params.tasks && params.tasks.length > 0) {
|
|
487
|
+
if (params.tasks.length > MAX_PARALLEL_TASKS)
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{
|
|
491
|
+
type: "text",
|
|
492
|
+
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
details: makeDetails("parallel")([]),
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const allResults: SingleResult[] = new Array(params.tasks.length);
|
|
499
|
+
|
|
500
|
+
for (let i = 0; i < params.tasks.length; i++) {
|
|
501
|
+
allResults[i] = {
|
|
502
|
+
agent: params.tasks[i].agent,
|
|
503
|
+
agentSource: "unknown",
|
|
504
|
+
task: params.tasks[i].task,
|
|
505
|
+
exitCode: -1,
|
|
506
|
+
messages: [],
|
|
507
|
+
stderr: "",
|
|
508
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const emitParallelUpdate = () => {
|
|
513
|
+
if (onUpdate) {
|
|
514
|
+
const running = allResults.filter((r) => r.exitCode === -1).length;
|
|
515
|
+
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
|
516
|
+
onUpdate({
|
|
517
|
+
content: [
|
|
518
|
+
{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` },
|
|
519
|
+
],
|
|
520
|
+
details: makeDetails("parallel")([...allResults]),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
526
|
+
const result = await runSingleAgent(
|
|
527
|
+
ctx.cwd,
|
|
528
|
+
agents,
|
|
529
|
+
t.agent,
|
|
530
|
+
t.task,
|
|
531
|
+
t.cwd,
|
|
532
|
+
undefined,
|
|
533
|
+
signal,
|
|
534
|
+
(partial) => {
|
|
535
|
+
if (partial.details?.results[0]) {
|
|
536
|
+
allResults[index] = partial.details.results[0];
|
|
537
|
+
emitParallelUpdate();
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
makeDetails("parallel"),
|
|
541
|
+
);
|
|
542
|
+
allResults[index] = result;
|
|
543
|
+
emitParallelUpdate();
|
|
544
|
+
return result;
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const successCount = results.filter((r) => !isFailedResult(r)).length;
|
|
548
|
+
const summaries = results.map((r) => {
|
|
549
|
+
const output = truncateParallelOutput(getResultOutput(r));
|
|
550
|
+
const status = isFailedResult(r)
|
|
551
|
+
? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}`
|
|
552
|
+
: "completed";
|
|
553
|
+
return `### [${r.agent}] ${status}\n\n${output}`;
|
|
554
|
+
});
|
|
555
|
+
return {
|
|
556
|
+
content: [
|
|
557
|
+
{
|
|
558
|
+
type: "text",
|
|
559
|
+
text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
|
|
560
|
+
},
|
|
561
|
+
],
|
|
562
|
+
details: makeDetails("parallel")(results),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (params.agent && params.task) {
|
|
567
|
+
const result = await runSingleAgent(
|
|
568
|
+
ctx.cwd,
|
|
569
|
+
agents,
|
|
570
|
+
params.agent,
|
|
571
|
+
params.task,
|
|
572
|
+
params.cwd,
|
|
573
|
+
undefined,
|
|
574
|
+
signal,
|
|
575
|
+
onUpdate,
|
|
576
|
+
makeDetails("single"),
|
|
577
|
+
);
|
|
578
|
+
const isError = isFailedResult(result);
|
|
579
|
+
if (isError) {
|
|
580
|
+
const errorMsg = getResultOutput(result);
|
|
581
|
+
return {
|
|
582
|
+
content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
|
|
583
|
+
details: makeDetails("single")([result]),
|
|
584
|
+
isError: true,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
|
589
|
+
details: makeDetails("single")([result]),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
594
|
+
return {
|
|
595
|
+
content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
|
|
596
|
+
details: makeDetails("single")([]),
|
|
597
|
+
};
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
renderCall(args, _theme, _context) {
|
|
601
|
+
const scope: AgentScope = args.agentScope ?? "both";
|
|
602
|
+
if (args.chain && args.chain.length > 0) {
|
|
603
|
+
return `subagent chain (${args.chain.length} steps) [${scope}]`;
|
|
604
|
+
}
|
|
605
|
+
if (args.tasks && args.tasks.length > 0) {
|
|
606
|
+
return `subagent parallel (${args.tasks.length} tasks) [${scope}]`;
|
|
607
|
+
}
|
|
608
|
+
const agentName = args.agent || "...";
|
|
609
|
+
const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
|
|
610
|
+
return `subagent ${agentName} [${scope}]\n ${preview}`;
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
renderResult(result, _opts, _theme, _context) {
|
|
614
|
+
const details = result.details as SubagentDetails | undefined;
|
|
615
|
+
if (!details || details.results.length === 0) {
|
|
616
|
+
const text = result.content[0];
|
|
617
|
+
return text?.type === "text" ? text.text : "(no output)";
|
|
618
|
+
}
|
|
619
|
+
if (details.mode === "single" && details.results.length === 1) {
|
|
620
|
+
const r = details.results[0];
|
|
621
|
+
const status = isFailedResult(r) ? "FAILED" : "OK";
|
|
622
|
+
const output = getFinalOutput(r.messages) || "(no output)";
|
|
623
|
+
return `[${r.agent}] ${status}\n${output}`;
|
|
624
|
+
}
|
|
625
|
+
if (details.mode === "chain") {
|
|
626
|
+
const steps = details.results.map((r, i) => {
|
|
627
|
+
const status = isFailedResult(r) ? "FAILED" : "OK";
|
|
628
|
+
return `Step ${i + 1} [${r.agent}] ${status}: ${getFinalOutput(r.messages) || "(no output)"}`;
|
|
629
|
+
});
|
|
630
|
+
return steps.join("\n");
|
|
631
|
+
}
|
|
632
|
+
if (details.mode === "parallel") {
|
|
633
|
+
const summaries = details.results.map((r) => {
|
|
634
|
+
const status = isFailedResult(r) ? "FAILED" : "OK";
|
|
635
|
+
return `[${r.agent}] ${status}: ${getFinalOutput(r.messages) || "(no output)"}`;
|
|
636
|
+
});
|
|
637
|
+
return summaries.join("\n");
|
|
638
|
+
}
|
|
639
|
+
const text = result.content[0];
|
|
640
|
+
return text?.type === "text" ? text.text : "(no output)";
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
apiVersion: agent-controller.dev/v1alpha1
|
|
2
|
+
kind: Extension
|
|
3
|
+
metadata:
|
|
4
|
+
name: subagent
|
|
5
|
+
version: 0.1.0
|
|
6
|
+
owner: agent-controller
|
|
7
|
+
description: Delegate tasks to specialized subagents with isolated context windows
|
|
8
|
+
spec:
|
|
9
|
+
entrypoint: ./entrypoint.ts
|
|
10
|
+
riskLevel: high
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-controller/runtime",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Pi runtime adapter for agent-controller — runs an ADL CompiledSpec against a Pi session and emits the NDJSON wire-event stream.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-controller",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"node": ">=22.19.0"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
|
-
"build": "tsc -p .",
|
|
39
|
+
"build": "tsc -p . && node ./scripts/copy-vendored-extensions.mjs",
|
|
40
40
|
"test": "vitest run",
|
|
41
41
|
"test:watch": "vitest"
|
|
42
42
|
},
|