Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md).

## Unreleased

- **AGENTS.md is now a chain from repo root to workdir
(`LoadContext`).** Previously only `<workDir>/AGENTS.md` loaded;
in a monorepo, running from a subdirectory lost the repo-wide
instructions (or vice versa). Every AGENTS.md from the repository
root (nearest ancestor with `.git`) down to the workdir is
concatenated root-first, so nearer files read as refinements.
Outside a repository the old single-file behavior is unchanged.
(#369)

- **Actionable model-unavailable errors
(`providers.ModelUnavailableHint`, `Factory.ModelsListURL`).** A 404
/ model-not-found failure previously surfaced as a raw provider
Expand Down
73 changes: 67 additions & 6 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,28 @@ type ProjectContext struct {
Roles map[string]Role
}

// LoadContext loads AGENTS.md (non-fatal if missing) and skills under
// `<workDir>/.agents/skills/<name>/SKILL.md`. An empty workDir returns an
// empty context.
// LoadContext loads the AGENTS.md chain (non-fatal if missing) and
// skills under `<workDir>/.agents/skills/<name>/SKILL.md`. An empty
// workDir returns an empty context.
//
// AGENTS.md is a chain, not a single file: every AGENTS.md from the
// repository root (the nearest ancestor containing `.git`, workDir
// included) down to workDir is concatenated, root first and nearest
// last — so in a monorepo a subdirectory can add local instructions on
// top of the repo-wide ones, and later (nearer) files read naturally
// as refinements. Outside a repository only `<workDir>/AGENTS.md` is
// consulted, matching the old single-file behavior.
func LoadContext(workDir string) (ProjectContext, error) {
var ctx ProjectContext
if strings.TrimSpace(workDir) == "" {
return ctx, nil
}

if data, err := os.ReadFile(filepath.Join(workDir, "AGENTS.md")); err == nil {
ctx.AgentsMD = strings.TrimSpace(string(data))
} else if !errors.Is(err, os.ErrNotExist) {
agentsMD, err := loadAgentsMDChain(workDir)
if err != nil {
return ProjectContext{}, err
}
ctx.AgentsMD = agentsMD

skills, err := loadSkills(filepath.Join(workDir, ".agents", "skills"))
if err != nil {
Expand All @@ -67,6 +75,59 @@ func LoadContext(workDir string) (ProjectContext, error) {
return ctx, nil
}

// loadAgentsMDChain concatenates every AGENTS.md from the repo root
// down to workDir (root first). Missing files are skipped; read errors
// other than not-exist propagate.
func loadAgentsMDChain(workDir string) (string, error) {
abs, err := filepath.Abs(workDir)
if err != nil {
return "", err
}

// The chain's top is the nearest ancestor (workDir included) that
// contains .git; without one the chain is workDir alone.
top := abs
for dir := abs; ; {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
top = dir
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}

var chain []string
for dir := abs; ; {
chain = append([]string{dir}, chain...)
if dir == top {
break
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}

var parts []string
for _, dir := range chain {
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return "", err
}
if text := strings.TrimSpace(string(data)); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n\n"), nil
}

func loadRoles(rolesDir string) (map[string]Role, error) {
entries, err := os.ReadDir(rolesDir)
if errors.Is(err, os.ErrNotExist) {
Expand Down
58 changes: 58 additions & 0 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,61 @@ func TestSessionSkillProgrammaticEntryWins(t *testing.T) {
t.Fatalf("user = %q, want programmatic 'from code' to win over disk", user)
}
}

func TestLoadContextAgentsMDChain(t *testing.T) {
root := t.TempDir()
sub := filepath.Join(root, "services", "api")
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("repo-wide rules"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "AGENTS.md"), []byte("api-local rules"), 0o644); err != nil {
t.Fatal(err)
}

ctx, err := LoadContext(sub)
if err != nil {
t.Fatalf("LoadContext: %v", err)
}
if ctx.AgentsMD != "repo-wide rules\n\napi-local rules" {
t.Fatalf("AgentsMD = %q, want root-first concatenation", ctx.AgentsMD)
}

// Intermediate directory without AGENTS.md is skipped silently;
// a workDir at the repo root sees only the root file.
ctx, err = LoadContext(root)
if err != nil {
t.Fatalf("LoadContext(root): %v", err)
}
if ctx.AgentsMD != "repo-wide rules" {
t.Fatalf("AgentsMD at root = %q", ctx.AgentsMD)
}
}

func TestLoadContextAgentsMDNoRepo(t *testing.T) {
// No .git anywhere in the temp tree: chain is workDir alone, so a
// parent AGENTS.md must NOT leak in.
parent := t.TempDir()
work := filepath.Join(parent, "w")
if err := os.MkdirAll(work, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(parent, "AGENTS.md"), []byte("parent rules"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(work, "AGENTS.md"), []byte("local rules"), 0o644); err != nil {
t.Fatal(err)
}
ctx, err := LoadContext(work)
if err != nil {
t.Fatalf("LoadContext: %v", err)
}
if ctx.AgentsMD != "local rules" {
t.Fatalf("AgentsMD = %q, want only the local file outside a repo", ctx.AgentsMD)
}
}
Loading