1// Package subagents discovers named agent profiles that can be selected by
2// zut's resident subagent runtime. Profiles use the common markdown/frontmatter layout:
3// a YAML-like metadata block followed by the agent's system prompt.
4//
5// Discovery prefers explicitly configured directories, then the shared
6// ~/.agents/agents directory. Profiles are metadata plus instructions; the
7// main agent sees only the compact manifest, while a selected child loads the
8// full profile itself.
9package subagents
10
11import (
12 "fmt"
13 "os"
14 "path/filepath"
15 "sort"
16 "strconv"
17 "strings"
18)
19
20const maxProfileBytes = 1 << 20
21
22// Profile is one discovered named subagent definition.
23type Profile struct {
24 Name string
25 Description string
26 SystemPrompt string
27 Tools []string
28 // ToolsDeclared distinguishes an omitted tools key (inherit the
29 // child-safe catalogue) from an explicit empty list (grant no tools).
30 ToolsDeclared bool
31 Model string
32 Provider string
33 Thinking string
34
35 // Nil means the profile did not specify whether fast mode is enabled.
36 FastMode *bool
37
38 // SystemPromptMode is "append" (the default) or "replace".
39 SystemPromptMode string
40
41 // Nil means the profile did not specify the inheritance behavior.
42 InheritProjectContext *bool
43 InheritSkills *bool
44
45 Path string
46 Source string
47}
48
49// Discover returns profiles in precedence order, sorted by name. A profile
50// with the same name in a higher-priority directory shadows lower-priority
51// copies. Missing directories are normal and do not produce errors.
52func Discover(cwd, userHome string) ([]*Profile, []error) {
53 seen := make(map[string]*Profile)
54 var errs []error
55
56 for _, loc := range searchDirs(cwd, userHome) {
57 entries, err := os.ReadDir(loc.dir)
58 if err != nil {
59 if os.IsNotExist(err) {
60 if info, statErr := os.Stat(loc.dir); statErr == nil && !info.IsDir() {
61 errs = append(errs, fmt.Errorf("read subagent directory %s: not a directory", loc.dir))
62 }
63 } else {
64 errs = append(errs, fmt.Errorf("read subagent directory %s: %w", loc.dir, err))
65 }
66 continue
67 }
68 for _, entry := range entries {
69 if !entry.Type().IsRegular() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".md") {
70 continue
71 }
72 path := filepath.Join(loc.dir, entry.Name())
73 profile, err := load(path, loc.label)
74 if err != nil {
75 if !os.IsNotExist(err) {
76 errs = append(errs, fmt.Errorf("read subagent profile %s: %w", path, err))
77 }
78 continue
79 }
80 if !validName(profile.Name) || profile.SystemPrompt == "" {
81 continue
82 }
83 if _, exists := seen[profile.Name]; exists {
84 continue
85 }
86 seen[profile.Name] = profile
87 }
88 }
89
90 out := make([]*Profile, 0, len(seen))
91 for _, profile := range seen {
92 out = append(out, profile)
93 }
94 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
95 return out, errs
96}
97
98// Find returns a profile by its exact name, or nil when it is unavailable.
99func Find(profiles []*Profile, name string) *Profile {
100 name = strings.TrimSpace(name)
101 for _, profile := range profiles {
102 if profile != nil && profile.Name == name {
103 return profile
104 }
105 }
106 return nil
107}
108
109// ModelSelection splits a provider-qualified model value such as
110// "openai-codex/gpt-5.6-luna". Explicit provider metadata must be paired
111// with an unqualified model; model IDs may contain slashes when no provider
112// metadata is supplied.
113func (p *Profile) ModelSelection() (provider, model string) {
114 if p == nil {
115 return "", ""
116 }
117 provider = strings.TrimSpace(p.Provider)
118 model = strings.TrimSpace(p.Model)
119 if provider == "" {
120 if before, after, ok := strings.Cut(model, "/"); ok {
121 provider, model = strings.TrimSpace(before), strings.TrimSpace(after)
122 }
123 }
124 return provider, model
125}
126
127// ResolveProfileTools applies a profile's exact frontmatter declaration to a
128// host-provided child-safe catalogue. An omitted declaration inherits that
129// catalogue; an explicit empty list grants no tools. Explicit names outside
130// the child catalogue or denied by host policy are omitted so portable profile
131// files can name host-only extensions without granting them to a child.
132func ResolveProfileTools(profile *Profile, childSafe []string, permitted func(string) bool) ([]string, error) {
133 if profile == nil || !profile.ToolsDeclared {
134 return append([]string(nil), childSafe...), nil
135 }
136 catalogue := make(map[string]struct{}, len(childSafe))
137 for _, name := range childSafe {
138 catalogue[name] = struct{}{}
139 }
140 resolved := make([]string, 0, len(profile.Tools))
141 for _, name := range profile.Tools {
142 name = strings.TrimSpace(name)
143 if _, ok := catalogue[name]; !ok {
144 continue
145 }
146 if permitted != nil && !permitted(name) {
147 continue
148 }
149 resolved = append(resolved, name)
150 }
151 return resolved, nil
152}
153
154// SystemPromptAddendum renders the compact [subagents_list] manifest for the
155// parent model. Full profile bodies are intentionally omitted: the selected
156// child loads its own instructions, and keeping them out of every parent turn
157// avoids unnecessary context and accidental instruction mixing.
158func SystemPromptAddendum(profiles []*Profile) string {
159 if len(profiles) == 0 {
160 return ""
161 }
162 var sb strings.Builder
163 sb.WriteString("[subagents_list]\n")
164 sb.WriteString("Named subagents available to subagent_spawn. 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")
165 for _, profile := range profiles {
166 if profile == nil {
167 continue
168 }
169 name := manifestValue(profile.Name)
170 description := manifestValue(profile.Description)
171 if description == "" {
172 description = "(no description)"
173 }
174 metadata := make([]string, 0, 7)
175 if model := manifestValue(profile.Model); model != "" {
176 metadata = append(metadata, "model="+model)
177 }
178 if provider := manifestValue(profile.Provider); provider != "" {
179 metadata = append(metadata, "provider="+provider)
180 }
181 if thinking := manifestValue(profile.Thinking); thinking != "" {
182 metadata = append(metadata, "thinking="+thinking)
183 }
184 if len(profile.Tools) > 0 {
185 metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ",")))
186 }
187 if profile.FastMode != nil {
188 metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode))
189 }
190 if len(metadata) > 0 {
191 fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description)
192 } else {
193 fmt.Fprintf(&sb, "- %s: %s\n", name, description)
194 }
195 }
196 sb.WriteString("[/subagents_list]")
197 return sb.String()
198}
199
200type location struct {
201 dir string
202 label string
203}
204
205func searchDirs(cwd, userHome string) []location {
206 var out []location
207 add := func(dir, label string) {
208 if strings.TrimSpace(dir) != "" {
209 out = append(out, location{dir: dir, label: label})
210 }
211 }
212 if extra := os.Getenv("ZUT_AGENT_PROFILES"); extra != "" {
213 for _, dir := range filepath.SplitList(extra) {
214 dir = strings.TrimSpace(dir)
215 if dir != "" && !filepath.IsAbs(dir) && strings.TrimSpace(cwd) != "" {
216 dir = filepath.Join(cwd, dir)
217 }
218 add(dir, "configured")
219 }
220 }
221 if userHome != "" {
222 add(filepath.Join(userHome, ".agents", "agents"), "global (agents)")
223 }
224 return out
225}
226
227func load(path, source string) (*Profile, error) {
228 info, err := os.Stat(path)
229 if err != nil {
230 return nil, err
231 }
232 if !info.Mode().IsRegular() {
233 return nil, fmt.Errorf("not a regular file")
234 }
235 if info.Size() > maxProfileBytes {
236 return nil, fmt.Errorf("profile is larger than %d bytes", maxProfileBytes)
237 }
238 raw, err := os.ReadFile(path)
239 if err != nil {
240 return nil, err
241 }
242 front, body, err := splitFrontmatter(string(raw))
243 if err != nil {
244 return nil, err
245 }
246 values, lists := parseFrontmatter(front)
247 name := unquote(values["name"])
248 if name == "" {
249 name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
250 }
251 thinking, err := parseThinkingValue(values["thinking"], "thinking")
252 if err != nil {
253 return nil, err
254 }
255 reasoning, err := parseThinkingValue(values["reasoning"], "reasoning")
256 if err != nil {
257 return nil, err
258 }
259 if thinking == "" {
260 thinking = reasoning
261 }
262 model := strings.TrimSpace(unquote(values["model"]))
263 provider := strings.TrimSpace(unquote(values["provider"]))
264 if provider != "" && strings.Contains(model, "/") {
265 return nil, fmt.Errorf("model must not include a provider when provider is set")
266 }
267 _, toolsDeclared := lists["tools"]
268 profile := &Profile{
269 Name: strings.TrimSpace(name),
270 Description: strings.TrimSpace(unquote(values["description"])),
271 SystemPrompt: strings.TrimSpace(body),
272 Tools: lists["tools"],
273 ToolsDeclared: toolsDeclared,
274 Model: model,
275 Provider: provider,
276 Thinking: thinking,
277 Path: path,
278 Source: source,
279 }
280 profile.SystemPromptMode = "append"
281 if mode, ok := values["systempromptmode"]; ok {
282 switch strings.ToLower(strings.TrimSpace(unquote(mode))) {
283 case "", "append":
284 case "replace":
285 profile.SystemPromptMode = "replace"
286 default:
287 return nil, fmt.Errorf("systemPromptMode must be append or replace")
288 }
289 }
290 if raw, ok := values["inheritprojectcontext"]; ok {
291 value, parsed := parseOptionalBool(raw)
292 if !parsed {
293 return nil, fmt.Errorf("inheritProjectContext must be true or false")
294 }
295 profile.InheritProjectContext = &value
296 }
297 if raw, ok := values["inheritskills"]; ok {
298 value, parsed := parseOptionalBool(raw)
299 if !parsed {
300 return nil, fmt.Errorf("inheritSkills must be true or false")
301 }
302 profile.InheritSkills = &value
303 }
304 if raw, ok := values["fastmode"]; ok {
305 value, parsed := parseOptionalBool(raw)
306 if !parsed {
307 return nil, fmt.Errorf("fastMode must be true or false")
308 }
309 profile.FastMode = &value
310 }
311 return profile, nil
312}
313
314func splitFrontmatter(raw string) (front, body string, err error) {
315 rest := strings.TrimLeft(raw, " \t\r\n")
316 firstEnd := strings.IndexByte(rest, '\n')
317 if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" {
318 return "", raw, nil
319 }
320 rest = rest[firstEnd+1:]
321 for offset := 0; offset <= len(rest); {
322 rel := strings.Index(rest[offset:], "\n---")
323 if rel < 0 {
324 // A document that starts a frontmatter block but never closes
325 // it is malformed, not a body-only profile.
326 return "", "", fmt.Errorf("frontmatter: missing closing delimiter")
327 }
328 end := offset + rel
329 after := end + len("\n---")
330 if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {
331 return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n"), nil
332 }
333 offset = after
334 }
335 return "", raw, nil
336}
337
338func parseFrontmatter(front string) (map[string]string, map[string][]string) {
339 values := make(map[string]string)
340 lists := make(map[string][]string)
341 lines := strings.Split(front, "\n")
342 for i := 0; i < len(lines); i++ {
343 line := lines[i]
344 trimmed := strings.TrimSpace(line)
345 if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
346 continue
347 }
348 colon := strings.IndexByte(trimmed, ':')
349 if colon < 0 {
350 continue
351 }
352 key := normalizeKey(trimmed[:colon])
353 value := strings.TrimSpace(trimmed[colon+1:])
354 if value == "" {
355 var items []string
356 for j := i + 1; j < len(lines); j++ {
357 next := strings.TrimSpace(lines[j])
358 if next == "" {
359 continue
360 }
361 if !strings.HasPrefix(next, "-") || (len(lines[j]) > 0 && !strings.HasPrefix(lines[j], " ") && !strings.HasPrefix(lines[j], "\t")) {
362 break
363 }
364 items = append(items, unquote(strings.TrimSpace(strings.TrimPrefix(next, "-"))))
365 i = j
366 }
367 if key == "tools" || len(items) > 0 {
368 lists[key] = items
369 }
370 continue
371 }
372 if key == "tools" {
373 lists[key] = parseList(value)
374 continue
375 }
376 values[key] = unquote(value)
377 }
378 return values, lists
379}
380
381func normalizeKey(key string) string {
382 key = strings.ToLower(strings.TrimSpace(key))
383 key = strings.ReplaceAll(key, "-", "")
384 key = strings.ReplaceAll(key, "_", "")
385 return key
386}
387
388func parseList(value string) []string {
389 value = strings.TrimSpace(value)
390 value = strings.TrimPrefix(value, "[")
391 value = strings.TrimSuffix(value, "]")
392 if value == "" {
393 return nil
394 }
395 parts := strings.Split(value, ",")
396 out := make([]string, 0, len(parts))
397 for _, part := range parts {
398 if item := strings.TrimSpace(unquote(part)); item != "" {
399 out = append(out, item)
400 }
401 }
402 return out
403}
404
405func parseThinkingValue(value, field string) (string, error) {
406 switch strings.ToLower(strings.TrimSpace(unquote(value))) {
407 case "":
408 return "", nil
409 case "off":
410 return "off", nil
411 case "minimal", "minimum":
412 return "minimum", nil
413 case "low", "medium", "high", "xhigh", "max":
414 return strings.ToLower(strings.TrimSpace(unquote(value))), nil
415 case "maximum":
416 return "xhigh", nil
417 default:
418 return "", fmt.Errorf("%s must be off|minimum|low|medium|high|xhigh|max", field)
419 }
420}
421
422func parseOptionalBool(value string) (bool, bool) {
423 parsed, err := strconv.ParseBool(strings.TrimSpace(unquote(value)))
424 return parsed, err == nil
425}
426
427func validName(name string) bool {
428 name = strings.TrimSpace(name)
429 if name == "" || len(name) > 128 {
430 return false
431 }
432 for _, r := range name {
433 if r == '/' || r == '\\' || r == '\x00' || r < 0x20 {
434 return false
435 }
436 }
437 return true
438}
439
440func manifestValue(value string) string {
441 return strings.Join(strings.Fields(value), " ")
442}
443
444func unquote(value string) string {
445 value = strings.TrimSpace(value)
446 if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
447 return value[1 : len(value)-1]
448 }
449 return value
450}