Add BRAIN_DISABLED flag and fix Ollama tool call formatting
Features: - Add BRAIN_DISABLED feature flag to hide all Brain functionality - When enabled, hides Brain banner, status indicator, menu, and commands - Flag location: src/constants/brain.ts Fixes: - Fix Ollama 400 error by properly formatting tool_calls in messages - Update OllamaMessage type to include tool_calls field - Fix Brain menu keyboard not working (add missing modes to isMenuOpen) UI Changes: - Remove "^Tab toggle mode" hint from status bar - Remove "ctrl+t to hide todos" hint from status bar Files modified: - src/constants/brain.ts (add BRAIN_DISABLED flag) - src/types/ollama.ts (add tool_calls to OllamaMessage) - src/providers/ollama/chat.ts (format tool_calls in messages) - src/tui-solid/components/header.tsx (hide Brain UI when disabled) - src/tui-solid/components/status-bar.tsx (remove hints) - src/tui-solid/components/command-menu.tsx (filter brain command) - src/tui-solid/components/input-area.tsx (fix isMenuOpen modes) - src/tui-solid/routes/session.tsx (skip brain menu when disabled) - src/services/brain.ts (early return when disabled) - src/services/chat-tui/initialize.ts (skip brain init when disabled)
This commit is contained in:
289
src/services/agent-definition-loader.ts
Normal file
289
src/services/agent-definition-loader.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Agent definition loader service
|
||||
* Loads agent definitions from markdown files with YAML frontmatter
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join, basename, extname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
import type {
|
||||
AgentDefinition,
|
||||
AgentFrontmatter,
|
||||
AgentDefinitionFile,
|
||||
AgentRegistry,
|
||||
AgentLoadResult,
|
||||
AgentTier,
|
||||
AgentColor,
|
||||
} from "@src/types/agent-definition";
|
||||
import { DEFAULT_AGENT_DEFINITION, AGENT_DEFINITION_SCHEMA } from "@src/types/agent-definition";
|
||||
import { AGENT_DEFINITION, AGENT_DEFINITION_PATHS, AGENT_MESSAGES } from "@src/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) => {
|
||||
byTrigger.set(phrase.toLowerCase(), agent.name);
|
||||
});
|
||||
|
||||
// Index by capabilities
|
||||
agent.capabilities?.forEach((capability) => {
|
||||
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) => registry.agents.get(name))
|
||||
.filter((a): 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) => frontmatter.push(` - ${phrase}`));
|
||||
}
|
||||
|
||||
if (agent.capabilities && agent.capabilities.length > 0) {
|
||||
frontmatter.push("capabilities:");
|
||||
agent.capabilities.forEach((cap) => frontmatter.push(` - ${cap}`));
|
||||
}
|
||||
|
||||
frontmatter.push("---");
|
||||
|
||||
const content = agent.systemPrompt || `# ${agent.name}\n\n${agent.description}`;
|
||||
|
||||
return `${frontmatter.join("\n")}\n\n${content}`;
|
||||
};
|
||||
Reference in New Issue
Block a user