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