feat: add subagent preset discovery and resolveChildModel change
This commit is contained in:
@@ -7,16 +7,26 @@ import {
|
|||||||
resolveChildModel,
|
resolveChildModel,
|
||||||
} from "./models.ts";
|
} 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({
|
const selection = resolveChildModel({
|
||||||
taskModel: "openai/gpt-5",
|
callModel: "openai/gpt-5",
|
||||||
topLevelModel: "anthropic/claude-sonnet-4-5",
|
presetModel: "anthropic/claude-sonnet-4-5",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(selection.requestedModel, "openai/gpt-5");
|
assert.equal(selection.requestedModel, "openai/gpt-5");
|
||||||
assert.equal(selection.resolvedModel, "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", () => {
|
test("formatModelReference returns provider/id", () => {
|
||||||
const ref = formatModelReference({ provider: "anthropic", id: "claude-sonnet-4-5" });
|
const ref = formatModelReference({ provider: "anthropic", id: "claude-sonnet-4-5" });
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ export function normalizeAvailableModelReference(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveChildModel(input: {
|
export function resolveChildModel(input: {
|
||||||
taskModel?: string;
|
callModel?: string;
|
||||||
topLevelModel: string;
|
presetModel?: string;
|
||||||
}): ModelSelection {
|
}): ModelSelection {
|
||||||
const requestedModel = input.taskModel ?? input.topLevelModel;
|
const requestedModel = input.callModel ?? input.presetModel;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestedModel,
|
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