@narumitw/pi-subagents 0.13.1 → 0.14.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 +134 -10
- package/package.json +3 -3
- package/src/agents.ts +17 -0
- package/src/context.ts +129 -0
- package/src/execution.ts +69 -22
- package/src/in-process-transport.ts +598 -0
- package/src/limits.ts +62 -0
- package/src/orchestration.ts +164 -0
- package/src/persistence.ts +207 -0
- package/src/protocol.ts +76 -0
- package/src/registry.ts +759 -0
- package/src/runner.ts +202 -74
- package/src/settings.ts +55 -9
- package/src/stateful-prompt.ts +43 -0
- package/src/stateful.ts +838 -0
- package/src/subagents.ts +12 -2
- package/src/subprocess-transport.ts +50 -0
- package/src/transport.ts +30 -0
- package/src/workspace.ts +116 -0
package/src/subagents.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { SubagentParams } from "./params.js";
|
|
|
18
18
|
import { executeSubagent } from "./execution.js";
|
|
19
19
|
import { renderSubagentCall, renderSubagentResult } from "./render.js";
|
|
20
20
|
import type { SubagentDetails } from "./runner.js";
|
|
21
|
+
import { registerStatefulSubagents } from "./stateful.js";
|
|
21
22
|
|
|
22
23
|
export default function (pi: ExtensionAPI) {
|
|
23
24
|
pi.registerTool<typeof SubagentParams, SubagentDetails>({
|
|
@@ -34,8 +35,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
34
35
|
"Decide whether to spawn 0, 1, or multiple subagents for independent research, review, verification, or multi-step work in isolated Pi processes.",
|
|
35
36
|
promptGuidelines: [
|
|
36
37
|
"Use subagent only when delegation fits; the main agent should decide how many subagents to spawn from task shape instead of waiting for the user to specify a count.",
|
|
37
|
-
"Use no subagent for simple answers, quick targeted edits, latency-sensitive one-step work,
|
|
38
|
-
"
|
|
38
|
+
"Use no subagent for simple answers, quick targeted edits, latency-sensitive one-step work, tasks requiring frequent user back-and-forth, or critical-path work needed for the main agent's next action.",
|
|
39
|
+
"A single blocking subagent call should be used only when independent context, high-volume output isolation, or an external review is worth waiting for; otherwise do the task in the main agent.",
|
|
40
|
+
"For one-shot parallel work, use a single subagent call with tasks instead of repeated subagent_spawn calls, even when the user explicitly requests multiple agents.",
|
|
41
|
+
"When subagent_spawn is available, use it only when detached delegation has a concrete parallel, isolation, or specialization benefit; otherwise keep the work in the main agent.",
|
|
42
|
+
"After detached spawn, continue useful non-overlapping work when available, or call subagent_wait when coordination is the only useful next action; do not yield permanently while delegated work remains unresolved.",
|
|
43
|
+
"Consume detached completion messages and synthesize their results before finishing; interrupt or close agents that are no longer needed.",
|
|
39
44
|
"Use subagent parallel mode with 2-4 parallel read-only subagents when work has broad independent branches; prefer scout or reviewer for fan-out and add an aggregator when synthesis helps.",
|
|
40
45
|
"Use more than 4 subagent tasks only when clearly justified by distinct independent branches, and stay within the existing hard max 8 parallel tasks.",
|
|
41
46
|
"Do not use subagent parallel mode for write-heavy implementation touching the same files or shared state; serialize those changes in the main agent or one worker.",
|
|
@@ -57,8 +62,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
57
62
|
},
|
|
58
63
|
});
|
|
59
64
|
|
|
65
|
+
pi.on("tool_result", (event) => {
|
|
66
|
+
if (event.toolName !== "subagent") return;
|
|
67
|
+
if ((event.details as (SubagentDetails & { isError?: boolean }) | undefined)?.isError) return { isError: true };
|
|
68
|
+
});
|
|
60
69
|
|
|
61
70
|
registerSubagentConfigCommand(pi);
|
|
71
|
+
registerStatefulSubagents(pi);
|
|
62
72
|
}
|
|
63
73
|
export { formatTokens, formatUsageStats } from "./render.js";
|
|
64
74
|
export { buildPiArgs } from "./runner.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { discoverAgents } from "./agents.js";
|
|
3
|
+
import type { ManagedAgent, TurnOutcome } from "./registry.js";
|
|
4
|
+
import { getResultFinalOutput, runSingleAgent, type SubagentDetails } from "./runner.js";
|
|
5
|
+
import { readSubagentSettings, resolveSubagentThinkingLevel } from "./settings.js";
|
|
6
|
+
import {
|
|
7
|
+
buildStatefulTurnPrompt,
|
|
8
|
+
resolveStatefulTurnTimeout,
|
|
9
|
+
} from "./stateful-prompt.js";
|
|
10
|
+
import type { SubagentTransport } from "./transport.js";
|
|
11
|
+
|
|
12
|
+
export class SubprocessTransport implements SubagentTransport {
|
|
13
|
+
readonly kind = "subprocess" as const;
|
|
14
|
+
|
|
15
|
+
constructor(private readonly ctx: ExtensionContext) {}
|
|
16
|
+
|
|
17
|
+
async runTurn(record: ManagedAgent, task: string, signal: AbortSignal): Promise<TurnOutcome> {
|
|
18
|
+
const settings = readSubagentSettings();
|
|
19
|
+
const discovery = discoverAgents(record.cwd, record.agentScope ?? "user", settings);
|
|
20
|
+
const agent = discovery.agents.find((candidate) => candidate.name === record.agent);
|
|
21
|
+
const boundedTask = buildStatefulTurnPrompt(record, task);
|
|
22
|
+
const makeDetails = (results: SubagentDetails["results"]): SubagentDetails => ({
|
|
23
|
+
mode: "single",
|
|
24
|
+
agentScope: record.agentScope ?? "user",
|
|
25
|
+
projectAgentsDir: discovery.projectAgentsDir,
|
|
26
|
+
results,
|
|
27
|
+
});
|
|
28
|
+
const single = await runSingleAgent(
|
|
29
|
+
record.cwd,
|
|
30
|
+
discovery.agents,
|
|
31
|
+
record.agent,
|
|
32
|
+
boundedTask.text,
|
|
33
|
+
undefined,
|
|
34
|
+
undefined,
|
|
35
|
+
signal,
|
|
36
|
+
resolveSubagentThinkingLevel(discovery.agents, record.agent),
|
|
37
|
+
resolveStatefulTurnTimeout(agent),
|
|
38
|
+
undefined,
|
|
39
|
+
makeDetails,
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
output: getResultFinalOutput(single),
|
|
43
|
+
exitCode: single.exitCode,
|
|
44
|
+
aborted: single.aborted,
|
|
45
|
+
truncated: single.truncated || boundedTask.truncated,
|
|
46
|
+
error: single.errorMessage || single.stderr || undefined,
|
|
47
|
+
policy: single.policy,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ManagedAgent, TurnOutcome } from "./registry.js";
|
|
2
|
+
|
|
3
|
+
export interface SubagentTransport {
|
|
4
|
+
readonly kind: "subprocess" | "in-process" | "fake";
|
|
5
|
+
runTurn(agent: ManagedAgent, task: string, signal: AbortSignal): Promise<TurnOutcome>;
|
|
6
|
+
release?(agent: ManagedAgent): Promise<void>;
|
|
7
|
+
shutdown?(): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type AgentTurnRunner = (
|
|
11
|
+
agent: ManagedAgent,
|
|
12
|
+
task: string,
|
|
13
|
+
signal: AbortSignal,
|
|
14
|
+
) => Promise<TurnOutcome>;
|
|
15
|
+
|
|
16
|
+
export class FunctionTransport implements SubagentTransport {
|
|
17
|
+
readonly kind = "fake" as const;
|
|
18
|
+
|
|
19
|
+
constructor(private readonly runner: AgentTurnRunner) {}
|
|
20
|
+
|
|
21
|
+
runTurn(agent: ManagedAgent, task: string, signal: AbortSignal): Promise<TurnOutcome> {
|
|
22
|
+
return this.runner(agent, task, signal);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeTransport(
|
|
27
|
+
transport: SubagentTransport | AgentTurnRunner,
|
|
28
|
+
): SubagentTransport {
|
|
29
|
+
return typeof transport === "function" ? new FunctionTransport(transport) : transport;
|
|
30
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const WORKTREE_PREFIX = "pi-subagent-worktree-";
|
|
9
|
+
|
|
10
|
+
export interface IsolatedWorkspace {
|
|
11
|
+
mode: "worktree";
|
|
12
|
+
path: string;
|
|
13
|
+
rootPath: string;
|
|
14
|
+
repositoryRoot: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class WorkspaceManager {
|
|
18
|
+
private readonly owned = new Map<string, IsolatedWorkspace>();
|
|
19
|
+
|
|
20
|
+
async create(ownerId: string, cwd: string): Promise<IsolatedWorkspace> {
|
|
21
|
+
if (this.owned.has(ownerId)) throw new Error(`Workspace owner already exists: ${ownerId}`);
|
|
22
|
+
const resolvedCwd = path.resolve(cwd);
|
|
23
|
+
const repositoryRoot = (
|
|
24
|
+
await execFileAsync("git", ["-C", resolvedCwd, "rev-parse", "--show-toplevel"])
|
|
25
|
+
).stdout.trim();
|
|
26
|
+
const relativeCwd = path.relative(repositoryRoot, resolvedCwd);
|
|
27
|
+
if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) {
|
|
28
|
+
throw new Error("Subagent cwd is outside the Git repository");
|
|
29
|
+
}
|
|
30
|
+
const status = (
|
|
31
|
+
await execFileAsync("git", ["-C", repositoryRoot, "status", "--porcelain"])
|
|
32
|
+
).stdout;
|
|
33
|
+
if (status.trim()) throw new Error("Isolated subagent workspace requires a clean Git repository");
|
|
34
|
+
const rootPath = await fs.promises.mkdtemp(path.join(os.tmpdir(), WORKTREE_PREFIX));
|
|
35
|
+
let registered = false;
|
|
36
|
+
try {
|
|
37
|
+
await execFileAsync("git", [
|
|
38
|
+
"-C",
|
|
39
|
+
repositoryRoot,
|
|
40
|
+
"worktree",
|
|
41
|
+
"add",
|
|
42
|
+
"--detach",
|
|
43
|
+
rootPath,
|
|
44
|
+
"HEAD",
|
|
45
|
+
]);
|
|
46
|
+
registered = true;
|
|
47
|
+
await fs.promises.writeFile(`${rootPath}.owner`, ownerId, { mode: 0o600 });
|
|
48
|
+
const workspace = {
|
|
49
|
+
mode: "worktree" as const,
|
|
50
|
+
path: path.join(rootPath, relativeCwd),
|
|
51
|
+
rootPath,
|
|
52
|
+
repositoryRoot,
|
|
53
|
+
};
|
|
54
|
+
this.owned.set(ownerId, workspace);
|
|
55
|
+
return workspace;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (registered) {
|
|
58
|
+
await execFileAsync("git", [
|
|
59
|
+
"-C",
|
|
60
|
+
repositoryRoot,
|
|
61
|
+
"worktree",
|
|
62
|
+
"remove",
|
|
63
|
+
"--force",
|
|
64
|
+
rootPath,
|
|
65
|
+
]).catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
await fs.promises.rm(rootPath, { recursive: true, force: true });
|
|
68
|
+
await fs.promises.rm(`${rootPath}.owner`, { force: true });
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async cleanup(ownerId: string): Promise<void> {
|
|
74
|
+
const workspace = this.owned.get(ownerId);
|
|
75
|
+
if (!workspace) return;
|
|
76
|
+
if (!(await this.isOwned(workspace.rootPath, ownerId))) {
|
|
77
|
+
throw new Error(`Refusing to clean unowned subagent workspace: ${workspace.rootPath}`);
|
|
78
|
+
}
|
|
79
|
+
this.owned.delete(ownerId);
|
|
80
|
+
await execFileAsync("git", [
|
|
81
|
+
"-C",
|
|
82
|
+
workspace.repositoryRoot,
|
|
83
|
+
"worktree",
|
|
84
|
+
"remove",
|
|
85
|
+
"--force",
|
|
86
|
+
workspace.rootPath,
|
|
87
|
+
]).catch(async () => {
|
|
88
|
+
await fs.promises.rm(workspace.rootPath, { recursive: true, force: true });
|
|
89
|
+
await execFileAsync("git", ["-C", workspace.repositoryRoot, "worktree", "prune"])
|
|
90
|
+
.catch(() => undefined);
|
|
91
|
+
});
|
|
92
|
+
await fs.promises.rm(`${workspace.rootPath}.owner`, { force: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async cleanupAll(): Promise<void> {
|
|
96
|
+
const results = await Promise.allSettled(
|
|
97
|
+
[...this.owned.keys()].map((ownerId) => this.cleanup(ownerId)),
|
|
98
|
+
);
|
|
99
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
100
|
+
if (failures.length > 0) {
|
|
101
|
+
throw new AggregateError(
|
|
102
|
+
failures.map((failure) => (failure as PromiseRejectedResult).reason),
|
|
103
|
+
`Failed to clean ${failures.length} subagent workspace(s)`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async isOwned(rootPath: string, ownerId: string): Promise<boolean> {
|
|
109
|
+
if (!path.basename(rootPath).startsWith(WORKTREE_PREFIX)) return false;
|
|
110
|
+
try {
|
|
111
|
+
return (await fs.promises.readFile(`${rootPath}.owner`, "utf8")) === ownerId;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|