From bcf216518cee8f5fa4837dbf6da21e18bd71dd31 Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 12 Apr 2026 11:03:00 +0100 Subject: [PATCH] feat: add subagent preset discovery and resolveChildModel change --- src/models.test.ts | 16 ++++++-- src/models.ts | 6 +-- src/presets.test.ts | 53 +++++++++++++++++++++++++ src/presets.ts | 95 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 src/presets.test.ts create mode 100644 src/presets.ts diff --git a/src/models.test.ts b/src/models.test.ts index e19d2a8..8e90c6a 100644 --- a/src/models.test.ts +++ b/src/models.test.ts @@ -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" }); diff --git a/src/models.ts b/src/models.ts index df9be58..282dfc8 100644 --- a/src/models.ts +++ b/src/models.ts @@ -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, diff --git a/src/presets.test.ts b/src/presets.test.ts new file mode 100644 index 0000000..2018e9f --- /dev/null +++ b/src/presets.test.ts @@ -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, []); +}); diff --git a/src/presets.ts b/src/presets.ts new file mode 100644 index 0000000..fc0c955 --- /dev/null +++ b/src/presets.ts @@ -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>(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(); + + 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 }; +}