171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
import { readFile, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { log } from "../log.js";
|
|
|
|
// ── Types ──
|
|
|
|
export type SubAgentModule = {
|
|
id: string;
|
|
name: string;
|
|
role: string;
|
|
description: string;
|
|
service?: "interview-service" | "roleplay-service" | "qscore-service" | "resume-service" | "matchmaking-service";
|
|
toolNames: string[];
|
|
};
|
|
|
|
type AgentFrontmatter = {
|
|
id?: string;
|
|
name?: string;
|
|
role?: string;
|
|
service?: string;
|
|
tools?: string[];
|
|
};
|
|
|
|
// ── Paths ──
|
|
|
|
const PROMPTS_DIR = path.resolve(process.cwd(), "prompts");
|
|
const AGENTS_DIR = path.resolve(process.cwd(), "agents");
|
|
const SYSTEM_PROMPT_FILE = path.join(PROMPTS_DIR, "system.txt");
|
|
|
|
// ── Frontmatter parser (no external dependencies) ──
|
|
|
|
function parseFrontmatter(raw: string): { data: AgentFrontmatter; body: string } {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed.startsWith("---")) return { data: {}, body: trimmed };
|
|
|
|
const secondDelim = trimmed.indexOf("---", 3);
|
|
if (secondDelim === -1) return { data: {}, body: trimmed };
|
|
|
|
const fmBlock = trimmed.slice(3, secondDelim).trim();
|
|
const body = trimmed.slice(secondDelim + 3).trim();
|
|
|
|
const data: AgentFrontmatter = {};
|
|
for (const line of fmBlock.split("\n")) {
|
|
const colonIdx = line.indexOf(":");
|
|
if (colonIdx === -1) continue;
|
|
const key = line.slice(0, colonIdx).trim();
|
|
let value: string | string[] = line.slice(colonIdx + 1).trim();
|
|
|
|
if (key === "tools" && value.startsWith("[") && value.endsWith("]")) {
|
|
// Parse inline array: ["tool1", "tool2"]
|
|
const inner = value.slice(1, -1);
|
|
value = inner
|
|
.split(",")
|
|
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
if (key === "id") data.id = value as string;
|
|
if (key === "name") data.name = value as string;
|
|
if (key === "role") data.role = value as string;
|
|
if (key === "service") data.service = value as string;
|
|
if (key === "tools") data.tools = value as string[];
|
|
}
|
|
|
|
return { data, body };
|
|
}
|
|
|
|
// ── Loader ──
|
|
|
|
let cachedModules: SubAgentModule[] | null = null;
|
|
let cachedSystemPrompt: string | null = null;
|
|
|
|
export function getSubAgentModules(): SubAgentModule[] {
|
|
if (!cachedModules) {
|
|
throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup");
|
|
}
|
|
return cachedModules;
|
|
}
|
|
|
|
export function getUnifiedSystemPrompt(): string {
|
|
if (!cachedSystemPrompt) {
|
|
throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup");
|
|
}
|
|
return cachedSystemPrompt;
|
|
}
|
|
|
|
export function getSubAgentModule(id: string): SubAgentModule | undefined {
|
|
return getSubAgentModules().find((m) => m.id === id);
|
|
}
|
|
|
|
export function jobApplicationModuleIds(): string[] {
|
|
return ["resume", "interview", "roleplay", "qscore"];
|
|
}
|
|
|
|
// Load all prompt and agent files from disk.
|
|
// Called once at startup. Rebuild the Docker image to pick up changes (§3).
|
|
export async function loadPromptsFromDisk(): Promise<void> {
|
|
// ── Load agent modules ──
|
|
let agentFiles: string[];
|
|
try {
|
|
agentFiles = (await readdir(AGENTS_DIR)).filter((f) => f.endsWith(".md"));
|
|
} catch (err) {
|
|
log.warn({ err, dir: AGENTS_DIR }, "agents directory not found — using empty catalog");
|
|
agentFiles = [];
|
|
}
|
|
|
|
const modules: SubAgentModule[] = [];
|
|
for (const filename of agentFiles) {
|
|
const filePath = path.join(AGENTS_DIR, filename);
|
|
try {
|
|
const raw = await readFile(filePath, "utf8");
|
|
const { data, body } = parseFrontmatter(raw);
|
|
|
|
if (!data.id || !data.name) {
|
|
log.warn({ file: filename }, "agent file missing required frontmatter fields (id, name)");
|
|
continue;
|
|
}
|
|
|
|
const service = data.service as SubAgentModule["service"] | undefined;
|
|
if (
|
|
service &&
|
|
service !== "interview-service" &&
|
|
service !== "roleplay-service" &&
|
|
service !== "qscore-service" &&
|
|
service !== "resume-service" &&
|
|
service !== "matchmaking-service"
|
|
) {
|
|
log.warn({ file: filename, service }, "unknown service value — treating as no service");
|
|
}
|
|
|
|
modules.push({
|
|
id: data.id,
|
|
name: data.name,
|
|
role: data.role ?? data.name,
|
|
description: body || `Agent module: ${data.name}`,
|
|
service: service &&
|
|
["interview-service", "roleplay-service", "qscore-service", "resume-service", "matchmaking-service"].includes(service)
|
|
? (service as SubAgentModule["service"])
|
|
: undefined,
|
|
toolNames: data.tools ?? [],
|
|
});
|
|
} catch (err) {
|
|
log.error({ err, file: filename }, "failed to load agent module");
|
|
}
|
|
}
|
|
|
|
cachedModules = modules;
|
|
log.info({ count: modules.length, dir: AGENTS_DIR }, "loaded sub-agent modules from disk");
|
|
|
|
// ── Load system prompt ──
|
|
try {
|
|
const template = await readFile(SYSTEM_PROMPT_FILE, "utf8");
|
|
const moduleDescriptions = modules
|
|
.map(
|
|
(m) =>
|
|
`- **${m.name}** (${m.id}): ${m.description} ${
|
|
m.service ? `[backed by ${m.service}]` : "[local workflow]"
|
|
}`,
|
|
)
|
|
.join("\n");
|
|
|
|
cachedSystemPrompt = template.replace("{{MODULE_DESCRIPTIONS}}", moduleDescriptions);
|
|
log.info({ path: SYSTEM_PROMPT_FILE }, "loaded system prompt from disk");
|
|
} catch (err) {
|
|
log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback");
|
|
// Fallback: assemble from modules without a template file.
|
|
const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
|
|
cachedSystemPrompt = fallback;
|
|
}
|
|
}
|