chore: initialize recovered claude workspace
This commit is contained in:
755
src/tools/AgentTool/loadAgentsDir.ts
Normal file
755
src/tools/AgentTool/loadAgentsDir.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
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 {
|
||||
checkAgentMemorySnapshot,
|
||||
initializeFromSnapshot,
|
||||
} from './agentMemorySnapshot.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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for and initialize agent memory from project snapshots.
|
||||
* For agents with memory enabled, copies snapshot to local if no local memory exists.
|
||||
* For agents with newer snapshots, logs a debug message (user prompt TODO).
|
||||
*/
|
||||
async function initializeAgentMemorySnapshots(
|
||||
agents: CustomAgentDefinition[],
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
agents.map(async agent => {
|
||||
if (agent.memory !== 'user') return
|
||||
const result = await checkAgentMemorySnapshot(
|
||||
agent.agentType,
|
||||
agent.memory,
|
||||
)
|
||||
switch (result.action) {
|
||||
case 'initialize':
|
||||
logForDebugging(
|
||||
`Initializing ${agent.agentType} memory from project snapshot`,
|
||||
)
|
||||
await initializeFromSnapshot(
|
||||
agent.agentType,
|
||||
agent.memory,
|
||||
result.snapshotTimestamp!,
|
||||
)
|
||||
break
|
||||
case 'prompt-update':
|
||||
agent.pendingSnapshotUpdate = {
|
||||
snapshotTimestamp: result.snapshotTimestamp!,
|
||||
}
|
||||
logForDebugging(
|
||||
`Newer snapshot available for ${agent.agentType} memory (snapshot: ${result.snapshotTimestamp})`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user