refactor: switch pi-subagents to generic subagents

This commit is contained in:
pi
2026-04-12 07:00:10 +01:00
parent c8859b626b
commit d80367037c
11 changed files with 26 additions and 204 deletions

View File

@@ -1,6 +1,6 @@
# pi-subagents # pi-subagents
`pi-subagents` is a Pi extension package that runs subagent tasks in separate child sessions and ships the prompts and wrapper code needed to execute those runs. `pi-subagents` is a Pi extension package that runs generic subagent tasks in separate child sessions and ships workflow prompt templates plus the wrapper code needed to execute those runs.
## Install ## Install
@@ -26,6 +26,14 @@ pi install https://gitea.rwiesner.com/pi/pi-subagents
- default: background child process runner - default: background child process runner
- optional tmux runner: set `{"runner":"tmux"}` in `.pi/subagents.json` or `~/.pi/agent/subagents.json` - optional tmux runner: set `{"runner":"tmux"}` in `.pi/subagents.json` or `~/.pi/agent/subagents.json`
## What a subagent run gets
- delegated task text
- selected model
- optional working directory
Child runs are normal Pi sessions. This package does not add built-in role behavior, markdown-discovered subagents, per-agent tool restrictions, or appended role prompts.
## Requirements ## Requirements
- default process runner: no tmux requirement - default process runner: no tmux requirement

View File

@@ -1,10 +1,10 @@
--- ---
description: Implement, review, then revise using subagents description: Implement, review, then revise using generic subagents
--- ---
Use the `subagent` tool in chain mode: Use the `subagent` tool in chain mode:
1. `worker` to implement: $@ 1. Run a generic subagent to implement: $@
2. `reviewer` to review `{previous}` and identify issues 2. Run a generic subagent to review `{previous}` and identify issues
3. `worker` to revise the implementation using `{previous}` 3. Run a generic subagent to revise the implementation using `{previous}`
User request: $@ User request: $@

View File

@@ -1,10 +1,10 @@
--- ---
description: Scout, plan, and implement using subagents description: Inspect, plan, and implement using generic subagents
--- ---
Use the `subagent` tool to handle this request in three stages: Use the `subagent` tool in chain mode:
1. Run `scout` to inspect the codebase for: $@ 1. Run a generic subagent to inspect the codebase for: $@
2. Run `planner` in chain mode, using `{previous}` from the scout output to produce a concrete implementation plan 2. Run a generic subagent to turn `{previous}` into a concrete implementation plan for: $@
3. Run `worker` in chain mode, using `{previous}` from the planner output to implement the approved plan 3. Run a generic subagent to implement the approved plan using `{previous}`
User request: $@ User request: $@

View File

@@ -1,9 +1,9 @@
--- ---
description: Scout the codebase, then produce a plan using subagents description: Inspect the codebase, then produce a plan using generic subagents
--- ---
Use the `subagent` tool in chain mode: Use the `subagent` tool in chain mode:
1. `scout` to inspect the codebase for: $@ 1. Run a generic subagent to inspect the codebase for: $@
2. `planner` to turn `{previous}` into an implementation plan 2. Run a generic subagent to turn `{previous}` into a concrete implementation plan for: $@
User request: $@ User request: $@

View File

@@ -1,54 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, writeFile, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { BUILTIN_AGENTS } from "./builtin-agents.ts";
import { discoverAgents } from "./agents.ts";
test("discoverAgents returns built-ins and lets user markdown override by name", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-subagents-agents-"));
const agentDir = join(root, "agent-home");
const userAgentsDir = join(agentDir, "agents");
await mkdir(userAgentsDir, { recursive: true });
await writeFile(
join(userAgentsDir, "scout.md"),
`---\nname: scout\ndescription: User scout\nmodel: openai/gpt-5\n---\nUser override prompt`,
"utf8",
);
const result = discoverAgents(join(root, "repo"), {
scope: "user",
agentDir,
builtins: BUILTIN_AGENTS,
});
const scout = result.agents.find((agent) => agent.name === "scout");
assert.equal(scout?.source, "user");
assert.equal(scout?.description, "User scout");
assert.equal(scout?.model, "openai/gpt-5");
});
test("discoverAgents lets project agents override user agents when scope is both", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-subagents-agents-"));
const repo = join(root, "repo");
const agentDir = join(root, "agent-home");
const userAgentsDir = join(agentDir, "agents");
const projectAgentsDir = join(repo, ".pi", "agents");
await mkdir(userAgentsDir, { recursive: true });
await mkdir(projectAgentsDir, { recursive: true });
await writeFile(join(userAgentsDir, "worker.md"), `---\nname: worker\ndescription: User worker\n---\nUser worker`, "utf8");
await writeFile(join(projectAgentsDir, "worker.md"), `---\nname: worker\ndescription: Project worker\n---\nProject worker`, "utf8");
const result = discoverAgents(repo, {
scope: "both",
agentDir,
builtins: BUILTIN_AGENTS,
});
const worker = result.agents.find((agent) => agent.name === "worker");
assert.equal(worker?.source, "project");
assert.equal(worker?.description, "Project worker");
assert.equal(result.projectAgentsDir, projectAgentsDir);
});

View File

@@ -1,91 +0,0 @@
import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
import { BUILTIN_AGENTS, type AgentDefinition } from "./builtin-agents.ts";
import type { AgentScope } from "./schema.ts";
export interface AgentDiscoveryOptions {
scope?: AgentScope;
agentDir?: string;
builtins?: AgentDefinition[];
}
export interface AgentDiscoveryResult {
agents: AgentDefinition[];
projectAgentsDir: string | null;
}
function loadMarkdownAgents(dir: string, source: "user" | "project"): AgentDefinition[] {
if (!existsSync(dir)) return [];
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
const agents: AgentDefinition[] = [];
for (const entry of entries) {
if (!entry.name.endsWith(".md")) continue;
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const filePath = join(dir, entry.name);
const content = readFileSync(filePath, "utf8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
if (!frontmatter.name || !frontmatter.description) continue;
agents.push({
name: frontmatter.name,
description: frontmatter.description,
tools: frontmatter.tools?.split(",").map((tool) => tool.trim()).filter(Boolean),
model: frontmatter.model,
systemPrompt: body.trim(),
source,
filePath,
});
}
return agents;
}
function findNearestProjectAgentsDir(cwd: string): string | null {
let current = cwd;
while (true) {
const candidate = join(current, ".pi", "agents");
try {
if (statSync(candidate).isDirectory()) return candidate;
} catch {}
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
}
export function discoverAgents(cwd: string, options: AgentDiscoveryOptions = {}): AgentDiscoveryResult {
const scope = options.scope ?? "user";
const builtins = options.builtins ?? BUILTIN_AGENTS;
const userAgentDir = join(options.agentDir ?? getAgentDir(), "agents");
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
const sources = new Map<string, AgentDefinition>();
for (const agent of builtins) sources.set(agent.name, agent);
if (scope !== "project") {
for (const agent of loadMarkdownAgents(userAgentDir, "user")) {
sources.set(agent.name, agent);
}
}
if (scope !== "user" && projectAgentsDir) {
for (const agent of loadMarkdownAgents(projectAgentsDir, "project")) {
sources.set(agent.name, agent);
}
}
return {
agents: [...sources.values()],
projectAgentsDir,
};
}

View File

@@ -1,43 +0,0 @@
export interface AgentDefinition {
name: string;
description: string;
tools?: string[];
model?: string;
systemPrompt: string;
source: "builtin" | "user" | "project";
filePath?: string;
}
export const BUILTIN_AGENTS: AgentDefinition[] = [
{
name: "scout",
description: "Fast codebase recon and compressed context gathering",
tools: ["read", "grep", "find", "ls", "bash"],
model: "claude-haiku-4-5",
systemPrompt: "You are a scout. Explore quickly, summarize clearly, and avoid implementation.",
source: "builtin",
},
{
name: "planner",
description: "Turns exploration into implementation plans",
tools: ["read", "grep", "find", "ls"],
model: "claude-sonnet-4-5",
systemPrompt: "You are a planner. Produce implementation plans, file lists, and risks.",
source: "builtin",
},
{
name: "reviewer",
description: "Reviews code and identifies correctness and quality issues",
tools: ["read", "grep", "find", "ls", "bash"],
model: "claude-sonnet-4-5",
systemPrompt: "You are a reviewer. Inspect code critically and report concrete issues.",
source: "builtin",
},
{
name: "worker",
description: "General-purpose implementation agent",
model: "claude-sonnet-4-5",
systemPrompt: "You are a worker. Execute the delegated task completely and report final results clearly.",
source: "builtin",
},
];

View File

@@ -23,7 +23,7 @@ test("monitorRun streams normalized events and resolves when result.json appears
await appendFile(eventsPath, `${JSON.stringify({ type: "tool_call", toolName: "read", args: { path: "a.ts" } })}\n`, "utf8"); await appendFile(eventsPath, `${JSON.stringify({ type: "tool_call", toolName: "read", args: { path: "a.ts" } })}\n`, "utf8");
await writeFile( await writeFile(
resultPath, resultPath,
JSON.stringify({ runId: "run-1", exitCode: 0, finalText: "done", agent: "scout", task: "inspect auth" }, null, 2), JSON.stringify({ runId: "run-1", exitCode: 0, finalText: "done", task: "inspect auth" }, null, 2),
"utf8", "utf8",
); );

View File

@@ -48,6 +48,9 @@ test("README documents local install, git install, and tmux PATH requirement", (
const readme = readFileSync(resolve(packageRoot, "README.md"), "utf8"); const readme = readFileSync(resolve(packageRoot, "README.md"), "utf8");
assert.match(readme, /pi install \/absolute\/path\/to\/subagents/); assert.match(readme, /pi install \/absolute\/path\/to\/subagents/);
assert.match(readme, /pi install https:\/\/gitea\.rwiesner\.com\/pi\/pi-subagents/); assert.match(readme, /pi install https:\/\/gitea\.rwiesner\.com\/pi\/pi-subagents/);
assert.match(readme, /generic subagent/i);
assert.doesNotMatch(readme, /specialized built-in roles/i);
assert.doesNotMatch(readme, /markdown agent discovery/i);
assert.match(readme, /tmux.*PATH/i); assert.match(readme, /tmux.*PATH/i);
}); });

View File

@@ -16,5 +16,6 @@ test("package.json exposes the extension and workflow prompt templates", () => {
const content = readFileSync(join(packageRoot, "prompts", name), "utf8"); const content = readFileSync(join(packageRoot, "prompts", name), "utf8");
assert.match(content, /^---\ndescription:/m); assert.match(content, /^---\ndescription:/m);
assert.match(content, /subagent/); assert.match(content, /subagent/);
assert.doesNotMatch(content, /\b(?:scout|planner|reviewer|worker)\b/);
} }
}); });

View File

@@ -5,8 +5,6 @@ import { createConfiguredRunSingleTask } from "./runner.ts";
function makeResult(finalText: string) { function makeResult(finalText: string) {
return { return {
runId: "run-1", runId: "run-1",
agent: "scout",
agentSource: "builtin" as const,
task: "inspect auth", task: "inspect auth",
exitCode: 0, exitCode: 0,
finalText, finalText,