- /data/skills mount (host: /opt/skills) - skill_find, skill_use, skill_resource added to default read-only tool set; opt-in for agents with explicit tools: whitelist - AGENTS.md builtin agents drop explicit tools: arrays to inherit the new default (now includes skill tools) - POST /api/chats/:id/skill_invoke for slash-command flow - 19 SKILL.md files seeded at /opt/skills/ across 6 source groups
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
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.
|
|
const ALL_TOOL_NAMES = [
|
|
'view_file', 'list_dir', 'grep', 'find_files', 'git_status',
|
|
'skill_find', 'skill_use', 'skill_resource',
|
|
] 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 "## <name>". 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<Agent, 'source'> {
|
|
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<Agent, 'source'>[];
|
|
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<Agent, 'source'>[] = [];
|
|
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<string, CacheEntry>();
|
|
|
|
export function invalidateAgentsCache(projectPath?: string): void {
|
|
if (projectPath === undefined) {
|
|
cache.clear();
|
|
} else {
|
|
cache.delete(projectPath);
|
|
}
|
|
}
|
|
|
|
async function safeStat(path: string): Promise<number | null> {
|
|
try {
|
|
const s = await fs.stat(path);
|
|
return s.mtimeMs;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function safeRead(path: string): Promise<string | null> {
|
|
try {
|
|
return await fs.readFile(path, 'utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getAgentsForProject(projectPath: string): Promise<AgentsResponse> {
|
|
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<string, Agent>();
|
|
|
|
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<Agent | null> {
|
|
const { agents } = await getAgentsForProject(projectPath);
|
|
return agents.find((a) => a.id === agentId) ?? null;
|
|
}
|