mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-08-20 06:03:50 +00:00
`parseFrontmatter` runs a real YAML parser, so `tools: [read, bash]`
arrives as an array while `tools: read, bash` arrives as a string. The
loader only handled the string form and called `.split(",")`, which
throws on the array form.
The throw escapes `loadAgentsFromDir` -- only `readdirSync` and
`readFileSync` are guarded -- so one agent file using the array form
broke discovery for every agent in that scope, not just itself.
Normalize both spellings in one helper, and type the frontmatter fields
as `unknown` so the string assumptions are explicit. The previous
`parseFrontmatter<Record<string, string>>` annotation asserted a shape
the YAML parser never guarantees, which is what hid the mismatch from
tsc while `AgentConfig.tools` was already typed `string[]`.
157 lines
4.5 KiB
TypeScript
157 lines
4.5 KiB
TypeScript
/**
|
|
* Agent discovery and configuration
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
|
|
export type AgentScope = "user" | "project" | "both";
|
|
|
|
export interface AgentConfig {
|
|
name: string;
|
|
description: string;
|
|
tools?: string[];
|
|
model?: string;
|
|
systemPrompt: string;
|
|
source: "user" | "project";
|
|
filePath: string;
|
|
}
|
|
|
|
export interface AgentDiscoveryResult {
|
|
agents: AgentConfig[];
|
|
projectAgentsDir: string | null;
|
|
}
|
|
|
|
/**
|
|
* Raw agent frontmatter. Values are `unknown` because `parseFrontmatter` runs a
|
|
* real YAML parser, so any scalar or collection can appear here.
|
|
*
|
|
* A type alias rather than an interface: `parseFrontmatter` constrains its
|
|
* parameter to `Record<string, unknown>`, and only an alias picks up the
|
|
* implicit index signature that satisfies it.
|
|
*/
|
|
type AgentFrontmatter = {
|
|
name?: unknown;
|
|
description?: unknown;
|
|
tools?: unknown;
|
|
model?: unknown;
|
|
};
|
|
|
|
/**
|
|
* Normalize a frontmatter `tools` value to a list of tool names.
|
|
*
|
|
* Both spellings are valid YAML and both are in use:
|
|
*
|
|
* tools: read, bash # string
|
|
* tools: [read, bash] # array
|
|
*
|
|
* so accept either. Anything else (a number, a map, a nested list) yields no
|
|
* tools rather than throwing: this runs inside agent discovery, where a single
|
|
* bad file must not take down every other agent in the same directory.
|
|
*/
|
|
function parseToolList(value: unknown): string[] | undefined {
|
|
const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
const tools = raw
|
|
.filter((t): t is string => typeof t === "string")
|
|
.map((t) => t.trim())
|
|
.filter(Boolean);
|
|
return tools.length > 0 ? tools : undefined;
|
|
}
|
|
|
|
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
|
const agents: AgentConfig[] = [];
|
|
|
|
if (!fs.existsSync(dir)) {
|
|
return agents;
|
|
}
|
|
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return agents;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.name.endsWith(".md")) continue;
|
|
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
|
|
const filePath = path.join(dir, entry.name);
|
|
let content: string;
|
|
try {
|
|
content = fs.readFileSync(filePath, "utf-8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const { frontmatter, body } = parseFrontmatter<AgentFrontmatter>(content);
|
|
|
|
if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") {
|
|
continue;
|
|
}
|
|
|
|
agents.push({
|
|
name: frontmatter.name,
|
|
description: frontmatter.description,
|
|
tools: parseToolList(frontmatter.tools),
|
|
model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
|
|
systemPrompt: body,
|
|
source,
|
|
filePath,
|
|
});
|
|
}
|
|
|
|
return agents;
|
|
}
|
|
|
|
function isDirectory(p: string): boolean {
|
|
try {
|
|
return fs.statSync(p).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
let currentDir = cwd;
|
|
while (true) {
|
|
const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
|
|
if (isDirectory(candidate)) return candidate;
|
|
|
|
const parentDir = path.dirname(currentDir);
|
|
if (parentDir === currentDir) return null;
|
|
currentDir = parentDir;
|
|
}
|
|
}
|
|
|
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
const userDir = path.join(getAgentDir(), "agents");
|
|
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
|
|
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
|
|
|
const agentMap = new Map<string, AgentConfig>();
|
|
|
|
if (scope === "both") {
|
|
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
} else if (scope === "user") {
|
|
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
} else {
|
|
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
}
|
|
|
|
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
|
}
|
|
|
|
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
|
if (agents.length === 0) return { text: "none", remaining: 0 };
|
|
const listed = agents.slice(0, maxItems);
|
|
const remaining = agents.length - listed.length;
|
|
return {
|
|
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
|
|
remaining,
|
|
};
|
|
}
|