725 lines
25 KiB
TypeScript
725 lines
25 KiB
TypeScript
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<ToolUseContext, 'options'>
|
|
}) => 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<string, AgentDefinition>()
|
|
|
|
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<void> {
|
|
logForDebugging(
|
|
'[loadAgentsDir] Agent memory snapshot sync is disabled in this build',
|
|
)
|
|
}
|
|
|
|
export const getAgentDefinitionsWithOverrides = memoize(
|
|
async (cwd: string): Promise<AgentDefinitionsResult> => {
|
|
// 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, unknown>): 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<string, unknown>,
|
|
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<string, unknown>,
|
|
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
|
|
}
|
|
}
|