v0.51.0 a9d674e

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

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