Files
codetyper.cli/src/services/agent-definition-loader.ts
2026-02-04 21:32:30 -05:00

331 lines
8.8 KiB
TypeScript

/**
* Agent definition loader service
* Loads agent definitions from markdown files with YAML frontmatter
*/
import { readFile, readdir } from "node:fs/promises";
import { join, extname } from "node:path";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import type {
AgentDefinition,
AgentFrontmatter,
AgentRegistry,
AgentLoadResult,
AgentTier,
AgentColor,
} from "@/types/agent-definition";
import {
DEFAULT_AGENT_DEFINITION,
AGENT_DEFINITION_SCHEMA,
} from "@/types/agent-definition";
import {
AGENT_DEFINITION,
AGENT_DEFINITION_PATHS,
AGENT_MESSAGES,
} from "@constants/agent-definition";
const parseFrontmatter = (
content: string,
): { frontmatter: Record<string, unknown>; body: string } | null => {
const delimiter = AGENT_DEFINITION.FRONTMATTER_DELIMITER;
const lines = content.split("\n");
if (lines[0]?.trim() !== delimiter) {
return null;
}
const endIndex = lines.findIndex(
(line, index) => index > 0 && line.trim() === delimiter,
);
if (endIndex === -1) {
return null;
}
const frontmatterLines = lines.slice(1, endIndex);
const body = lines
.slice(endIndex + 1)
.join("\n")
.trim();
// Simple YAML parser for frontmatter
const frontmatter: Record<string, unknown> = {};
let currentKey = "";
let currentArray: string[] | null = null;
frontmatterLines.forEach((line) => {
const trimmed = line.trim();
if (trimmed.startsWith("- ") && currentArray !== null) {
currentArray.push(trimmed.slice(2));
return;
}
if (currentArray !== null) {
frontmatter[currentKey] = currentArray;
currentArray = null;
}
const colonIndex = trimmed.indexOf(":");
if (colonIndex === -1) return;
const key = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
if (value === "") {
currentKey = key;
currentArray = [];
} else if (value.startsWith("[") && value.endsWith("]")) {
frontmatter[key] = value
.slice(1, -1)
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""));
} else if (value === "true") {
frontmatter[key] = true;
} else if (value === "false") {
frontmatter[key] = false;
} else if (!isNaN(Number(value))) {
frontmatter[key] = Number(value);
} else {
frontmatter[key] = value.replace(/^["']|["']$/g, "");
}
});
if (currentArray !== null) {
frontmatter[currentKey] = currentArray;
}
return { frontmatter, body };
};
const validateFrontmatter = (
frontmatter: Record<string, unknown>,
): AgentFrontmatter | null => {
const { required } = AGENT_DEFINITION_SCHEMA;
for (const field of required) {
if (!(field in frontmatter)) {
return null;
}
}
const name = frontmatter.name;
const description = frontmatter.description;
const tools = frontmatter.tools;
if (
typeof name !== "string" ||
typeof description !== "string" ||
!Array.isArray(tools)
) {
return null;
}
return {
name,
description,
tools: tools as ReadonlyArray<string>,
tier: (frontmatter.tier as AgentTier) || DEFAULT_AGENT_DEFINITION.tier,
color: (frontmatter.color as AgentColor) || DEFAULT_AGENT_DEFINITION.color,
maxTurns:
(frontmatter.maxTurns as number) || DEFAULT_AGENT_DEFINITION.maxTurns,
triggerPhrases: (frontmatter.triggerPhrases as ReadonlyArray<string>) || [],
capabilities: (frontmatter.capabilities as ReadonlyArray<string>) || [],
allowedPaths: frontmatter.allowedPaths as ReadonlyArray<string> | undefined,
deniedPaths: frontmatter.deniedPaths as ReadonlyArray<string> | undefined,
};
};
const frontmatterToDefinition = (
frontmatter: AgentFrontmatter,
content: string,
): AgentDefinition => ({
name: frontmatter.name,
description: frontmatter.description,
tools: frontmatter.tools,
tier: frontmatter.tier || (DEFAULT_AGENT_DEFINITION.tier as AgentTier),
color: frontmatter.color || (DEFAULT_AGENT_DEFINITION.color as AgentColor),
maxTurns: frontmatter.maxTurns || DEFAULT_AGENT_DEFINITION.maxTurns,
systemPrompt: content || undefined,
triggerPhrases: frontmatter.triggerPhrases || [],
capabilities: frontmatter.capabilities || [],
permissions: {
allowedPaths: frontmatter.allowedPaths,
deniedPaths: frontmatter.deniedPaths,
},
});
export const loadAgentDefinitionFile = async (
filePath: string,
): Promise<AgentLoadResult> => {
try {
const content = await readFile(filePath, "utf-8");
const parsed = parseFrontmatter(content);
if (!parsed) {
return {
success: false,
error: AGENT_MESSAGES.INVALID_FRONTMATTER,
filePath,
};
}
const frontmatter = validateFrontmatter(parsed.frontmatter);
if (!frontmatter) {
return {
success: false,
error: AGENT_MESSAGES.MISSING_REQUIRED,
filePath,
};
}
const agent = frontmatterToDefinition(frontmatter, parsed.body);
return { success: true, agent, filePath };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: message, filePath };
}
};
export const loadAgentDefinitionsFromDirectory = async (
directoryPath: string,
): Promise<ReadonlyArray<AgentLoadResult>> => {
const resolvedPath = directoryPath.replace("~", homedir());
if (!existsSync(resolvedPath)) {
return [];
}
try {
const files = await readdir(resolvedPath);
const mdFiles = files.filter(
(file) => extname(file) === AGENT_DEFINITION.FILE_EXTENSION,
);
const results = await Promise.all(
mdFiles.map((file) => loadAgentDefinitionFile(join(resolvedPath, file))),
);
return results;
} catch {
return [];
}
};
export const loadAllAgentDefinitions = async (
projectPath: string,
): Promise<AgentRegistry> => {
const agents = new Map<string, AgentDefinition>();
const byTrigger = new Map<string, string>();
const byCapability = new Map<string, string[]>();
// Load from all paths in priority order (project > global > builtin)
const paths = [
join(projectPath, AGENT_DEFINITION_PATHS.PROJECT),
AGENT_DEFINITION_PATHS.GLOBAL,
];
for (const path of paths) {
const results = await loadAgentDefinitionsFromDirectory(path);
results.forEach((result) => {
if (result.success && result.agent) {
const { agent } = result;
// Don't override if already loaded (project takes precedence)
if (!agents.has(agent.name)) {
agents.set(agent.name, agent);
// Index by trigger phrases
agent.triggerPhrases?.forEach((phrase: string) => {
byTrigger.set(phrase.toLowerCase(), agent.name);
});
// Index by capabilities
agent.capabilities?.forEach((capability: string) => {
const existing = byCapability.get(capability) || [];
byCapability.set(capability, [...existing, agent.name]);
});
}
}
});
}
return { agents, byTrigger, byCapability };
};
export const findAgentByTrigger = (
registry: AgentRegistry,
text: string,
): AgentDefinition | undefined => {
const normalized = text.toLowerCase();
for (const [phrase, agentName] of registry.byTrigger) {
if (normalized.includes(phrase)) {
return registry.agents.get(agentName);
}
}
return undefined;
};
export const findAgentsByCapability = (
registry: AgentRegistry,
capability: string,
): ReadonlyArray<AgentDefinition> => {
const agentNames = registry.byCapability.get(capability) || [];
return agentNames
.map((name: string) => registry.agents.get(name))
.filter(
(a: AgentDefinition | undefined): a is AgentDefinition => a !== undefined,
);
};
export const getAgentByName = (
registry: AgentRegistry,
name: string,
): AgentDefinition | undefined => registry.agents.get(name);
export const listAllAgents = (
registry: AgentRegistry,
): ReadonlyArray<AgentDefinition> => Array.from(registry.agents.values());
export const createAgentDefinitionContent = (
agent: AgentDefinition,
): string => {
const frontmatter = [
"---",
`name: ${agent.name}`,
`description: ${agent.description}`,
`tools: [${agent.tools.join(", ")}]`,
`tier: ${agent.tier}`,
`color: ${agent.color}`,
];
if (agent.maxTurns) {
frontmatter.push(`maxTurns: ${agent.maxTurns}`);
}
if (agent.triggerPhrases && agent.triggerPhrases.length > 0) {
frontmatter.push("triggerPhrases:");
agent.triggerPhrases.forEach((phrase: string) =>
frontmatter.push(` - ${phrase}`),
);
}
if (agent.capabilities && agent.capabilities.length > 0) {
frontmatter.push("capabilities:");
agent.capabilities.forEach((cap: string) => frontmatter.push(` - ${cap}`));
}
frontmatter.push("---");
const content =
agent.systemPrompt || `# ${agent.name}\n\n${agent.description}`;
return `${frontmatter.join("\n")}\n\n${content}`;
};