Support AGENTS.md context files
- commit
- 9f0629bcaf456dbddba36048ca13a7ecca949846
- author
- patriceckhart · · 2026-05-09 16:37 UTC
- parent
- 25cb7d5
- signature
- unsigned
- refs
- tag: v0.1.71
2 files · +127 -0
M internal/agent/build.go +80 -0
| @@ -278,6 +278,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 278 | 278 | summaries := toolSummaries(reg, args) | |
| 279 | 279 | ||
| 280 | 280 | append_ := append([]string(nil), args.AppendSystemPrompt...) | |
| 281 | + | if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" { | |
| 282 | + | append_ = append(append_, agentsAddendum) | |
| 283 | + | } | |
| 281 | 284 | if skillAddendum != "" { | |
| 282 | 285 | append_ = append(append_, skillAddendum) | |
| 283 | 286 | } | |
| @@ -340,6 +343,83 @@ func readUserSystemPrompt(zotHome string) string { | |||
| 340 | 343 | return strings.TrimSpace(string(raw)) | |
| 341 | 344 | } | |
| 342 | 345 | ||
| 346 | + | // readAgentsContext loads optional AGENTS.md instruction files. No | |
| 347 | + | // default file is created or required: zot only includes files that | |
| 348 | + | // already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first, | |
| 349 | + | // followed by project instructions from the top-most parent down to cwd. | |
| 350 | + | func readAgentsContext(cwd, zotHome string) string { | |
| 351 | + | type contextFile struct { | |
| 352 | + | path string | |
| 353 | + | content string | |
| 354 | + | } | |
| 355 | + | var files []contextFile | |
| 356 | + | seen := map[string]bool{} | |
| 357 | + | add := func(path string) { | |
| 358 | + | if path == "" { | |
| 359 | + | return | |
| 360 | + | } | |
| 361 | + | abs, err := filepath.Abs(path) | |
| 362 | + | if err == nil { | |
| 363 | + | path = abs | |
| 364 | + | } | |
| 365 | + | if seen[path] { | |
| 366 | + | return | |
| 367 | + | } | |
| 368 | + | raw, err := os.ReadFile(path) | |
| 369 | + | if err != nil { | |
| 370 | + | return | |
| 371 | + | } | |
| 372 | + | content := strings.TrimSpace(string(raw)) | |
| 373 | + | if content == "" { | |
| 374 | + | return | |
| 375 | + | } | |
| 376 | + | seen[path] = true | |
| 377 | + | files = append(files, contextFile{path: path, content: content}) | |
| 378 | + | } | |
| 379 | + | addFirstFromDir := func(dir string) { | |
| 380 | + | if dir == "" { | |
| 381 | + | return | |
| 382 | + | } | |
| 383 | + | for _, name := range []string{"AGENTS.md", "AGENTS.MD"} { | |
| 384 | + | path := filepath.Join(dir, name) | |
| 385 | + | if _, err := os.Stat(path); err == nil { | |
| 386 | + | add(path) | |
| 387 | + | return | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | addFirstFromDir(zotHome) | |
| 393 | + | ||
| 394 | + | if cwd != "" { | |
| 395 | + | abs, err := filepath.Abs(cwd) | |
| 396 | + | if err == nil { | |
| 397 | + | cwd = abs | |
| 398 | + | } | |
| 399 | + | var dirs []string | |
| 400 | + | for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) { | |
| 401 | + | dirs = append(dirs, dir) | |
| 402 | + | parent := filepath.Dir(dir) | |
| 403 | + | if parent == dir { | |
| 404 | + | break | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | for i := len(dirs) - 1; i >= 0; i-- { | |
| 408 | + | addFirstFromDir(dirs[i]) | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | if len(files) == 0 { | |
| 413 | + | return "" | |
| 414 | + | } | |
| 415 | + | var sb strings.Builder | |
| 416 | + | 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") | |
| 417 | + | for _, f := range files { | |
| 418 | + | fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content) | |
| 419 | + | } | |
| 420 | + | return strings.TrimSpace(sb.String()) | |
| 421 | + | } | |
| 422 | + | ||
| 343 | 423 | // descMapFromSummaries indexes the human-readable descriptions for | |
| 344 | 424 | // the renderToolsSection rebuild path. | |
| 345 | 425 | func descMapFromSummaries(summaries []ToolSummary) map[string]string { | |
| @@ -278,6 +278,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 278 | summaries := toolSummaries(reg, args) | 278 | summaries := toolSummaries(reg, args) | ||
| 279 | 279 | ||||
| 280 | append_ := append([]string(nil), args.AppendSystemPrompt...) | 280 | append_ := append([]string(nil), args.AppendSystemPrompt...) | ||
| 281 | + | if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" { | |||
| 282 | + | append_ = append(append_, agentsAddendum) | |||
| 283 | + | } | |||
| 281 | if skillAddendum != "" { | 284 | if skillAddendum != "" { | ||
| 282 | append_ = append(append_, skillAddendum) | 285 | append_ = append(append_, skillAddendum) | ||
| 283 | } | 286 | } | ||
| @@ -340,6 +343,83 @@ func readUserSystemPrompt(zotHome string) string { | |||||
| 340 | return strings.TrimSpace(string(raw)) | 343 | return strings.TrimSpace(string(raw)) | ||
| 341 | } | 344 | } | ||
| 342 | 345 | ||||
| 346 | + | // readAgentsContext loads optional AGENTS.md instruction files. No | |||
| 347 | + | // default file is created or required: zot only includes files that | |||
| 348 | + | // already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first, | |||
| 349 | + | // followed by project instructions from the top-most parent down to cwd. | |||
| 350 | + | func readAgentsContext(cwd, zotHome string) string { | |||
| 351 | + | type contextFile struct { | |||
| 352 | + | path string | |||
| 353 | + | content string | |||
| 354 | + | } | |||
| 355 | + | var files []contextFile | |||
| 356 | + | seen := map[string]bool{} | |||
| 357 | + | add := func(path string) { | |||
| 358 | + | if path == "" { | |||
| 359 | + | return | |||
| 360 | + | } | |||
| 361 | + | abs, err := filepath.Abs(path) | |||
| 362 | + | if err == nil { | |||
| 363 | + | path = abs | |||
| 364 | + | } | |||
| 365 | + | if seen[path] { | |||
| 366 | + | return | |||
| 367 | + | } | |||
| 368 | + | raw, err := os.ReadFile(path) | |||
| 369 | + | if err != nil { | |||
| 370 | + | return | |||
| 371 | + | } | |||
| 372 | + | content := strings.TrimSpace(string(raw)) | |||
| 373 | + | if content == "" { | |||
| 374 | + | return | |||
| 375 | + | } | |||
| 376 | + | seen[path] = true | |||
| 377 | + | files = append(files, contextFile{path: path, content: content}) | |||
| 378 | + | } | |||
| 379 | + | addFirstFromDir := func(dir string) { | |||
| 380 | + | if dir == "" { | |||
| 381 | + | return | |||
| 382 | + | } | |||
| 383 | + | for _, name := range []string{"AGENTS.md", "AGENTS.MD"} { | |||
| 384 | + | path := filepath.Join(dir, name) | |||
| 385 | + | if _, err := os.Stat(path); err == nil { | |||
| 386 | + | add(path) | |||
| 387 | + | return | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | addFirstFromDir(zotHome) | |||
| 393 | + | ||||
| 394 | + | if cwd != "" { | |||
| 395 | + | abs, err := filepath.Abs(cwd) | |||
| 396 | + | if err == nil { | |||
| 397 | + | cwd = abs | |||
| 398 | + | } | |||
| 399 | + | var dirs []string | |||
| 400 | + | for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) { | |||
| 401 | + | dirs = append(dirs, dir) | |||
| 402 | + | parent := filepath.Dir(dir) | |||
| 403 | + | if parent == dir { | |||
| 404 | + | break | |||
| 405 | + | } | |||
| 406 | + | } | |||
| 407 | + | for i := len(dirs) - 1; i >= 0; i-- { | |||
| 408 | + | addFirstFromDir(dirs[i]) | |||
| 409 | + | } | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | if len(files) == 0 { | |||
| 413 | + | return "" | |||
| 414 | + | } | |||
| 415 | + | var sb strings.Builder | |||
| 416 | + | 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") | |||
| 417 | + | for _, f := range files { | |||
| 418 | + | fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content) | |||
| 419 | + | } | |||
| 420 | + | return strings.TrimSpace(sb.String()) | |||
| 421 | + | } | |||
| 422 | + | ||||
| 343 | // descMapFromSummaries indexes the human-readable descriptions for | 423 | // descMapFromSummaries indexes the human-readable descriptions for | ||
| 344 | // the renderToolsSection rebuild path. | 424 | // the renderToolsSection rebuild path. | ||
| 345 | func descMapFromSummaries(summaries []ToolSummary) map[string]string { | 425 | func descMapFromSummaries(summaries []ToolSummary) map[string]string { | ||
A internal/agent/build_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |
| 11 | + | root := t.TempDir() | |
| 12 | + | zotHome := filepath.Join(root, "zot-home") | |
| 13 | + | project := filepath.Join(root, "repo") | |
| 14 | + | nested := filepath.Join(project, "packages", "app") | |
| 15 | + | if err := os.MkdirAll(zotHome, 0o755); err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | if err := os.MkdirAll(nested, 0o755); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | got := readAgentsContext(nested, zotHome) | |
| 32 | + | for _, want := range []string{"global rule", "repo rule", "app rule"} { | |
| 33 | + | if !strings.Contains(got, want) { | |
| 34 | + | t.Fatalf("readAgentsContext missing %q in:\n%s", want, got) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") { | |
| 38 | + | t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |
| 43 | + | got := readAgentsContext(t.TempDir(), t.TempDir()) | |
| 44 | + | if got != "" { | |
| 45 | + | t.Fatalf("expected no context, got %q", got) | |
| 46 | + | } | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) { | |||
| 11 | + | root := t.TempDir() | |||
| 12 | + | zotHome := filepath.Join(root, "zot-home") | |||
| 13 | + | project := filepath.Join(root, "repo") | |||
| 14 | + | nested := filepath.Join(project, "packages", "app") | |||
| 15 | + | if err := os.MkdirAll(zotHome, 0o755); err != nil { | |||
| 16 | + | t.Fatal(err) | |||
| 17 | + | } | |||
| 18 | + | if err := os.MkdirAll(nested, 0o755); err != nil { | |||
| 19 | + | t.Fatal(err) | |||
| 20 | + | } | |||
| 21 | + | if err := os.WriteFile(filepath.Join(zotHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | got := readAgentsContext(nested, zotHome) | |||
| 32 | + | for _, want := range []string{"global rule", "repo rule", "app rule"} { | |||
| 33 | + | if !strings.Contains(got, want) { | |||
| 34 | + | t.Fatalf("readAgentsContext missing %q in:\n%s", want, got) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") { | |||
| 38 | + | t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |||
| 43 | + | got := readAgentsContext(t.TempDir(), t.TempDir()) | |||
| 44 | + | if got != "" { | |||
| 45 | + | t.Fatalf("expected no context, got %q", got) | |||
| 46 | + | } | |||
| 47 | + | } | |||