agent-loader.ts
1 /** 2 * Agent Loader MCP Tools - Dynamic DreamNode sub-agent management 3 * 4 * Enables AURYN to load/unload DreamNodes as sub-agents at runtime. 5 * Each DreamNode becomes a self-contained agent with its own context and tools. 6 */ 7 8 import { DreamNodeService } from '../services/standalone-adapter.js'; 9 import * as path from 'path'; 10 import * as fs from 'fs/promises'; 11 import { exec } from 'child_process'; 12 import { promisify } from 'util'; 13 14 const execAsync = promisify(exec); 15 16 // AURYN's agents directory 17 const AURYN_AGENTS_DIR = path.join(process.cwd(), '.claude', 'agents'); 18 19 /** 20 * Generate a safe filename from a DreamNode title 21 */ 22 function safeFilename(title: string): string { 23 return title 24 .toLowerCase() 25 .replace(/[^a-z0-9]+/g, '-') 26 .replace(/^-|-$/g, ''); 27 } 28 29 // Note: Sub-agents inherit parent's MCP config in Claude Code. 30 // We can't give sub-agents different MCP tools than AURYN has. 31 // Instead, we restrict to file operation tools and scope via system prompt. 32 33 /** 34 * Get cascading README content from a DreamNode's holarchy 35 */ 36 async function getHolarchyContext(dreamNodePath: string): Promise<string> { 37 let context = ''; 38 39 // Read the root README 40 const rootReadme = path.join(dreamNodePath, 'README.md'); 41 try { 42 const content = await fs.readFile(rootReadme, 'utf-8'); 43 context += content; 44 } catch { 45 context += '*No README found at root level.*\n'; 46 } 47 48 // Get submodule READMEs recursively 49 try { 50 const { stdout } = await execAsync( 51 "git submodule foreach --recursive --quiet 'echo $displaypath'", 52 { cwd: dreamNodePath } 53 ); 54 const submodulePaths = stdout.trim().split('\n').filter(p => p.length > 0); 55 56 for (const submodulePath of submodulePaths) { 57 const subReadme = path.join(dreamNodePath, submodulePath, 'README.md'); 58 try { 59 const subContent = await fs.readFile(subReadme, 'utf-8'); 60 context += `\n\n---\n\n## Submodule: ${submodulePath}\n\n${subContent}`; 61 } catch { 62 // Skip submodules without READMEs 63 } 64 } 65 } catch { 66 // No submodules or git error 67 } 68 69 return context; 70 } 71 72 /** 73 * Tool: load_dreamnode_agent 74 * Load a DreamNode as a sub-agent in AURYN's context 75 */ 76 export async function loadDreamNodeAgent(args: { 77 identifier: string; 78 model?: string; 79 }): Promise<{ 80 success: boolean; 81 agent_name?: string; 82 agent_file?: string; 83 description?: string; 84 message?: string; 85 error?: string; 86 }> { 87 try { 88 // Find the DreamNode 89 const node = await DreamNodeService.getDreamNode(args.identifier); 90 if (!node) { 91 return { 92 success: false, 93 error: `DreamNode not found: ${args.identifier}` 94 }; 95 } 96 97 // Generate agent name 98 const agentName = safeFilename(node.title); 99 const agentFile = path.join(AURYN_AGENTS_DIR, `${agentName}.md`); 100 101 // Ensure agents directory exists 102 await fs.mkdir(AURYN_AGENTS_DIR, { recursive: true }); 103 104 // Read the DreamNode's README for description 105 const readmePath = path.join(node.path, 'README.md'); 106 let readme = ''; 107 let description = `Agent for ${node.title}`; 108 try { 109 readme = await fs.readFile(readmePath, 'utf-8'); 110 // Extract first paragraph as description (up to 200 chars) 111 const firstPara = readme.split('\n\n')[0].replace(/^#.*\n/, '').trim(); 112 if (firstPara.length > 0) { 113 description = firstPara.slice(0, 200) + (firstPara.length > 200 ? '...' : ''); 114 } 115 } catch { 116 readme = `# ${node.title}\n\n*No README available.*`; 117 } 118 119 // Get full holarchy context 120 const holarchyContext = await getHolarchyContext(node.path); 121 122 // Generate agent file content 123 const model = args.model || 'sonnet'; 124 125 // Tools: file operations only, NOT AURYN's orchestrator tools 126 // This keeps the agent scoped to its own domain 127 const agentTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash']; 128 129 const agentContent = `--- 130 name: ${agentName} 131 description: ${description.replace(/\n/g, ' ')} 132 tools: ${agentTools.join(', ')} 133 model: ${model} 134 permissionMode: default 135 --- 136 137 # ${node.title} 138 139 You are the agent for the "${node.title}" DreamNode. 140 141 ## Your Location 142 143 **DreamNode Path**: \`${node.path}\` 144 145 This is your home directory. When using file tools (Read, Write, Glob, etc.), operate within this path. You can read files, explore your directory structure, and make changes within your domain. 146 147 ## Your Knowledge Base 148 149 The following READMEs from your holarchy are your context: 150 151 ${holarchyContext} 152 153 ## Your Role 154 155 You are a domain expert for "${node.title}". You can: 156 - Answer questions based on your deep context 157 - Read and modify files within your DreamNode (\`${node.path}\`) 158 - Perform domain-specific tasks 159 160 If asked about something outside your domain, say so clearly and suggest the user ask AURYN to load the appropriate DreamNode. 161 `; 162 163 // Write the agent file 164 await fs.writeFile(agentFile, agentContent, 'utf-8'); 165 166 return { 167 success: true, 168 agent_name: agentName, 169 agent_file: agentFile, 170 description, 171 message: `Agent "${agentName}" loaded. Run /resume to make it available in this session.` 172 }; 173 } catch (error) { 174 return { 175 success: false, 176 error: error instanceof Error ? error.message : 'Unknown error' 177 }; 178 } 179 } 180 181 /** 182 * Tool: unload_dreamnode_agent 183 * Remove a DreamNode sub-agent from AURYN's context 184 */ 185 export async function unloadDreamNodeAgent(args: { 186 agent_name: string; 187 }): Promise<{ 188 success: boolean; 189 removed_file?: string; 190 message?: string; 191 error?: string; 192 }> { 193 try { 194 const agentFile = path.join(AURYN_AGENTS_DIR, `${args.agent_name}.md`); 195 196 // Check if file exists 197 try { 198 await fs.access(agentFile); 199 } catch { 200 return { 201 success: false, 202 error: `Agent not found: ${args.agent_name}` 203 }; 204 } 205 206 // Don't allow removing core AURYN agents 207 const coreAgents = ['dreamwalk']; 208 if (coreAgents.includes(args.agent_name)) { 209 return { 210 success: false, 211 error: `Cannot unload core agent: ${args.agent_name}` 212 }; 213 } 214 215 // Remove the file 216 await fs.unlink(agentFile); 217 218 return { 219 success: true, 220 removed_file: agentFile, 221 message: `Agent "${args.agent_name}" unloaded. Run /resume to update the session.` 222 }; 223 } catch (error) { 224 return { 225 success: false, 226 error: error instanceof Error ? error.message : 'Unknown error' 227 }; 228 } 229 } 230 231 /** 232 * Tool: list_loaded_agents 233 * List all currently loaded DreamNode sub-agents 234 */ 235 export async function listLoadedAgents(): Promise<{ 236 success: boolean; 237 agents?: Array<{ 238 name: string; 239 file: string; 240 description: string; 241 }>; 242 error?: string; 243 }> { 244 try { 245 // Ensure directory exists 246 await fs.mkdir(AURYN_AGENTS_DIR, { recursive: true }); 247 248 const files = await fs.readdir(AURYN_AGENTS_DIR); 249 const agents: Array<{ name: string; file: string; description: string }> = []; 250 251 for (const file of files) { 252 if (!file.endsWith('.md')) continue; 253 254 const filePath = path.join(AURYN_AGENTS_DIR, file); 255 const content = await fs.readFile(filePath, 'utf-8'); 256 257 // Parse frontmatter to get description 258 const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); 259 let description = ''; 260 if (frontmatterMatch) { 261 const descMatch = frontmatterMatch[1].match(/description:\s*(.+)/); 262 if (descMatch) { 263 description = descMatch[1]; 264 } 265 } 266 267 agents.push({ 268 name: file.replace('.md', ''), 269 file: filePath, 270 description 271 }); 272 } 273 274 return { 275 success: true, 276 agents 277 }; 278 } catch (error) { 279 return { 280 success: false, 281 error: error instanceof Error ? error.message : 'Unknown error' 282 }; 283 } 284 } 285 286 /** 287 * Export tool definitions for MCP registration 288 */ 289 export const agentLoaderTools = { 290 load_dreamnode_agent: { 291 name: 'load_dreamnode_agent', 292 description: 'Load a DreamNode as a sub-agent. The agent will have the DreamNode\'s README as context and its MCP tools available. Requires /resume to take effect.', 293 inputSchema: { 294 type: 'object' as const, 295 properties: { 296 identifier: { 297 type: 'string', 298 description: 'UUID or title of the DreamNode to load as an agent' 299 }, 300 model: { 301 type: 'string', 302 description: 'Model to use for the agent (default: sonnet)', 303 enum: ['sonnet', 'opus', 'haiku'] 304 } 305 }, 306 required: ['identifier'] 307 }, 308 handler: loadDreamNodeAgent 309 }, 310 311 unload_dreamnode_agent: { 312 name: 'unload_dreamnode_agent', 313 description: 'Unload a DreamNode sub-agent from AURYN. Removes the agent definition file. Requires /resume to take effect.', 314 inputSchema: { 315 type: 'object' as const, 316 properties: { 317 agent_name: { 318 type: 'string', 319 description: 'Name of the agent to unload (filename without .md)' 320 } 321 }, 322 required: ['agent_name'] 323 }, 324 handler: unloadDreamNodeAgent 325 }, 326 327 list_loaded_agents: { 328 name: 'list_loaded_agents', 329 description: 'List all currently loaded DreamNode sub-agents in AURYN', 330 inputSchema: { 331 type: 'object' as const, 332 properties: {}, 333 required: [] 334 }, 335 handler: listLoadedAgents 336 } 337 };