v0.29.1 d92a60f

profiles.go RawHistoryFolder

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, fast-mode preference, and budget override 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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close