feat(pi): add subagent config and agents

This commit is contained in:
2026-08-01 14:27:36 -04:00
parent 289ea1344c
commit 3977ed6822
7 changed files with 472 additions and 8 deletions

View File

@@ -0,0 +1,141 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import type { ContextMode } from "./types.ts";
import type { Diagnostics } from "./config.ts";
export interface AgentDefinition {
name: string;
description: string;
body: string;
context?: ContextMode;
model?: string;
thinking?: string;
tools?: string;
allowedContexts?: ContextMode[];
hidden?: boolean;
source: string;
}
export function loadAgents(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): Map<string, AgentDefinition> {
const user = loadTier(join(agentDir, "agents"), "user", diagnostics);
const project = projectTrusted ? loadTier(join(cwd, ".pi", "agents"), "project", diagnostics) : new Map<string, AgentDefinition>();
return new Map([...user, ...project]);
}
function loadTier(dir: string, tier: string, diagnostics: Diagnostics): Map<string, AgentDefinition> {
const agents = new Map<string, AgentDefinition>();
if (!existsSync(dir)) return agents;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const path = join(dir, entry.name);
const parsed = parseAgent(path, diagnostics);
if (!parsed) continue;
if (agents.has(parsed.name)) {
diagnostics.warnings.push(`Duplicate ${tier} agent '${parsed.name}' ignored at ${path}`);
continue;
}
const stem = basename(entry.name, ".md");
if (stem !== parsed.name) diagnostics.warnings.push(`${tier} agent file '${entry.name}' name '${parsed.name}' does not match filename`);
agents.set(parsed.name, parsed);
}
return agents;
}
export function parseAgent(path: string, diagnostics: Diagnostics): AgentDefinition | undefined {
try {
const text = readFileSync(path, "utf8");
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/u.exec(text);
if (!match) {
diagnostics.warnings.push(`Agent ${path} missing YAML frontmatter`);
return undefined;
}
const frontmatter = parseFrontmatter(match[1]);
const name = stringField(frontmatter, "name");
const description = stringField(frontmatter, "description");
if (!name || !/^[a-z0-9-]+$/.test(name)) {
diagnostics.warnings.push(`Agent ${path} has invalid name`);
return undefined;
}
if (!description) {
diagnostics.warnings.push(`Agent ${path} has invalid description`);
return undefined;
}
const context = contextField(frontmatter.context);
const allowedContexts = contextsField(frontmatter.allowedContexts);
if (frontmatter.context !== undefined && !context) diagnostics.warnings.push(`Agent ${path} has invalid context`);
if (frontmatter.allowedContexts !== undefined && !allowedContexts) diagnostics.warnings.push(`Agent ${path} has invalid allowedContexts`);
if (context && allowedContexts && !allowedContexts.includes(context)) diagnostics.warnings.push(`Agent ${path} context is outside allowedContexts`);
return {
name,
description,
body: match[2].trim(),
context,
model: stringField(frontmatter, "model"),
thinking: stringField(frontmatter, "thinking"),
tools: stringField(frontmatter, "tools"),
allowedContexts,
hidden: booleanField(frontmatter, "hidden"),
source: path,
};
} catch (error) {
diagnostics.warnings.push(`Failed to load agent ${path}: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
function parseFrontmatter(text: string): Record<string, unknown> {
const result: Record<string, unknown> = {};
const lines = text.split(/\r?\n/u);
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (!line.trim() || line.trimStart().startsWith("#")) continue;
const scalar = /^(\w+):\s*(.*?)\s*$/u.exec(line);
if (!scalar) continue;
const [, key, raw] = scalar;
if (raw !== "") {
result[key] = parseScalar(raw);
continue;
}
const values: string[] = [];
while (i + 1 < lines.length) {
const item = /^\s+-\s*(.*?)\s*$/u.exec(lines[i + 1]);
if (!item) break;
values.push(String(parseScalar(item[1])));
i += 1;
}
result[key] = values;
}
return result;
}
function parseScalar(raw: string): string | boolean {
const unquoted = raw.replace(/^['"]|['"]$/gu, "");
if (unquoted === "true") return true;
if (unquoted === "false") return false;
return unquoted;
}
function stringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function booleanField(record: Record<string, unknown>, key: string): boolean | undefined {
const value = record[key];
return typeof value === "boolean" ? value : undefined;
}
function contextField(value: unknown): ContextMode | undefined {
return value === "independent" || value === "fork" ? value : undefined;
}
function contextsField(value: unknown): ContextMode[] | undefined {
if (!Array.isArray(value)) return undefined;
const contexts = value.map(contextField);
return contexts.every(Boolean) ? (contexts as ContextMode[]) : undefined;
}
function defaultAgentDir(): string {
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
}