/ internal / agents / loader.go
loader.go
  1  package agents
  2  
  3  import (
  4  	"fmt"
  5  	"os"
  6  	"path/filepath"
  7  	"regexp"
  8  	"sort"
  9  	"strings"
 10  	"unicode/utf8"
 11  
 12  	"gopkg.in/yaml.v3"
 13  
 14  	"github.com/Kocoro-lab/ShanClaw/internal/cwdctx"
 15  	"github.com/Kocoro-lab/ShanClaw/internal/skills"
 16  )
 17  
 18  var agentNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
 19  var mentionRe = regexp.MustCompile(`^@([a-zA-Z0-9][a-zA-Z0-9_-]*)(?:\s|$)`)
 20  
 21  // AgentToolsFilter controls which local tools an agent can access.
 22  // If Allow is non-empty, only those tools are available.
 23  // If Deny is non-empty, all tools except those are available.
 24  // If both are empty, all tools are available (backwards-compatible).
 25  type AgentToolsFilter struct {
 26  	Allow []string `yaml:"allow,omitempty" json:"allow,omitempty"`
 27  	Deny  []string `yaml:"deny,omitempty" json:"deny,omitempty"`
 28  }
 29  
 30  // AgentMCPConfig holds MCP server configs with an optional inherit flag.
 31  // When Inherit is false (default), only the servers listed here are used.
 32  // When Inherit is true, these servers are merged on top of the global set.
 33  // This struct is populated programmatically by parseAgentConfig, not by
 34  // direct YAML unmarshaling.
 35  type AgentMCPConfig struct {
 36  	Inherit bool
 37  	Servers map[string]AgentMCPServerRef
 38  }
 39  
 40  // AgentMCPServerRef mirrors the fields needed for per-agent MCP server config.
 41  // We keep it simple — the full MCPServerConfig is resolved at merge time.
 42  type AgentMCPServerRef struct {
 43  	Command   string            `yaml:"command" json:"command,omitempty"`
 44  	Args      []string          `yaml:"args,omitempty" json:"args,omitempty"`
 45  	Env       map[string]string `yaml:"env,omitempty" json:"env,omitempty"`
 46  	Type      string            `yaml:"type,omitempty" json:"type,omitempty"`
 47  	URL       string            `yaml:"url,omitempty" json:"url,omitempty"`
 48  	Disabled  bool              `yaml:"disabled,omitempty" json:"disabled,omitempty"`
 49  	Context   string            `yaml:"context,omitempty" json:"context,omitempty"`
 50  	KeepAlive bool              `yaml:"keep_alive,omitempty" json:"keep_alive,omitempty"`
 51  }
 52  
 53  // WatchEntry defines a single file system watch path for an agent.
 54  type WatchEntry struct {
 55  	Path string `yaml:"path" json:"path"`
 56  	Glob string `yaml:"glob,omitempty" json:"glob,omitempty"`
 57  }
 58  
 59  // HeartbeatConfig configures periodic heartbeat checks for an agent.
 60  // IsolatedSession defaults to true (nil = true). Use pointer for YAML omit-means-default.
 61  type HeartbeatConfig struct {
 62  	Every           string `yaml:"every" json:"every"`
 63  	ActiveHours     string `yaml:"active_hours,omitempty" json:"active_hours,omitempty"`
 64  	Model           string `yaml:"model,omitempty" json:"model,omitempty"`
 65  	IsolatedSession *bool  `yaml:"isolated_session,omitempty" json:"isolated_session,omitempty"`
 66  }
 67  
 68  // IsIsolatedSession returns the effective value (default true).
 69  func (h *HeartbeatConfig) IsIsolatedSession() bool {
 70  	if h.IsolatedSession == nil {
 71  		return true
 72  	}
 73  	return *h.IsolatedSession
 74  }
 75  
 76  // AgentConfig is the per-agent config overlay loaded from config.yaml.
 77  type AgentConfig struct {
 78  	CWD         string            `yaml:"cwd"`
 79  	MCPServers  *AgentMCPConfig   `yaml:"-"` // parsed manually for _inherit
 80  	Tools       *AgentToolsFilter `yaml:"tools"`
 81  	Agent       *AgentModelConfig `yaml:"agent"`
 82  	AutoApprove *bool             `yaml:"auto_approve"`
 83  	Watch       []WatchEntry      `yaml:"watch,omitempty"`
 84  	Heartbeat   *HeartbeatConfig  `yaml:"heartbeat,omitempty"`
 85  }
 86  
 87  // AgentModelConfig holds per-agent model/iteration overrides.
 88  type AgentModelConfig struct {
 89  	Model         *string  `yaml:"model" json:"model,omitempty"`
 90  	MaxIterations *int     `yaml:"max_iterations" json:"max_iterations,omitempty"`
 91  	Temperature   *float64 `yaml:"temperature" json:"temperature,omitempty"`
 92  	MaxTokens     *int     `yaml:"max_tokens" json:"max_tokens,omitempty"`
 93  	ContextWindow *int     `yaml:"context_window" json:"context_window,omitempty"`
 94  
 95  	IdleSoftTimeoutSecs *int `yaml:"idle_soft_timeout_secs" json:"idle_soft_timeout_secs,omitempty"`
 96  	IdleHardTimeoutSecs *int `yaml:"idle_hard_timeout_secs" json:"idle_hard_timeout_secs,omitempty"`
 97  }
 98  
 99  // Agent represents a loaded agent definition.
100  type Agent struct {
101  	Name     string
102  	Prompt   string
103  	Memory   string
104  	Config   *AgentConfig      // nil = inherit everything (backwards-compatible)
105  	Commands map[string]string // agent-scoped slash commands (name → content)
106  	Skills   []*skills.Skill   // agent-scoped skills (prompt, tool_chain, sub_agent)
107  }
108  
109  func ValidateAgentName(name string) error {
110  	if !agentNameRe.MatchString(name) {
111  		return fmt.Errorf("invalid agent name %q: must match %s", name, agentNameRe.String())
112  	}
113  	return nil
114  }
115  
116  func LoadAgent(agentsDir, name string) (*Agent, error) {
117  	if err := ValidateAgentName(name); err != nil {
118  		return nil, err
119  	}
120  
121  	// Two-step resolution: user dir first, then _builtin fallback
122  	dir := filepath.Join(agentsDir, name)
123  	if _, err := os.Stat(filepath.Join(dir, "AGENT.md")); err != nil {
124  		builtinDir := filepath.Join(agentsDir, "_builtin", name)
125  		if _, err := os.Stat(filepath.Join(builtinDir, "AGENT.md")); err != nil {
126  			return nil, fmt.Errorf("agent %q: missing AGENT.md: %w", name, err)
127  		}
128  		dir = builtinDir
129  	}
130  
131  	promptData, err := os.ReadFile(filepath.Join(dir, "AGENT.md"))
132  	if err != nil {
133  		return nil, fmt.Errorf("agent %q: missing AGENT.md: %w", name, err)
134  	}
135  
136  	// MEMORY.md always from top-level runtime dir, not definition dir
137  	runtimeDir := filepath.Join(agentsDir, name)
138  	var memory string
139  	if data, err := os.ReadFile(filepath.Join(runtimeDir, "MEMORY.md")); err == nil {
140  		memory = string(data)
141  	}
142  
143  	ag := &Agent{Name: name, Prompt: string(promptData), Memory: memory}
144  
145  	// Load per-agent config overlay (optional)
146  	if cfgData, err := os.ReadFile(filepath.Join(dir, "config.yaml")); err == nil {
147  		agCfg, err := parseAgentConfig(cfgData)
148  		if err != nil {
149  			return nil, fmt.Errorf("agent %q: bad config.yaml: %w", name, err)
150  		}
151  		if agCfg.CWD != "" {
152  			if err := cwdctx.ValidateCWD(agCfg.CWD); err != nil {
153  				return nil, fmt.Errorf("agent %s: %w", name, err)
154  			}
155  		}
156  		ag.Config = agCfg
157  	}
158  
159  	// Load agent-scoped commands (optional)
160  	ag.Commands = loadAgentCommands(filepath.Join(dir, "commands"))
161  
162  	// Load skills from _attached.yaml manifest
163  	shannonDir := filepath.Dir(agentsDir)
164  	attachedNames, hasManifest := loadAttachedSkills(filepath.Join(dir, "_attached.yaml"))
165  	if hasManifest && len(attachedNames) > 0 {
166  		globalSkillsDir := filepath.Join(shannonDir, "skills")
167  		allSkills, err := skills.LoadSkills(
168  			skills.SkillSource{Dir: globalSkillsDir, Source: "global"},
169  		)
170  		if err != nil {
171  			return nil, fmt.Errorf("agent %q: bad skills: %w", name, err)
172  		}
173  		// Match manifest entries against both Slug (directory identifier,
174  		// canonical post-decoupling) and Name (frontmatter display label)
175  		// so manifests written by either API path resolve correctly.
176  		attached := make(map[string]bool, len(attachedNames))
177  		for _, n := range attachedNames {
178  			attached[n] = true
179  		}
180  		for _, s := range allSkills {
181  			if attached[s.Slug] || attached[s.Name] {
182  				ag.Skills = append(ag.Skills, s)
183  			}
184  		}
185  	}
186  
187  	return ag, nil
188  }
189  
190  // loadAttachedSkills reads the _attached.yaml manifest listing skill names.
191  // Returns (names, true) if the file exists, (nil, false) if not.
192  func loadAttachedSkills(path string) ([]string, bool) {
193  	data, err := os.ReadFile(path)
194  	if err != nil {
195  		return nil, false
196  	}
197  	var names []string
198  	if err := yaml.Unmarshal(data, &names); err != nil {
199  		return nil, false
200  	}
201  	return names, true
202  }
203  
204  // ReadAttachedSkills reads an agent's attached-skill manifest.
205  // Returns (nil, nil) when the manifest does not exist.
206  func ReadAttachedSkills(agentsDir, agentName string) ([]string, error) {
207  	if err := ValidateAgentName(agentName); err != nil {
208  		return nil, err
209  	}
210  	path := filepath.Join(agentsDir, agentName, "_attached.yaml")
211  	data, err := os.ReadFile(path)
212  	if err != nil {
213  		if os.IsNotExist(err) {
214  			return nil, nil
215  		}
216  		return nil, err
217  	}
218  	var names []string
219  	if err := yaml.Unmarshal(data, &names); err != nil {
220  		return nil, err
221  	}
222  	return names, nil
223  }
224  
225  // WriteAttachedSkills writes the _attached.yaml manifest for an agent.
226  func WriteAttachedSkills(agentsDir, agentName string, names []string) error {
227  	dir := filepath.Join(agentsDir, agentName)
228  	if err := os.MkdirAll(dir, 0700); err != nil {
229  		return err
230  	}
231  	data, err := yaml.Marshal(names)
232  	if err != nil {
233  		return err
234  	}
235  	return os.WriteFile(filepath.Join(dir, "_attached.yaml"), data, 0600)
236  }
237  
238  // DeleteAttachedSkills removes the _attached.yaml manifest.
239  func DeleteAttachedSkills(agentsDir, agentName string) error {
240  	path := filepath.Join(agentsDir, agentName, "_attached.yaml")
241  	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
242  		return err
243  	}
244  	return nil
245  }
246  
247  // SetAttachedSkills replaces an agent's attached-skill manifest with a normalized set.
248  // Names are deduplicated and sorted; an empty set removes the manifest.
249  func SetAttachedSkills(agentsDir, agentName string, names []string) error {
250  	seen := make(map[string]bool, len(names))
251  	normalized := make([]string, 0, len(names))
252  	for _, name := range names {
253  		if name == "" || seen[name] {
254  			continue
255  		}
256  		seen[name] = true
257  		normalized = append(normalized, name)
258  	}
259  	sort.Strings(normalized)
260  	if len(normalized) == 0 {
261  		return DeleteAttachedSkills(agentsDir, agentName)
262  	}
263  	return WriteAttachedSkills(agentsDir, agentName, normalized)
264  }
265  
266  // AttachSkill adds a skill name to an agent's attached-skill manifest.
267  func AttachSkill(agentsDir, agentName, skillName string) error {
268  	names, err := ReadAttachedSkills(agentsDir, agentName)
269  	if err != nil {
270  		return err
271  	}
272  	names = append(names, skillName)
273  	return SetAttachedSkills(agentsDir, agentName, names)
274  }
275  
276  // DetachSkill removes a skill name from an agent's attached-skill manifest.
277  func DetachSkill(agentsDir, agentName, skillName string) error {
278  	names, err := ReadAttachedSkills(agentsDir, agentName)
279  	if err != nil {
280  		return err
281  	}
282  	filtered := make([]string, 0, len(names))
283  	for _, name := range names {
284  		if name != skillName {
285  			filtered = append(filtered, name)
286  		}
287  	}
288  	return SetAttachedSkills(agentsDir, agentName, filtered)
289  }
290  
291  // parseAgentConfig parses the per-agent config.yaml, handling the special
292  // _inherit key inside mcp_servers.
293  func parseAgentConfig(data []byte) (*AgentConfig, error) {
294  	var cfg AgentConfig
295  	if err := yaml.Unmarshal(data, &cfg); err != nil {
296  		return nil, err
297  	}
298  
299  	// Parse mcp_servers manually to handle _inherit key
300  	var raw struct {
301  		MCPServers map[string]yaml.Node `yaml:"mcp_servers"`
302  	}
303  	if err := yaml.Unmarshal(data, &raw); err == nil && len(raw.MCPServers) > 0 {
304  		mcpCfg := &AgentMCPConfig{
305  			Servers: make(map[string]AgentMCPServerRef),
306  		}
307  		for key, node := range raw.MCPServers {
308  			if key == "_inherit" {
309  				var inherit bool
310  				if err := node.Decode(&inherit); err == nil {
311  					mcpCfg.Inherit = inherit
312  				}
313  				continue
314  			}
315  			var srv AgentMCPServerRef
316  			if err := node.Decode(&srv); err == nil {
317  				mcpCfg.Servers[key] = srv
318  			}
319  		}
320  		cfg.MCPServers = mcpCfg
321  	}
322  
323  	return &cfg, nil
324  }
325  
326  const maxAgentCommandChars = 8000
327  
328  // loadAgentCommands loads .md files from the agent's commands/ directory.
329  func loadAgentCommands(dir string) map[string]string {
330  	entries, err := filepath.Glob(filepath.Join(dir, "*.md"))
331  	if err != nil || len(entries) == 0 {
332  		return nil
333  	}
334  	sort.Strings(entries)
335  	commands := make(map[string]string)
336  	for _, path := range entries {
337  		name := strings.TrimSuffix(filepath.Base(path), ".md")
338  		data, err := os.ReadFile(path)
339  		if err != nil {
340  			continue
341  		}
342  		content := string(data)
343  		if utf8.RuneCountInString(content) > maxAgentCommandChars {
344  			content = string([]rune(content)[:maxAgentCommandChars])
345  		}
346  		commands[name] = content
347  	}
348  	return commands
349  }
350  
351  // LoadGlobalSkills loads skills from the global skills directory (~/.shannon/skills/).
352  // Only installed (global) skills are returned — bundled skills must be explicitly
353  // installed first (except those auto-installed by EnsureBuiltinSkills).
354  func LoadGlobalSkills(shannonDir string) ([]*skills.Skill, error) {
355  	globalSkillsDir := filepath.Join(shannonDir, "skills")
356  	return skills.LoadSkills(
357  		skills.SkillSource{Dir: globalSkillsDir, Source: "global"},
358  	)
359  }
360  
361  // AgentEntry represents an agent in the listing with source metadata.
362  type AgentEntry struct {
363  	Name     string `json:"name"`
364  	Builtin  bool   `json:"builtin"`  // loaded from _builtin
365  	Override bool   `json:"override"` // user-defined agent overrides a builtin
366  }
367  
368  func ListAgents(agentsDir string) ([]AgentEntry, error) {
369  	userNames, err := listAgentNames(agentsDir)
370  	if err != nil {
371  		return nil, err
372  	}
373  	builtinNames, err2 := listAgentNames(filepath.Join(agentsDir, "_builtin"))
374  	if err2 != nil {
375  		return nil, fmt.Errorf("scanning builtin agents: %w", err2)
376  	}
377  
378  	builtinSet := make(map[string]bool, len(builtinNames))
379  	for _, n := range builtinNames {
380  		builtinSet[n] = true
381  	}
382  
383  	seen := make(map[string]bool)
384  	var entries []AgentEntry
385  
386  	// User-defined agents first (they win on dedup)
387  	for _, name := range userNames {
388  		if name == "_builtin" {
389  			continue
390  		}
391  		seen[name] = true
392  		entries = append(entries, AgentEntry{
393  			Name:     name,
394  			Builtin:  false,
395  			Override: builtinSet[name],
396  		})
397  	}
398  
399  	// Builtin agents not overridden
400  	for _, name := range builtinNames {
401  		if seen[name] {
402  			continue
403  		}
404  		entries = append(entries, AgentEntry{
405  			Name:    name,
406  			Builtin: true,
407  		})
408  	}
409  
410  	sort.Slice(entries, func(i, j int) bool {
411  		return entries[i].Name < entries[j].Name
412  	})
413  	return entries, nil
414  }
415  
416  // listAgentNames scans a directory for valid agent subdirectories.
417  // Returns an error for I/O failures other than "directory does not exist".
418  func listAgentNames(dir string) ([]string, error) {
419  	entries, err := os.ReadDir(dir)
420  	if err != nil {
421  		if os.IsNotExist(err) {
422  			return nil, nil
423  		}
424  		return nil, err
425  	}
426  	var names []string
427  	for _, e := range entries {
428  		if !e.IsDir() {
429  			continue
430  		}
431  		if err := ValidateAgentName(e.Name()); err != nil {
432  			continue
433  		}
434  		if _, err := os.Stat(filepath.Join(dir, e.Name(), "AGENT.md")); err == nil {
435  			names = append(names, e.Name())
436  		}
437  	}
438  	sort.Strings(names)
439  	return names, nil
440  }
441  
442  func ParseAgentMention(msg string) (string, string) {
443  	m := mentionRe.FindStringSubmatch(msg)
444  	if m == nil {
445  		return "", msg
446  	}
447  	name := strings.ToLower(m[1])
448  	if err := ValidateAgentName(name); err != nil {
449  		return "", msg
450  	}
451  	rest := strings.TrimSpace(msg[len(m[0]):])
452  	return name, rest
453  }