import { promises as fs } from 'node:fs'; import { join } from 'node:path'; import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js'; // v1.8.1: global agents live at /data/AGENTS.md inside the container // (./data:/data:ro mount on the host). Per-project AGENTS.md at the project // root overrides global by name. In-code builtins are gone — the seed file is // the contents of the previous BUILTIN_AGENTS list, copied into /data/AGENTS.md // once on first deploy. const GLOBAL_AGENTS_PATH = '/data/AGENTS.md'; const CACHE_TTL_MS = 60_000; // Tools whitelist universe matches services/tools.ts ALL_TOOLS. Keep in sync. // Batch 9.6: skill_find / skill_use / skill_resource added. Agents without an // explicit `tools:` field inherit the full default set (which now includes // the skill tools); agents with an explicit `tools:` array must list any // skill tool they want to use — strict opt-in. // Batch 9.7: ask_user_input added — same opt-in semantics. Agents with an // explicit tools list that omits it cannot trigger the interactive picker. const ALL_TOOL_NAMES = [ 'view_file', 'list_dir', 'grep', 'find_files', 'git_status', 'skill_find', 'skill_use', 'skill_resource', 'ask_user_input', ] as const; const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES]; const DEFAULT_TEMPERATURE = 0.7; export function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } // ---- AGENTS.md parser ------------------------------------------------------ interface ParsedFrontmatter { temperature?: number; tools?: string[]; description?: string; model?: string; // v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves // from the agent's toolset at runtime. max_tool_calls?: number; } function stripQuotes(s: string): string { if ( s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1] ) { return s.slice(1, -1); } return s; } function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: string[] } { const data: ParsedFrontmatter = {}; const errors: string[] = []; const lines = yaml.split('\n'); let arrayKey: 'tools' | null = null; for (const rawLine of lines) { const line = rawLine.trim(); if (line.length === 0) continue; // Block-list continuation: "- value" under a key that was set to empty if (arrayKey && line.startsWith('- ')) { data[arrayKey]!.push(line.slice(2).trim()); continue; } arrayKey = null; const colonIdx = line.indexOf(':'); if (colonIdx < 0) continue; const key = line.slice(0, colonIdx).trim(); const valueRaw = line.slice(colonIdx + 1).trim(); if (key === 'temperature') { const n = Number(valueRaw); if (Number.isFinite(n)) data.temperature = n; else errors.push(`temperature must be a number (got "${valueRaw}")`); } else if (key === 'tools') { if (valueRaw === '') { data.tools = []; arrayKey = 'tools'; } else if (valueRaw.startsWith('[') && valueRaw.endsWith(']')) { const inner = valueRaw.slice(1, -1); data.tools = inner .split(',') .map((s) => stripQuotes(s.trim())) .filter((s) => s.length > 0); } else { // Loose form: "tools: a, b, c" data.tools = valueRaw .split(',') .map((s) => stripQuotes(s.trim())) .filter((s) => s.length > 0); } } else if (key === 'description') { data.description = stripQuotes(valueRaw); } else if (key === 'model') { data.model = stripQuotes(valueRaw); } else if (key === 'max_tool_calls') { // v1.8.2: 1..100 inclusive integer. Out-of-range values are skipped // with a warning rather than throwing — agents shouldn't be unusable // because of a typo on a defaulted field. Non-numeric or non-integer // still hard-fails the block, matching `temperature` behavior. const n = Number(valueRaw); if (Number.isInteger(n) && n >= 1 && n <= 100) { data.max_tool_calls = n; } else if (Number.isInteger(n)) { console.warn( `agents: max_tool_calls ${n} out of range 1-100, ignoring (falling back to default)`, ); } else { errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`); } } // Unknown keys silently ignored — forward-compat. } return { data, errors }; } interface RawSection { name: string; body: string; } function splitSections(content: string): RawSection[] { // Split by lines matching exactly "## ". Level-3+ headings are body content. const sections: RawSection[] = []; let currentName: string | null = null; let currentLines: string[] = []; for (const line of content.split('\n')) { const h2 = /^##\s+(.+?)\s*$/.exec(line); const h3 = line.startsWith('### '); if (h2 && !h3) { if (currentName !== null) { sections.push({ name: currentName, body: currentLines.join('\n') }); } currentName = h2[1]!.trim(); currentLines = []; continue; } if (currentName !== null) { currentLines.push(line); } } if (currentName !== null) { sections.push({ name: currentName, body: currentLines.join('\n') }); } return sections; } // Throws on malformed section — caller handles per-block error collection. function parseAgentSection(section: RawSection): Omit { const lines = section.body.split('\n'); // Opening "---" fence must be the first non-empty line. let openIdx = -1; for (let i = 0; i < lines.length; i++) { const t = lines[i]!.trim(); if (t === '') continue; if (t === '---') { openIdx = i; } break; } if (openIdx < 0) { throw new Error('missing opening --- fence after heading'); } let closeIdx = -1; for (let i = openIdx + 1; i < lines.length; i++) { if (lines[i]!.trim() === '---') { closeIdx = i; break; } } if (closeIdx < 0) { throw new Error('missing closing --- fence'); } const yamlText = lines.slice(openIdx + 1, closeIdx).join('\n'); const systemPrompt = lines.slice(closeIdx + 1).join('\n').trim(); const { data: fm, errors: fmErrors } = parseFrontmatter(yamlText); if (fmErrors.length > 0) { throw new Error(fmErrors.join('; ')); } const filteredTools = Array.isArray(fm.tools) ? fm.tools.filter((t): t is string => (ALL_TOOL_NAMES as readonly string[]).includes(t), ) : DEFAULT_TOOLS; return { id: slugify(section.name), name: section.name, description: fm.description ?? '', system_prompt: systemPrompt, temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE, tools: filteredTools, model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null, max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null, }; } interface ParseResult { agents: Omit[]; errors: AgentParseError[]; } // v1.8.1: parse each `## Name` block independently. A failure in one block // does not abort the rest of the file — we collect a per-agent error and // keep parsing. Server logs a console.warn for each skipped agent. export function parseAgentsMd(content: string): ParseResult { const sections = splitSections(content); const agents: Omit[] = []; const errors: AgentParseError[] = []; for (const section of sections) { try { agents.push(parseAgentSection(section)); } catch (err) { const reason = err instanceof Error ? err.message : String(err); console.warn(`agents: skipped "${section.name}" — ${reason}`); errors.push({ agent_name: section.name, reason }); } } return { agents, errors }; } // ---- mtime-keyed cache + public API ---------------------------------------- interface CacheEntry { globalMtime: number | null; projectMtime: number | null; cachedAt: number; result: AgentsResponse; } // Keyed by projectPath ('' is fine — no project case, e.g. tests). Two files // participate in the cache key (global + project); editing either bumps the // corresponding mtime so the next read sees a miss without a watcher. const cache = new Map(); export function invalidateAgentsCache(projectPath?: string): void { if (projectPath === undefined) { cache.clear(); } else { cache.delete(projectPath); } } async function safeStat(path: string): Promise { try { const s = await fs.stat(path); return s.mtimeMs; } catch { return null; } } async function safeRead(path: string): Promise { try { return await fs.readFile(path, 'utf8'); } catch { return null; } } export async function getAgentsForProject(projectPath: string): Promise { const projectAgentsPath = projectPath ? join(projectPath, 'AGENTS.md') : null; const [globalMtime, projectMtime] = await Promise.all([ safeStat(GLOBAL_AGENTS_PATH), projectAgentsPath ? safeStat(projectAgentsPath) : Promise.resolve(null), ]); const cacheKey = projectPath || '__none__'; const cached = cache.get(cacheKey); const now = Date.now(); if ( cached && cached.globalMtime === globalMtime && cached.projectMtime === projectMtime && now - cached.cachedAt < CACHE_TTL_MS ) { return cached.result; } const [globalContent, projectContent] = await Promise.all([ globalMtime !== null ? safeRead(GLOBAL_AGENTS_PATH) : Promise.resolve(null), projectAgentsPath && projectMtime !== null ? safeRead(projectAgentsPath) : Promise.resolve(null), ]); const errors: AgentParseError[] = []; const byName = new Map(); if (globalContent !== null) { const r = parseAgentsMd(globalContent); for (const a of r.agents) byName.set(a.name, { ...a, source: 'global' }); errors.push(...r.errors); } if (projectContent !== null) { const r = parseAgentsMd(projectContent); for (const a of r.agents) byName.set(a.name, { ...a, source: 'project' }); errors.push(...r.errors); } const result: AgentsResponse = { agents: Array.from(byName.values()), errors, }; cache.set(cacheKey, { globalMtime, projectMtime, cachedAt: now, result }); return result; } export async function getAgentById( projectPath: string, agentId: string, ): Promise { const { agents } = await getAgentsForProject(projectPath); return agents.find((a) => a.id === agentId) ?? null; }