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