/ src / tools / agent-loader.ts
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  };