diff --git a/internal/agent/build.go b/internal/agent/build.go index b06df5c..652cc48 100644 --- a/internal/agent/build.go +++ b/internal/agent/build.go @@ -278,6 +278,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { summaries := toolSummaries(reg, args) append_ := append([]string(nil), args.AppendSystemPrompt...) + if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" { + append_ = append(append_, agentsAddendum) + } if skillAddendum != "" { append_ = append(append_, skillAddendum) } @@ -340,6 +343,83 @@ func readUserSystemPrompt(zotHome string) string { return strings.TrimSpace(string(raw)) } +// readAgentsContext loads optional AGENTS.md instruction files. No +// default file is created or required: zot only includes files that +// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first, +// followed by project instructions from the top-most parent down to cwd. +func readAgentsContext(cwd, zotHome string) string { + type contextFile struct { + path string + content string + } + var files []contextFile + seen := map[string]bool{} + add := func(path string) { + if path == "" { + return + } + abs, err := filepath.Abs(path) + if err == nil { + path = abs + } + if seen[path] { + return + } + raw, err := os.ReadFile(path) + if err != nil { + return + } + content := strings.TrimSpace(string(raw)) + if content == "" { + return + } + seen[path] = true + files = append(files, contextFile{path: path, content: content}) + } + addFirstFromDir := func(dir string) { + if dir == "" { + return + } + for _, name := range []string{"AGENTS.md", "AGENTS.MD"} { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + add(path) + return + } + } + } + + addFirstFromDir(zotHome) + + if cwd != "" { + abs, err := filepath.Abs(cwd) + if err == nil { + cwd = abs + } + var dirs []string + for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) { + dirs = append(dirs, dir) + parent := filepath.Dir(dir) + if parent == dir { + break + } + } + for i := len(dirs) - 1; i >= 0; i-- { + addFirstFromDir(dirs[i]) + } + } + + if len(files) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n") + for _, f := range files { + fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content) + } + return strings.TrimSpace(sb.String()) +} + // descMapFromSummaries indexes the human-readable descriptions for // the renderToolsSection rebuild path. func descMapFromSummaries(summaries []ToolSummary) map[string]string { diff --git a/internal/agent/build_test.go b/internal/agent/build_test.go new file mode 100644 index 0000000..9358b48 --- /dev/null +++ b/internal/agent/build_test.go @@ -0,0 +1,47 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { + root := t.TempDir() + zotHome := filepath.Join(root, "zot-home") + project := filepath.Join(root, "repo") + nested := filepath.Join(project, "packages", "app") + if err := os.MkdirAll(zotHome, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil { + t.Fatal(err) + } + + got := readAgentsContext(nested, zotHome) + for _, want := range []string{"global rule", "repo rule", "app rule"} { + if !strings.Contains(got, want) { + t.Fatalf("readAgentsContext missing %q in:\n%s", want, got) + } + } + if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") { + t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got) + } +} + +func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { + got := readAgentsContext(t.TempDir(), t.TempDir()) + if got != "" { + t.Fatalf("expected no context, got %q", got) + } +}