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 }