import { feature } from 'bun:bundle' import memoize from 'lodash-es/memoize.js' import { basename } from 'path' import type { SettingSource } from 'src/utils/settings/constants.js' import { z } from 'zod/v4' import { isAutoMemoryEnabled } from '../../memdir/paths.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../../services/analytics/index.js' import { type McpServerConfig, McpServerConfigSchema, } from '../../services/mcp/types.js' import type { ToolUseContext } from '../../Tool.js' import { logForDebugging } from '../../utils/debug.js' import { EFFORT_LEVELS, type EffortValue, parseEffortValue, } from '../../utils/effort.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { parsePositiveIntFromFrontmatter } from '../../utils/frontmatterParser.js' import { lazySchema } from '../../utils/lazySchema.js' import { logError } from '../../utils/log.js' import { loadMarkdownFilesForSubdir, parseAgentToolsFromFrontmatter, parseSlashCommandToolsFromFrontmatter, } from '../../utils/markdownConfigLoader.js' import { PERMISSION_MODES, type PermissionMode, } from '../../utils/permissions/PermissionMode.js' import { clearPluginAgentCache, loadPluginAgents, } from '../../utils/plugins/loadPluginAgents.js' import { HooksSchema, type HooksSettings } from '../../utils/settings/types.js' import { jsonStringify } from '../../utils/slowOperations.js' import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js' import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js' import { AGENT_COLORS, type AgentColorName, setAgentColor, } from './agentColorManager.js' import { type AgentMemoryScope, loadAgentMemoryPrompt } from './agentMemory.js' import { getBuiltInAgents } from './builtInAgents.js' // Type for MCP server specification in agent definitions // Can be either a reference to an existing server by name, or an inline definition as { [name]: config } export type AgentMcpServerSpec = | string // Reference to existing server by name (e.g., "slack") | { [name: string]: McpServerConfig } // Inline definition as { name: config } // Zod schema for agent MCP server specs const AgentMcpServerSpecSchema = lazySchema(() => z.union([ z.string(), // Reference by name z.record(z.string(), McpServerConfigSchema()), // Inline as { name: config } ]), ) // Zod schemas for JSON agent validation // Note: HooksSchema is lazy so the circular chain AppState -> loadAgentsDir -> settings/types // is broken at module load time const AgentJsonSchema = lazySchema(() => z.object({ description: z.string().min(1, 'Description cannot be empty'), tools: z.array(z.string()).optional(), disallowedTools: z.array(z.string()).optional(), prompt: z.string().min(1, 'Prompt cannot be empty'), model: z .string() .trim() .min(1, 'Model cannot be empty') .transform(m => (m.toLowerCase() === 'inherit' ? 'inherit' : m)) .optional(), effort: z.union([z.enum(EFFORT_LEVELS), z.number().int()]).optional(), permissionMode: z.enum(PERMISSION_MODES).optional(), mcpServers: z.array(AgentMcpServerSpecSchema()).optional(), hooks: HooksSchema().optional(), maxTurns: z.number().int().positive().optional(), skills: z.array(z.string()).optional(), initialPrompt: z.string().optional(), memory: z.enum(['user', 'project', 'local']).optional(), background: z.boolean().optional(), isolation: (process.env.USER_TYPE === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree']) ).optional(), }), ) const AgentsJsonSchema = lazySchema(() => z.record(z.string(), AgentJsonSchema()), ) // Base type with common fields for all agents export type BaseAgentDefinition = { agentType: string whenToUse: string tools?: string[] disallowedTools?: string[] skills?: string[] // Skill names to preload (parsed from comma-separated frontmatter) mcpServers?: AgentMcpServerSpec[] // MCP servers specific to this agent hooks?: HooksSettings // Session-scoped hooks registered when agent starts color?: AgentColorName model?: string effort?: EffortValue permissionMode?: PermissionMode maxTurns?: number // Maximum number of agentic turns before stopping filename?: string // Original filename without .md extension (for user/project/managed agents) baseDir?: string criticalSystemReminder_EXPERIMENTAL?: string // Short message re-injected at every user turn requiredMcpServers?: string[] // MCP server name patterns that must be configured for agent to be available background?: boolean // Always run as background task when spawned initialPrompt?: string // Prepended to the first user turn (slash commands work) memory?: AgentMemoryScope // Persistent memory scope isolation?: 'worktree' | 'remote' // Run in an isolated git worktree, or remotely in CCR (ant-only) pendingSnapshotUpdate?: { snapshotTimestamp: string } /** Omit CLAUDE.md hierarchy from the agent's userContext. Read-only agents * (Explore, Plan) don't need commit/PR/lint guidelines — the main agent has * full CLAUDE.md and interprets their output. Saves ~5-15 Gtok/week across * 34M+ Explore spawns. Kill-switch: tengu_slim_subagent_claudemd. */ omitClaudeMd?: boolean } // Built-in agents - dynamic prompts only, no static systemPrompt field export type BuiltInAgentDefinition = BaseAgentDefinition & { source: 'built-in' baseDir: 'built-in' callback?: () => void getSystemPrompt: (params: { toolUseContext: Pick }) => string } // Custom agents from user/project/policy settings - prompt stored via closure export type CustomAgentDefinition = BaseAgentDefinition & { getSystemPrompt: () => string source: SettingSource filename?: string baseDir?: string } // Plugin agents - similar to custom but with plugin metadata, prompt stored via closure export type PluginAgentDefinition = BaseAgentDefinition & { getSystemPrompt: () => string source: 'plugin' filename?: string plugin: string } // Union type for all agent types export type AgentDefinition = | BuiltInAgentDefinition | CustomAgentDefinition | PluginAgentDefinition // Type guards for runtime type checking export function isBuiltInAgent( agent: AgentDefinition, ): agent is BuiltInAgentDefinition { return agent.source === 'built-in' } export function isCustomAgent( agent: AgentDefinition, ): agent is CustomAgentDefinition { return agent.source !== 'built-in' && agent.source !== 'plugin' } export function isPluginAgent( agent: AgentDefinition, ): agent is PluginAgentDefinition { return agent.source === 'plugin' } export type AgentDefinitionsResult = { activeAgents: AgentDefinition[] allAgents: AgentDefinition[] failedFiles?: Array<{ path: string; error: string }> allowedAgentTypes?: string[] } export function getActiveAgentsFromList( allAgents: AgentDefinition[], ): AgentDefinition[] { const builtInAgents = allAgents.filter(a => a.source === 'built-in') const pluginAgents = allAgents.filter(a => a.source === 'plugin') const userAgents = allAgents.filter(a => a.source === 'userSettings') const projectAgents = allAgents.filter(a => a.source === 'projectSettings') const managedAgents = allAgents.filter(a => a.source === 'policySettings') const flagAgents = allAgents.filter(a => a.source === 'flagSettings') const agentGroups = [ builtInAgents, pluginAgents, userAgents, projectAgents, flagAgents, managedAgents, ] const agentMap = new Map() for (const agents of agentGroups) { for (const agent of agents) { agentMap.set(agent.agentType, agent) } } return Array.from(agentMap.values()) } /** * Checks if an agent's required MCP servers are available. * Returns true if no requirements or all requirements are met. * @param agent The agent to check * @param availableServers List of available MCP server names (e.g., from mcp.clients) */ export function hasRequiredMcpServers( agent: AgentDefinition, availableServers: string[], ): boolean { if (!agent.requiredMcpServers || agent.requiredMcpServers.length === 0) { return true } // Each required pattern must match at least one available server (case-insensitive) return agent.requiredMcpServers.every(pattern => availableServers.some(server => server.toLowerCase().includes(pattern.toLowerCase()), ), ) } /** * Filters agents based on MCP server requirements. * Only returns agents whose required MCP servers are available. * @param agents List of agents to filter * @param availableServers List of available MCP server names */ export function filterAgentsByMcpRequirements( agents: AgentDefinition[], availableServers: string[], ): AgentDefinition[] { return agents.filter(agent => hasRequiredMcpServers(agent, availableServers)) } /** * Agent memory snapshot sync is disabled in this fork to avoid copying * project-scoped memory into persistent user/local agent memory. */ async function initializeAgentMemorySnapshots( _agents: CustomAgentDefinition[], ): Promise { logForDebugging( '[loadAgentsDir] Agent memory snapshot sync is disabled in this build', ) } export const getAgentDefinitionsWithOverrides = memoize( async (cwd: string): Promise => { // Simple mode: skip custom agents, only return built-ins if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { const builtInAgents = getBuiltInAgents() return { activeAgents: builtInAgents, allAgents: builtInAgents, } } try { const markdownFiles = await loadMarkdownFilesForSubdir('agents', cwd) const failedFiles: Array<{ path: string; error: string }> = [] const customAgents = markdownFiles .map(({ filePath, baseDir, frontmatter, content, source }) => { const agent = parseAgentFromMarkdown( filePath, baseDir, frontmatter, content, source, ) if (!agent) { // Skip non-agent markdown files silently (e.g., reference docs // co-located with agent definitions). Only report errors for files // that look like agent attempts (have a 'name' field in frontmatter). if (!frontmatter['name']) { return null } const errorMsg = getParseError(frontmatter) failedFiles.push({ path: filePath, error: errorMsg }) logForDebugging( `Failed to parse agent from ${filePath}: ${errorMsg}`, ) logEvent('tengu_agent_parse_error', { error: errorMsg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, location: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return null } return agent }) .filter(agent => agent !== null) // Kick off plugin agent loading concurrently with memory snapshot init — // loadPluginAgents is memoized and takes no args, so it's independent. // Join both so neither becomes a floating promise if the other throws. let pluginAgentsPromise = loadPluginAgents() if (feature('AGENT_MEMORY_SNAPSHOT') && isAutoMemoryEnabled()) { const [pluginAgents_] = await Promise.all([ pluginAgentsPromise, initializeAgentMemorySnapshots(customAgents), ]) pluginAgentsPromise = Promise.resolve(pluginAgents_) } const pluginAgents = await pluginAgentsPromise const builtInAgents = getBuiltInAgents() const allAgentsList: AgentDefinition[] = [ ...builtInAgents, ...pluginAgents, ...customAgents, ] const activeAgents = getActiveAgentsFromList(allAgentsList) // Initialize colors for all active agents for (const agent of activeAgents) { if (agent.color) { setAgentColor(agent.agentType, agent.color) } } return { activeAgents, allAgents: allAgentsList, failedFiles: failedFiles.length > 0 ? failedFiles : undefined, } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logForDebugging(`Error loading agent definitions: ${errorMessage}`) logError(error) // Even on error, return the built-in agents const builtInAgents = getBuiltInAgents() return { activeAgents: builtInAgents, allAgents: builtInAgents, failedFiles: [{ path: 'unknown', error: errorMessage }], } } }, ) export function clearAgentDefinitionsCache(): void { getAgentDefinitionsWithOverrides.cache.clear?.() clearPluginAgentCache() } /** * Helper to determine the specific parsing error for an agent file */ function getParseError(frontmatter: Record): string { const agentType = frontmatter['name'] const description = frontmatter['description'] if (!agentType || typeof agentType !== 'string') { return 'Missing required "name" field in frontmatter' } if (!description || typeof description !== 'string') { return 'Missing required "description" field in frontmatter' } return 'Unknown parsing error' } /** * Parse hooks from frontmatter using the HooksSchema * @param frontmatter The frontmatter object containing potential hooks * @param agentType The agent type for logging purposes * @returns Parsed hooks settings or undefined if invalid/missing */ function parseHooksFromFrontmatter( frontmatter: Record, agentType: string, ): HooksSettings | undefined { if (!frontmatter.hooks) { return undefined } const result = HooksSchema().safeParse(frontmatter.hooks) if (!result.success) { logForDebugging( `Invalid hooks in agent '${agentType}': ${result.error.message}`, ) return undefined } return result.data } /** * Parses agent definition from JSON data */ export function parseAgentFromJson( name: string, definition: unknown, source: SettingSource = 'flagSettings', ): CustomAgentDefinition | null { try { const parsed = AgentJsonSchema().parse(definition) let tools = parseAgentToolsFromFrontmatter(parsed.tools) // If memory is enabled, inject Write/Edit/Read tools for memory access if (isAutoMemoryEnabled() && parsed.memory && tools !== undefined) { const toolSet = new Set(tools) for (const tool of [ FILE_WRITE_TOOL_NAME, FILE_EDIT_TOOL_NAME, FILE_READ_TOOL_NAME, ]) { if (!toolSet.has(tool)) { tools = [...tools, tool] } } } const disallowedTools = parsed.disallowedTools !== undefined ? parseAgentToolsFromFrontmatter(parsed.disallowedTools) : undefined const systemPrompt = parsed.prompt const agent: CustomAgentDefinition = { agentType: name, whenToUse: parsed.description, ...(tools !== undefined ? { tools } : {}), ...(disallowedTools !== undefined ? { disallowedTools } : {}), getSystemPrompt: () => { if (isAutoMemoryEnabled() && parsed.memory) { return ( systemPrompt + '\n\n' + loadAgentMemoryPrompt(name, parsed.memory) ) } return systemPrompt }, source, ...(parsed.model ? { model: parsed.model } : {}), ...(parsed.effort !== undefined ? { effort: parsed.effort } : {}), ...(parsed.permissionMode ? { permissionMode: parsed.permissionMode } : {}), ...(parsed.mcpServers && parsed.mcpServers.length > 0 ? { mcpServers: parsed.mcpServers } : {}), ...(parsed.hooks ? { hooks: parsed.hooks } : {}), ...(parsed.maxTurns !== undefined ? { maxTurns: parsed.maxTurns } : {}), ...(parsed.skills && parsed.skills.length > 0 ? { skills: parsed.skills } : {}), ...(parsed.initialPrompt ? { initialPrompt: parsed.initialPrompt } : {}), ...(parsed.background ? { background: parsed.background } : {}), ...(parsed.memory ? { memory: parsed.memory } : {}), ...(parsed.isolation ? { isolation: parsed.isolation } : {}), } return agent } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logForDebugging(`Error parsing agent '${name}' from JSON: ${errorMessage}`) logError(error) return null } } /** * Parses multiple agents from a JSON object */ export function parseAgentsFromJson( agentsJson: unknown, source: SettingSource = 'flagSettings', ): AgentDefinition[] { try { const parsed = AgentsJsonSchema().parse(agentsJson) return Object.entries(parsed) .map(([name, def]) => parseAgentFromJson(name, def, source)) .filter((agent): agent is CustomAgentDefinition => agent !== null) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logForDebugging(`Error parsing agents from JSON: ${errorMessage}`) logError(error) return [] } } /** * Parses agent definition from markdown file data */ export function parseAgentFromMarkdown( filePath: string, baseDir: string, frontmatter: Record, content: string, source: SettingSource, ): CustomAgentDefinition | null { try { const agentType = frontmatter['name'] let whenToUse = frontmatter['description'] as string // Validate required fields — silently skip files without any agent // frontmatter (they're likely co-located reference documentation) if (!agentType || typeof agentType !== 'string') { return null } if (!whenToUse || typeof whenToUse !== 'string') { logForDebugging( `Agent file ${filePath} is missing required 'description' in frontmatter`, ) return null } // Unescape newlines in whenToUse that were escaped for YAML parsing whenToUse = whenToUse.replace(/\\n/g, '\n') const color = frontmatter['color'] as AgentColorName | undefined const modelRaw = frontmatter['model'] let model: string | undefined if (typeof modelRaw === 'string' && modelRaw.trim().length > 0) { const trimmed = modelRaw.trim() model = trimmed.toLowerCase() === 'inherit' ? 'inherit' : trimmed } // Parse background flag const backgroundRaw = frontmatter['background'] if ( backgroundRaw !== undefined && backgroundRaw !== 'true' && backgroundRaw !== 'false' && backgroundRaw !== true && backgroundRaw !== false ) { logForDebugging( `Agent file ${filePath} has invalid background value '${backgroundRaw}'. Must be 'true', 'false', or omitted.`, ) } const background = backgroundRaw === 'true' || backgroundRaw === true ? true : undefined // Parse memory scope const VALID_MEMORY_SCOPES: AgentMemoryScope[] = ['user', 'project', 'local'] const memoryRaw = frontmatter['memory'] as string | undefined let memory: AgentMemoryScope | undefined if (memoryRaw !== undefined) { if (VALID_MEMORY_SCOPES.includes(memoryRaw as AgentMemoryScope)) { memory = memoryRaw as AgentMemoryScope } else { logForDebugging( `Agent file ${filePath} has invalid memory value '${memoryRaw}'. Valid options: ${VALID_MEMORY_SCOPES.join(', ')}`, ) } } // Parse isolation mode. 'remote' is ant-only; external builds reject it at parse time. type IsolationMode = 'worktree' | 'remote' const VALID_ISOLATION_MODES: readonly IsolationMode[] = process.env.USER_TYPE === 'ant' ? ['worktree', 'remote'] : ['worktree'] const isolationRaw = frontmatter['isolation'] as string | undefined let isolation: IsolationMode | undefined if (isolationRaw !== undefined) { if (VALID_ISOLATION_MODES.includes(isolationRaw as IsolationMode)) { isolation = isolationRaw as IsolationMode } else { logForDebugging( `Agent file ${filePath} has invalid isolation value '${isolationRaw}'. Valid options: ${VALID_ISOLATION_MODES.join(', ')}`, ) } } // Parse effort from frontmatter (supports string levels and integers) const effortRaw = frontmatter['effort'] const parsedEffort = effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined if (effortRaw !== undefined && parsedEffort === undefined) { logForDebugging( `Agent file ${filePath} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`, ) } // Parse permissionMode from frontmatter const permissionModeRaw = frontmatter['permissionMode'] as | string | undefined const isValidPermissionMode = permissionModeRaw && (PERMISSION_MODES as readonly string[]).includes(permissionModeRaw) if (permissionModeRaw && !isValidPermissionMode) { const errorMsg = `Agent file ${filePath} has invalid permissionMode '${permissionModeRaw}'. Valid options: ${PERMISSION_MODES.join(', ')}` logForDebugging(errorMsg) } // Parse maxTurns from frontmatter const maxTurnsRaw = frontmatter['maxTurns'] const maxTurns = parsePositiveIntFromFrontmatter(maxTurnsRaw) if (maxTurnsRaw !== undefined && maxTurns === undefined) { logForDebugging( `Agent file ${filePath} has invalid maxTurns '${maxTurnsRaw}'. Must be a positive integer.`, ) } // Extract filename without extension const filename = basename(filePath, '.md') // Parse tools from frontmatter let tools = parseAgentToolsFromFrontmatter(frontmatter['tools']) // If memory is enabled, inject Write/Edit/Read tools for memory access if (isAutoMemoryEnabled() && memory && tools !== undefined) { const toolSet = new Set(tools) for (const tool of [ FILE_WRITE_TOOL_NAME, FILE_EDIT_TOOL_NAME, FILE_READ_TOOL_NAME, ]) { if (!toolSet.has(tool)) { tools = [...tools, tool] } } } // Parse disallowedTools from frontmatter const disallowedToolsRaw = frontmatter['disallowedTools'] const disallowedTools = disallowedToolsRaw !== undefined ? parseAgentToolsFromFrontmatter(disallowedToolsRaw) : undefined // Parse skills from frontmatter const skills = parseSlashCommandToolsFromFrontmatter(frontmatter['skills']) const initialPromptRaw = frontmatter['initialPrompt'] const initialPrompt = typeof initialPromptRaw === 'string' && initialPromptRaw.trim() ? initialPromptRaw : undefined // Parse mcpServers from frontmatter using same Zod validation as JSON agents const mcpServersRaw = frontmatter['mcpServers'] let mcpServers: AgentMcpServerSpec[] | undefined if (Array.isArray(mcpServersRaw)) { mcpServers = mcpServersRaw .map(item => { const result = AgentMcpServerSpecSchema().safeParse(item) if (result.success) { return result.data } logForDebugging( `Agent file ${filePath} has invalid mcpServers item: ${jsonStringify(item)}. Error: ${result.error.message}`, ) return null }) .filter((item): item is AgentMcpServerSpec => item !== null) } // Parse hooks from frontmatter const hooks = parseHooksFromFrontmatter(frontmatter, agentType) const systemPrompt = content.trim() const agentDef: CustomAgentDefinition = { baseDir, agentType: agentType, whenToUse: whenToUse, ...(tools !== undefined ? { tools } : {}), ...(disallowedTools !== undefined ? { disallowedTools } : {}), ...(skills !== undefined ? { skills } : {}), ...(initialPrompt !== undefined ? { initialPrompt } : {}), ...(mcpServers !== undefined && mcpServers.length > 0 ? { mcpServers } : {}), ...(hooks !== undefined ? { hooks } : {}), getSystemPrompt: () => { if (isAutoMemoryEnabled() && memory) { const memoryPrompt = loadAgentMemoryPrompt(agentType, memory) return systemPrompt + '\n\n' + memoryPrompt } return systemPrompt }, source, filename, ...(color && typeof color === 'string' && AGENT_COLORS.includes(color) ? { color } : {}), ...(model !== undefined ? { model } : {}), ...(parsedEffort !== undefined ? { effort: parsedEffort } : {}), ...(isValidPermissionMode ? { permissionMode: permissionModeRaw as PermissionMode } : {}), ...(maxTurns !== undefined ? { maxTurns } : {}), ...(background ? { background } : {}), ...(memory ? { memory } : {}), ...(isolation ? { isolation } : {}), } return agentDef } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logForDebugging(`Error parsing agent from ${filePath}: ${errorMessage}`) logError(error) return null } }