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 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")
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}