feat: add subagent preset discovery and resolveChildModel change
This commit is contained in:
@@ -7,16 +7,26 @@ import {
|
||||
resolveChildModel,
|
||||
} from "./models.ts";
|
||||
|
||||
test("resolveChildModel prefers the per-task override over the required top-level model", () => {
|
||||
test("resolveChildModel prefers explicit call model over preset default model", () => {
|
||||
const selection = resolveChildModel({
|
||||
taskModel: "openai/gpt-5",
|
||||
topLevelModel: "anthropic/claude-sonnet-4-5",
|
||||
callModel: "openai/gpt-5",
|
||||
presetModel: "anthropic/claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
assert.equal(selection.requestedModel, "openai/gpt-5");
|
||||
assert.equal(selection.resolvedModel, "openai/gpt-5");
|
||||
});
|
||||
|
||||
test("resolveChildModel falls back to preset default model", () => {
|
||||
const selection = resolveChildModel({
|
||||
callModel: undefined,
|
||||
presetModel: "anthropic/claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
assert.equal(selection.requestedModel, "anthropic/claude-sonnet-4-5");
|
||||
assert.equal(selection.resolvedModel, "anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
test("formatModelReference returns provider/id", () => {
|
||||
const ref = formatModelReference({ provider: "anthropic", id: "claude-sonnet-4-5" });
|
||||
|
||||
|
||||
@@ -46,10 +46,10 @@ export function normalizeAvailableModelReference(
|
||||
}
|
||||
|
||||
export function resolveChildModel(input: {
|
||||
taskModel?: string;
|
||||
topLevelModel: string;
|
||||
callModel?: string;
|
||||
presetModel?: string;
|
||||
}): ModelSelection {
|
||||
const requestedModel = input.taskModel ?? input.topLevelModel;
|
||||
const requestedModel = input.callModel ?? input.presetModel;
|
||||
|
||||
return {
|
||||
requestedModel,
|
||||
|
||||
53
src/presets.test.ts
Normal file
53
src/presets.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { discoverSubagentPresets } from "./presets.ts";
|
||||
|
||||
test("discoverSubagentPresets loads global presets and lets nearest project presets override by name", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-subagents-presets-"));
|
||||
const homeDir = join(root, "home");
|
||||
const repo = join(root, "repo", "apps", "web");
|
||||
const globalDir = join(homeDir, ".pi", "agent", "subagents");
|
||||
const projectDir = join(root, "repo", ".pi", "subagents");
|
||||
|
||||
await mkdir(globalDir, { recursive: true });
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
await mkdir(repo, { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
join(globalDir, "reviewer.md"),
|
||||
`---\nname: reviewer\ndescription: Global reviewer\nmodel: openai/gpt-5\ntools: read,grep\n---\nGlobal prompt`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(projectDir, "reviewer.md"),
|
||||
`---\nname: reviewer\ndescription: Project reviewer\nmodel: anthropic/claude-sonnet-4-5\n---\nProject prompt`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = discoverSubagentPresets(repo, { homeDir });
|
||||
const reviewer = result.presets.find((preset) => preset.name === "reviewer");
|
||||
|
||||
assert.equal(reviewer?.source, "project");
|
||||
assert.equal(reviewer?.description, "Project reviewer");
|
||||
assert.equal(reviewer?.model, "anthropic/claude-sonnet-4-5");
|
||||
assert.equal(reviewer?.tools, undefined);
|
||||
assert.equal(reviewer?.systemPrompt, "Project prompt");
|
||||
assert.match(result.projectPresetsDir ?? "", /\.pi\/subagents$/);
|
||||
});
|
||||
|
||||
test("discoverSubagentPresets ignores markdown files without required frontmatter", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-subagents-presets-"));
|
||||
const homeDir = join(root, "home");
|
||||
const repo = join(root, "repo");
|
||||
const globalDir = join(homeDir, ".pi", "agent", "subagents");
|
||||
|
||||
await mkdir(globalDir, { recursive: true });
|
||||
await mkdir(repo, { recursive: true });
|
||||
await writeFile(join(globalDir, "broken.md"), `---\nname: broken\n---\nMissing description`, "utf8");
|
||||
|
||||
const result = discoverSubagentPresets(repo, { homeDir });
|
||||
assert.deepEqual(result.presets, []);
|
||||
});
|
||||
95
src/presets.ts
Normal file
95
src/presets.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export interface SubagentPreset {
|
||||
name: string;
|
||||
description: string;
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
systemPrompt: string;
|
||||
source: "global" | "project";
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface SubagentPresetDiscoveryResult {
|
||||
presets: SubagentPreset[];
|
||||
projectPresetsDir: string | null;
|
||||
}
|
||||
|
||||
function findNearestProjectPresetsDir(cwd: string): string | null {
|
||||
let current = cwd;
|
||||
while (true) {
|
||||
const candidate = join(current, ".pi", "subagents");
|
||||
try {
|
||||
if (statSync(candidate).isDirectory()) return candidate;
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function loadPresetDir(dir: string, source: "global" | "project"): SubagentPreset[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
const presets: SubagentPreset[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.endsWith(".md")) continue;
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
||||
const filePath = join(dir, entry.name);
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(filePath, "utf8");
|
||||
} catch (_err) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseFrontmatter<Record<string, unknown>>(content);
|
||||
const frontmatter = parsed.frontmatter ?? {};
|
||||
const body = parsed.body ?? "";
|
||||
|
||||
if (typeof frontmatter.name !== "string") continue;
|
||||
if (typeof frontmatter.description !== "string") continue;
|
||||
|
||||
const tools = typeof frontmatter.tools === "string"
|
||||
? frontmatter.tools.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
presets.push({
|
||||
name: frontmatter.name,
|
||||
description: frontmatter.description,
|
||||
model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
|
||||
tools: tools && tools.length > 0 ? tools : undefined,
|
||||
systemPrompt: body.trim(),
|
||||
source,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
export function discoverSubagentPresets(cwd: string, options: { homeDir?: string } = {}): SubagentPresetDiscoveryResult {
|
||||
const globalAgentDir = options.homeDir ? join(options.homeDir, ".pi", "agent") : getAgentDir();
|
||||
const globalDir = join(globalAgentDir, "subagents");
|
||||
const projectPresetsDir = findNearestProjectPresetsDir(cwd);
|
||||
|
||||
const map = new Map<string, SubagentPreset>();
|
||||
|
||||
for (const preset of loadPresetDir(globalDir, "global")) {
|
||||
map.set(preset.name, preset);
|
||||
}
|
||||
|
||||
if (projectPresetsDir) {
|
||||
for (const preset of loadPresetDir(projectPresetsDir, "project")) {
|
||||
// project overrides global by name
|
||||
map.set(preset.name, preset);
|
||||
}
|
||||
}
|
||||
|
||||
return { presets: Array.from(map.values()), projectPresetsDir };
|
||||
}
|
||||
Reference in New Issue
Block a user