// Package subagents discovers named agent profiles that can be selected by // zut's resident subagent runtime. Profiles use the common markdown/frontmatter layout: // a YAML-like metadata block followed by the agent's system prompt. // // Discovery prefers explicitly configured directories, then the shared // ~/.agents/agents directory. Profiles are metadata plus instructions; the // main agent sees only the compact manifest, while a selected child loads the // full profile itself. package subagents import ( "fmt" "os" "path/filepath" "sort" "strconv" "strings" ) const maxProfileBytes = 1 << 20 // Profile is one discovered named subagent definition. type Profile struct { Name string Description string SystemPrompt string Tools []string // ToolsDeclared distinguishes an omitted tools key (inherit the // child-safe catalogue) from an explicit empty list (grant no tools). ToolsDeclared bool Model string Provider string Thinking string // Nil means the profile did not specify whether fast mode is enabled. FastMode *bool // SystemPromptMode is "append" (the default) or "replace". SystemPromptMode string // Nil means the profile did not specify the inheritance behavior. InheritProjectContext *bool InheritSkills *bool Path string Source string } // Discover returns profiles in precedence order, sorted by name. A profile // with the same name in a higher-priority directory shadows lower-priority // copies. Missing directories are normal and do not produce errors. func Discover(cwd, userHome string) ([]*Profile, []error) { seen := make(map[string]*Profile) var errs []error for _, loc := range searchDirs(cwd, userHome) { entries, err := os.ReadDir(loc.dir) if err != nil { if os.IsNotExist(err) { if info, statErr := os.Stat(loc.dir); statErr == nil && !info.IsDir() { errs = append(errs, fmt.Errorf("read subagent directory %s: not a directory", loc.dir)) } } else { errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err)) } continue } for _, entry := range entries { if !entry.Type().IsRegular() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".md") { continue } path := filepath.Join(loc.dir, entry.Name()) profile, err := load(path, loc.label) if err != nil { if !os.IsNotExist(err) { errs = append(errs, fmt.Errorf("read subagent profile %s: %w", path, err)) } continue } if !validName(profile.Name) || profile.SystemPrompt == "" { continue } if _, exists := seen[profile.Name]; exists { continue } seen[profile.Name] = profile } } out := make([]*Profile, 0, len(seen)) for _, profile := range seen { out = append(out, profile) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, errs } // Find returns a profile by its exact name, or nil when it is unavailable. func Find(profiles []*Profile, name string) *Profile { name = strings.TrimSpace(name) for _, profile := range profiles { if profile != nil && profile.Name == name { return profile } } return nil } // ModelSelection splits a provider-qualified model value such as // "openai-codex/gpt-5.6-luna". Explicit provider metadata must be paired // with an unqualified model; model IDs may contain slashes when no provider // metadata is supplied. func (p *Profile) ModelSelection() (provider, model string) { if p == nil { return "", "" } provider = strings.TrimSpace(p.Provider) model = strings.TrimSpace(p.Model) if provider == "" { if before, after, ok := strings.Cut(model, "/"); ok { provider, model = strings.TrimSpace(before), strings.TrimSpace(after) } } return provider, model } // ResolveProfileTools applies a profile's exact frontmatter declaration to a // host-provided child-safe catalogue. An omitted declaration inherits that // catalogue; an explicit empty list grants no tools. Explicit names outside // the child catalogue or denied by host policy are omitted so portable profile // files can name host-only extensions without granting them to a child. func ResolveProfileTools(profile *Profile, childSafe []string, permitted func(string) bool) ([]string, error) { if profile == nil || !profile.ToolsDeclared { return append([]string(nil), childSafe...), nil } catalogue := make(map[string]struct{}, len(childSafe)) for _, name := range childSafe { catalogue[name] = struct{}{} } resolved := make([]string, 0, len(profile.Tools)) for _, name := range profile.Tools { name = strings.TrimSpace(name) if _, ok := catalogue[name]; !ok { continue } if permitted != nil && !permitted(name) { continue } resolved = append(resolved, name) } return resolved, nil } // SystemPromptAddendum renders the compact [subagents_list] manifest for the // parent model. Full profile bodies are intentionally omitted: the selected // child loads its own instructions, and keeping them out of every parent turn // avoids unnecessary context and accidental instruction mixing. func SystemPromptAddendum(profiles []*Profile) string { if len(profiles) == 0 { return "" } var sb strings.Builder sb.WriteString("[subagents_list]\n") sb.WriteString("Named subagents available to the subagent tool's spawn action. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n") for _, profile := range profiles { if profile == nil { continue } name := manifestValue(profile.Name) description := manifestValue(profile.Description) if description == "" { description = "(no description)" } metadata := make([]string, 0, 7) if model := manifestValue(profile.Model); model != "" { metadata = append(metadata, "model="+model) } if provider := manifestValue(profile.Provider); provider != "" { metadata = append(metadata, "provider="+provider) } if thinking := manifestValue(profile.Thinking); thinking != "" { metadata = append(metadata, "thinking="+thinking) } if len(profile.Tools) > 0 { metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ","))) } if profile.FastMode != nil { metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) } if len(metadata) > 0 { fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) } else { fmt.Fprintf(&sb, "- %s: %s\n", name, description) } } sb.WriteString("[/subagents_list]") return sb.String() } type location struct { dir string label string } func searchDirs(cwd, userHome string) []location { var out []location add := func(dir, label string) { if strings.TrimSpace(dir) != "" { out = append(out, location{dir: dir, label: label}) } } if extra := os.Getenv("ZUT_AGENT_PROFILES"); extra != "" { for _, dir := range filepath.SplitList(extra) { dir = strings.TrimSpace(dir) if dir != "" && !filepath.IsAbs(dir) && strings.TrimSpace(cwd) != "" { dir = filepath.Join(cwd, dir) } add(dir, "configured") } } if userHome != "" { add(filepath.Join(userHome, ".agents", "agents"), "global (agents)") } return out } func load(path, source string) (*Profile, error) { info, err := os.Stat(path) if err != nil { return nil, err } if !info.Mode().IsRegular() { return nil, fmt.Errorf("not a regular file") } if info.Size() > maxProfileBytes { return nil, fmt.Errorf("profile is larger than %d bytes", maxProfileBytes) } raw, err := os.ReadFile(path) if err != nil { return nil, err } front, body, err := splitFrontmatter(string(raw)) if err != nil { return nil, err } values, lists := parseFrontmatter(front) name := unquote(values["name"]) if name == "" { name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) } thinking, err := parseThinkingValue(values["thinking"], "thinking") if err != nil { return nil, err } reasoning, err := parseThinkingValue(values["reasoning"], "reasoning") if err != nil { return nil, err } if thinking == "" { thinking = reasoning } model := strings.TrimSpace(unquote(values["model"])) provider := strings.TrimSpace(unquote(values["provider"])) if provider != "" && strings.Contains(model, "/") { return nil, fmt.Errorf("model must not include a provider when provider is set") } _, toolsDeclared := lists["tools"] profile := &Profile{ Name: strings.TrimSpace(name), Description: strings.TrimSpace(unquote(values["description"])), SystemPrompt: strings.TrimSpace(body), Tools: lists["tools"], ToolsDeclared: toolsDeclared, Model: model, Provider: provider, Thinking: thinking, Path: path, Source: source, } profile.SystemPromptMode = "append" if mode, ok := values["systempromptmode"]; ok { switch strings.ToLower(strings.TrimSpace(unquote(mode))) { case "", "append": case "replace": profile.SystemPromptMode = "replace" default: return nil, fmt.Errorf("systemPromptMode must be append or replace") } } if raw, ok := values["inheritprojectcontext"]; ok { value, parsed := parseOptionalBool(raw) if !parsed { return nil, fmt.Errorf("inheritProjectContext must be true or false") } profile.InheritProjectContext = &value } if raw, ok := values["inheritskills"]; ok { value, parsed := parseOptionalBool(raw) if !parsed { return nil, fmt.Errorf("inheritSkills must be true or false") } profile.InheritSkills = &value } if raw, ok := values["fastmode"]; ok { value, parsed := parseOptionalBool(raw) if !parsed { return nil, fmt.Errorf("fastMode must be true or false") } profile.FastMode = &value } return profile, nil } func splitFrontmatter(raw string) (front, body string, err error) { rest := strings.TrimLeft(raw, " \t\r\n") firstEnd := strings.IndexByte(rest, '\n') if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" { return "", raw, nil } rest = rest[firstEnd+1:] for offset := 0; offset <= len(rest); { rel := strings.Index(rest[offset:], "\n---") if rel < 0 { // A document that starts a frontmatter block but never closes // it is malformed, not a body-only profile. return "", "", fmt.Errorf("frontmatter: missing closing delimiter") } end := offset + rel after := end + len("\n---") if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' { return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n"), nil } offset = after } return "", raw, nil } func parseFrontmatter(front string) (map[string]string, map[string][]string) { values := make(map[string]string) lists := make(map[string][]string) lines := strings.Split(front, "\n") for i := 0; i < len(lines); i++ { line := lines[i] trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { continue } colon := strings.IndexByte(trimmed, ':') if colon < 0 { continue } key := normalizeKey(trimmed[:colon]) value := strings.TrimSpace(trimmed[colon+1:]) if value == "" { var items []string for j := i + 1; j < len(lines); j++ { next := strings.TrimSpace(lines[j]) if next == "" { continue } if !strings.HasPrefix(next, "-") || (len(lines[j]) > 0 && !strings.HasPrefix(lines[j], " ") && !strings.HasPrefix(lines[j], "\t")) { break } items = append(items, unquote(strings.TrimSpace(strings.TrimPrefix(next, "-")))) i = j } if key == "tools" || len(items) > 0 { lists[key] = items } continue } if key == "tools" { lists[key] = parseList(value) continue } values[key] = unquote(value) } return values, lists } func normalizeKey(key string) string { key = strings.ToLower(strings.TrimSpace(key)) key = strings.ReplaceAll(key, "-", "") key = strings.ReplaceAll(key, "_", "") return key } func parseList(value string) []string { value = strings.TrimSpace(value) value = strings.TrimPrefix(value, "[") value = strings.TrimSuffix(value, "]") if value == "" { return nil } parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) for _, part := range parts { if item := strings.TrimSpace(unquote(part)); item != "" { out = append(out, item) } } return out } func parseThinkingValue(value, field string) (string, error) { switch strings.ToLower(strings.TrimSpace(unquote(value))) { case "": return "", nil case "off": return "off", nil case "minimal", "minimum": return "minimum", nil case "low", "medium", "high", "xhigh", "max": return strings.ToLower(strings.TrimSpace(unquote(value))), nil case "maximum": return "xhigh", nil default: return "", fmt.Errorf("%s must be off|minimum|low|medium|high|xhigh|max", field) } } func parseOptionalBool(value string) (bool, bool) { parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value))) return parsed, err == nil } func validName(name string) bool { name = strings.TrimSpace(name) if name == "" || len(name) > 128 { return false } for _, r := range name { if r == '/' || r == '\\' || r == '\x00' || r < 0x20 { return false } } return true } func manifestValue(value string) string { return strings.Join(strings.Fields(value), " ") } func unquote(value string) string { value = strings.TrimSpace(value) if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { return value[1 : len(value)-1] } return value }