refactor(agent/agentcontext): fixed-location shallow context discovery (#26596)

## Problem

`agent/agentcontext` resolved workspace context by walking the working
directory recursively (depth 8) and matching files by basename. This
over-injected context:

- Instruction-file matching was case-insensitive, so the generated
`docs/reference/api/agents.md` was treated as an instruction file.
- Symlinked instruction files (`CLAUDE.md`, `.cursorrules` ->
`AGENTS.md`) shipped as duplicate resources.
- Nested `AGENTS.md` (e.g. `site/AGENTS.md`) were collected from
anywhere in the tree.
- Skills were discovered from *any* `skills/` directory anywhere in the
tree, and `.mcp.json` from any depth.

Resolving the repo root produced six instruction sources for what was
effectively one file of guidance, plus skills/MCP found by an open-ended
walk.

## What changed

Replace the recursive scan with **fixed-location, shallow** discovery.
Each scan root is inspected at its top level only: the resolver never
descends into subdirectories and never climbs to a parent. Additional
directories are added explicitly as sources (HTTP API) or via the
`CODER_AGENT_EXP_*_DIRS` seeding env vars.

- **Single working-dir scan root.** The working directory is one scan
root. Instruction files and `.mcp.json` are read only at its top level.
- **Fixed-location skills.** Skills are discovered only from `skills`,
`.agents/skills`, `.claude/skills`, `.codex/skills` (one skill per
immediate subdir with a `SKILL.md`), not from arbitrary `skills/`
directories.
- **Case-sensitive instruction names.** Exact
`AGENTS.md`/`CLAUDE.md`/`.cursorrules`; a lower-case `agents.md` is
ignored.
- **Symlink dedup.** Resources are attributed to their resolved target,
so symlinked `CLAUDE.md`/`.cursorrules` collapse into the single
`AGENTS.md`.
- **Watcher** mirrors the same fixed-location set instead of recursively
watching every scan root (no more walking `node_modules`).
- The recursive `walkDir`, `skipDirNames`, `MaxScanDepth`, and
`isSkillsContainer` are removed.

Resolving the repo root now yields `AGENTS.md`, `.mcp.json`, and the
`.agents/skills`/`.claude/skills` skills, with no nested
instruction-file noise.

## Behavior change

`site/AGENTS.md` is no longer auto-injected when the working dir is the
repo root. It loads when the working dir **is** `site/` (its top level),
or when `site/` is added as an explicit source. There is intentionally
**no walk-up** to a `.git` project root: an agent started in a
subdirectory does not auto-inherit ancestor `AGENTS.md`; those
directories are added explicitly.

<details>
<summary>Verification &amp; decision log</summary>

**codex research (confirmed via source).** Instruction files: codex
walks up to the first `.git` ancestor and reads root-&gt;cwd, one file
per directory, exact-cased names (`codex-rs/core/src/agents_md.rs`).
Skills: fixed roots (`.agents/skills`, `.codex/skills`,
`$CODEX_HOME/skills`, ...) with bounded in-root recursion
(`core-skills/src/loader.rs`). MCP: `.codex/config.toml` via walk-up; a
project `.mcp.json` is not a runtime source in codex. No resource type
triggers an unbounded downward walk.

**Decisions.**
- Adopt codex's fixed-location, shallow discovery (case-sensitive names,
symlink dedup, top-level-only files, container-only skills).
- **Deliberately omit codex's walk-up to the `.git` project root.** In
Coder the working dir is the scan root and extra directories are added
explicitly (HTTP API / `CODER_AGENT_EXP_*_DIRS`), so the implicit
ancestor climb added surprise without benefit (e.g. `context add ./site`
should scan `./site`, not the repo root).
- Keep `.mcp.json` (codex uses `config.toml`, intentionally not added).
- Include `.claude/skills` and `.codex/skills` in the container list so
the repo's existing `.claude/skills` skills are not regressed; skills
recurse one level inside a container.

**Tests.** `TestManager_WorkingDirScannedShallow` (working dir read at
top level; ancestor root and nested subdir both excluded);
`TestResolver_SkillsOnlyFromFixedContainers`,
`TestResolver_MCPConfigOnlyAtScanRoot`,
`TestResolver_SymlinkedInstructionFilesDeduplicated`,
`TestResolver_InstructionFilesOnlyAtScanRoot`,
`TestResolver_InstructionNamesAreCaseSensitive`. Cap tests use multiple
scan roots.

**Local checks.** `gofmt`, `go vet`, `golangci-lint`, `go test -race`,
and `make lint/emdash` pass for the package.
</details>

---
🤖 Generated by Coder Agents on behalf of @kylecarbs.
This commit is contained in:
Kyle Carberry
2026-06-23 03:42:40 +00:00
committed by GitHub
parent 688ee63f9d
commit 0f37522e6f
6 changed files with 351 additions and 264 deletions
+11 -6
View File
@@ -6,12 +6,17 @@
// "RFC: Workspace Context Sources for Coder Agents". It owns:
//
// - User-declared scan roots (Sources) layered on top of
// built-in defaults.
// - A resolver that classifies files under each scan root into
// typed Resources (instruction files, skills, MCP configs,
// MCP servers).
// - A unified recursive fsnotify watcher that signals a
// re-resolve when any recognized file changes.
// built-in defaults and the working directory.
// - A resolver that classifies files at fixed locations under
// each scan root into typed Resources (instruction files,
// skills, MCP configs, MCP servers). Discovery is shallow:
// instruction files (AGENTS.md, CLAUDE.md, .cursorrules) and
// .mcp.json are read only at a scan root's top level, skills
// only from fixed container directories (skills, .agents/skills,
// .claude/skills, .codex/skills), and the resolver never walks
// the tree downward or up to a parent directory.
// - A fixed-location fsnotify watcher that signals a re-resolve
// when any recognized file changes.
// - An HTTP API at /api/v0/context/sources for source CRUD
// and /api/v0/context/resync for synchronous push barriers.
// - A Pusher abstraction so the latest Snapshot can be shipped
+7 -1
View File
@@ -219,7 +219,6 @@ func (m *Manager) Run(ctx context.Context) error {
Logger: m.logger.Named("watcher"),
Clock: m.clock,
Debounce: m.debounce,
MaxDepth: m.resolver.MaxDepth,
OnChange: m.signal,
})
if err != nil {
@@ -561,6 +560,13 @@ func (m *Manager) scanRootsLocked() []ScanRoot {
out := make([]ScanRoot, 0, 1+len(builtinRoots)+len(m.sources))
if m.workingDir != nil {
if wd := strings.TrimSpace(m.workingDir()); wd != "" {
// The working directory is a single scan root. The
// resolver reads its top-level instruction files and
// .mcp.json plus the fixed skill containers under it;
// it neither descends into subdirectories nor climbs
// to parent directories. Additional directories are
// added explicitly as Sources or via the seeding env
// vars.
out = append(out, ScanRoot{Path: wd})
}
}
+31
View File
@@ -437,3 +437,34 @@ func TestManager_MCPResourcesAppliesToSnapshot(t *testing.T) {
}
require.True(t, found, "expected MCP server resource in snapshot")
}
// TestManager_WorkingDirScannedShallow confirms the working
// directory is a single scan root: its top-level instruction files
// are read, but the resolver neither climbs to an ancestor (no
// walk-up to a .git project root) nor descends into subdirectories.
func TestManager_WorkingDirScannedShallow(t *testing.T) {
t.Parallel()
root := testutil.TempDirResolved(t)
require.NoError(t, os.MkdirAll(filepath.Join(root, ".git"), 0o755))
mustWriteFile(t, filepath.Join(root, "AGENTS.md"), "root rules")
cwd := filepath.Join(root, "service")
require.NoError(t, os.MkdirAll(cwd, 0o755))
mustWriteFile(t, filepath.Join(cwd, "AGENTS.md"), "service rules")
// A subdirectory below the working dir must not be descended.
mustWriteFile(t, filepath.Join(cwd, "nested", "AGENTS.md"), "nested rules")
m := newTestManager(t, agentcontext.ManagerOptions{
WorkingDir: func() string { return cwd },
})
snap := m.Snapshot()
var sources []string
for _, r := range snap.Resources {
if r.Kind == agentcontext.KindInstructionFile {
sources = append(sources, r.Source)
}
}
// Only the working directory's own AGENTS.md is present: the
// ancestor root and the nested subdirectory are both excluded.
require.Equal(t, []string{filepath.Join(cwd, "AGENTS.md")}, sources)
}
+144 -171
View File
@@ -5,7 +5,6 @@ import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
@@ -36,57 +35,74 @@ const (
// DefaultMaxResources is the resource count cap. Resources
// past this cap are emitted with Status == StatusExcluded.
DefaultMaxResources = 500
// DefaultMaxScanDepth bounds how deep the recursive walk
// descends from each scan root. The default avoids runaway
// scans in node_modules / vendor / .git trees while still
// covering realistic monorepo layouts.
DefaultMaxScanDepth = 8
)
// File-name conventions recognized by the v1 resolver.
var (
// instructionFileNames are picked up from any scan root.
// Matching is case-insensitive on the basename.
// instructionFileNames are picked up from the top level of a
// scan root. Matching is case-sensitive on the basename,
// mirroring codex: it keys on the exact name "AGENTS.md" and
// never case-folds, so a lower-case agents.md (for example a
// generated API reference doc) is not mistaken for an
// instruction file.
instructionFileNames = []string{
"AGENTS.md",
"CLAUDE.md",
".cursorrules",
}
// mcpConfigFileName is recognized at any depth under a
// scan root.
// mcpConfigFileName is recognized at the top level of a scan
// root only, not at arbitrary depth.
mcpConfigFileName = ".mcp.json"
// skillMetaFileName is the file inside a skill directory
// that carries the skill front-matter.
skillMetaFileName = "SKILL.md"
)
// skipDirNames are directory basenames that the recursive walk
// never descends into. The list mirrors what most language
// tool-chains treat as opaque.
var skipDirNames = map[string]struct{}{
".git": {},
".hg": {},
".svn": {},
"node_modules": {},
"vendor": {},
"target": {},
"dist": {},
"build": {},
".venv": {},
"__pycache__": {},
// skillContainerRelPaths are the directories, relative to a scan
// root, under which skills are discovered. A skill is an immediate
// subdirectory of a container that holds a SKILL.md. The list
// covers the cross-tool conventions Coder supports; codex itself
// uses .agents/skills and .codex/skills.
var skillContainerRelPaths = []string{
"skills",
filepath.Join(".agents", "skills"),
filepath.Join(".claude", "skills"),
filepath.Join(".codex", "skills"),
}
// recognizedInstructionFile reports whether name is one of the
// instruction-file conventions, case-insensitively.
// instruction-file conventions. Matching is case-sensitive:
// codex keys on the exact basename "AGENTS.md", so a lower-case
// agents.md is intentionally not recognized.
func recognizedInstructionFile(name string) bool {
for _, candidate := range instructionFileNames {
if strings.EqualFold(name, candidate) {
if name == candidate {
return true
}
}
return false
}
// skillContainersFor returns the existing skill-container
// directories reachable from rootPath without recursing the tree:
// rootPath itself when it is already a "skills" directory, plus
// each skillContainerRelPaths entry that exists. Skills live in
// the immediate children of a container, so the resolver and the
// watcher both stop here.
func skillContainersFor(rootPath string) []string {
var out []string
if filepath.Base(rootPath) == "skills" {
out = append(out, rootPath)
}
for _, rel := range skillContainerRelPaths {
container := filepath.Join(rootPath, rel)
if info, err := os.Stat(container); err == nil && info.IsDir() {
out = append(out, container)
}
}
return out
}
// Resolver walks one or more scan roots and produces a snapshot
// of every recognized resource it finds. The Resolver is
// stateless; the Manager owns the scan-root list and orchestrates
@@ -101,9 +117,6 @@ type Resolver struct {
// MaxResources caps the resource count. Use
// DefaultMaxResources if zero.
MaxResources int
// MaxDepth caps the directory walk depth. Use
// DefaultMaxScanDepth if zero.
MaxDepth int
// MCPResources, when non-nil, is consulted after the
// filesystem pass and returns the KindMCPServer resources
// for live MCP servers. It must not block: the resolver
@@ -202,15 +215,19 @@ func (r *Resolver) normalize() *Resolver {
if out.MaxResources == 0 {
out.MaxResources = DefaultMaxResources
}
if out.MaxDepth == 0 {
out.MaxDepth = DefaultMaxScanDepth
}
return &out
}
// walk traverses every scan root and produces an unordered
// resource list. Aggregate caps are applied separately. The ctx
// is checked between roots so callers can bail out promptly.
// walk visits every scan root and produces an unordered resource
// list. Aggregate caps are applied separately. The ctx is checked
// between roots so callers can bail out promptly.
//
// Discovery is deliberately shallow. For each scan root the
// resolver inspects only that directory's top level (instruction
// files and .mcp.json) plus a fixed set of skill-container
// locations under it. It never descends into subdirectories and
// never climbs to a parent directory; additional directories must
// be added explicitly as scan roots.
func (r *Resolver) walk(ctx context.Context, roots []ScanRoot) (resources []Resource, snapErrs []string) {
// Dedup roots by canonical path. The first occurrence
// wins so user-added roots that overlap with a built-in
@@ -228,131 +245,89 @@ func (r *Resolver) walk(ctx context.Context, roots []ScanRoot) (resources []Reso
dedup = append(dedup, root)
}
// Deduplicate resources across roots by ID. Without this,
// a built-in root and a user root that both cover the
// same project tree would double-count AGENTS.md.
// Deduplicate resources across roots by ID so two roots that
// resolve to the same file (e.g. overlapping ancestors, or a
// built-in root nested under a project root) do not
// double-count it.
seenID := make(map[string]struct{})
for _, root := range dedup {
if err := ctx.Err(); err != nil {
return nil, []string{err.Error()}
}
info, err := os.Stat(root.Path)
if err != nil {
// Missing roots silently fall through. The user
// either added a path that does not exist yet or
// removed it later. The watcher will surface
// re-creation as a change event.
continue
}
if !info.IsDir() {
// Single-file roots are classified directly.
if res, ok := r.classifyFile(root.Path, root.Path, info, root.UserSource); ok {
if _, dup := seenID[res.ID]; !dup {
seenID[res.ID] = struct{}{}
resources = append(resources, res)
}
}
continue
}
walkErr := r.walkDir(ctx, root, &resources, seenID)
if walkErr != nil {
snapErrs = append(snapErrs, fmt.Sprintf("walk %q: %s", root.Path, walkErr))
}
r.discoverIn(root, &resources, seenID)
}
return resources, snapErrs
}
// walkDir performs the recursive descent for a single scan
// directory. It honors r.MaxDepth and skipDirNames. The ctx is
// checked inside the WalkDir callback so cancellation
// terminates the walk even mid-root.
func (r *Resolver) walkDir(ctx context.Context, root ScanRoot, out *[]Resource, seenID map[string]struct{}) error {
rootDepth := strings.Count(filepath.Clean(root.Path), string(os.PathSeparator))
maxDepth := rootDepth + r.MaxDepth
return filepath.WalkDir(root.Path, func(path string, d fs.DirEntry, err error) error {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
// discoverIn inspects a single scan root. A root that points at a
// file is classified directly. A directory root contributes its
// top-level instruction files and .mcp.json plus skills from the
// fixed container locations under it. The walk goes no deeper.
func (r *Resolver) discoverIn(root ScanRoot, out *[]Resource, seenID map[string]struct{}) {
info, err := os.Stat(root.Path)
if err != nil {
// Missing roots silently fall through. The user either
// added a path that does not exist yet or removed it
// later; the watcher surfaces re-creation as a change.
return
}
if !info.IsDir() {
if res, ok := r.classifyFile(root.Path, root.Path, info, root.UserSource); ok {
appendResource(out, seenID, res)
}
if err != nil {
// Surface the error as Unreadable when we can
// associate it with a single recognized file;
// otherwise let the walk continue.
if d != nil && !d.IsDir() {
kind, recognized := kindFromFilename(d.Name())
if recognized {
res := Resource{
ID: resourceID(kind, path),
Kind: kind,
Source: path,
SizeBytes: 0,
Status: StatusUnreadable,
Error: err.Error(),
SourcePath: root.UserSource,
}
if _, dup := seenID[res.ID]; !dup {
seenID[res.ID] = struct{}{}
*out = append(*out, res)
}
}
}
if errors.Is(err, fs.ErrPermission) {
// Permission errors on a directory: skip the
// subtree but continue walking siblings.
if d != nil && d.IsDir() {
return fs.SkipDir
}
}
return nil
}
if d.IsDir() {
if strings.Count(path, string(os.PathSeparator)) > maxDepth {
return fs.SkipDir
}
if _, skip := skipDirNames[d.Name()]; skip && path != root.Path {
return fs.SkipDir
}
// If we are entering a "skills container"
// directory (".agents/skills", "~/.coder/skills",
// "plugins/<plugin>/skills"), eagerly emit skill
// resources for its immediate subdirectories.
if isSkillsContainer(path) {
r.emitSkillsFromContainer(path, root, out, seenID)
}
return nil
}
// Regular file.
info, statErr := d.Info()
if statErr != nil {
return nil
}
if res, ok := r.classifyFile(root.Path, path, info, root.UserSource); ok {
if _, dup := seenID[res.ID]; dup {
return nil
}
seenID[res.ID] = struct{}{}
*out = append(*out, res)
}
return nil
})
return
}
r.discoverTopLevelFiles(root, out, seenID)
for _, container := range skillContainersFor(root.Path) {
r.emitSkillsFromContainer(container, root, out, seenID)
}
}
// kindFromFilename maps a file basename to its ResourceKind.
// recognized=false when the name matches no convention.
func kindFromFilename(name string) (kind ResourceKind, recognized bool) {
switch {
case recognizedInstructionFile(name):
return KindInstructionFile, true
case name == mcpConfigFileName:
return KindMCPConfig, true
case name == skillMetaFileName:
return KindSkill, true
default:
return 0, false
// discoverTopLevelFiles classifies the instruction files and
// .mcp.json that sit directly in root.Path. Nested files are
// ignored: instruction files and MCP configs are recognized only
// at a scan root's top level.
func (r *Resolver) discoverTopLevelFiles(root ScanRoot, out *[]Resource, seenID map[string]struct{}) {
entries, err := os.ReadDir(root.Path)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
isInstruction := recognizedInstructionFile(name)
if !isInstruction && name != mcpConfigFileName {
continue
}
// A directory that happens to share a recognized basename
// is not a resource. resolveReadTarget separately rejects
// symlinks whose targets are not regular files.
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
path := filepath.Join(root.Path, name)
var res Resource
if isInstruction {
res = r.readInstructionFile(root.Path, path, info, root.UserSource)
} else {
res = r.readMCPConfig(root.Path, path, info, root.UserSource)
}
appendResource(out, seenID, res)
}
}
// appendResource adds res to out unless an earlier resource
// already claimed its ID.
func appendResource(out *[]Resource, seenID map[string]struct{}, res Resource) {
if _, dup := seenID[res.ID]; dup {
return
}
seenID[res.ID] = struct{}{}
*out = append(*out, res)
}
// resolveReadTarget produces the path and FileInfo that should
@@ -401,8 +376,10 @@ func resolveReadTarget(path string, info fs.FileInfo, scanRoot string) (readPath
return target, tgtInfo, true, StatusOK, ""
}
// classifyFile inspects a single file path and produces a
// classifyFile inspects a single-file scan root and produces a
// Resource when the basename matches a recognized convention.
// Directory roots are handled by discoverIn; this is reached only
// for sources that point directly at a file.
func (r *Resolver) classifyFile(scanRoot, path string, info fs.FileInfo, userSource string) (Resource, bool) {
name := info.Name()
switch {
@@ -411,13 +388,10 @@ func (r *Resolver) classifyFile(scanRoot, path string, info fs.FileInfo, userSou
case name == mcpConfigFileName:
return r.readMCPConfig(scanRoot, path, info, userSource), true
case name == skillMetaFileName:
// SKILL.md outside a skills container is still a
// valid skill if its parent directory name matches
// the front-matter name. emitSkillsFromContainer
// already handles the common case; here we cover
// "user adds a single SKILL.md file as a source".
res, ok := r.readSkillMeta(scanRoot, path, info, userSource)
return res, ok
// SKILL.md as an explicit single-file source is still a
// valid skill when its parent directory name matches the
// front-matter name.
return r.readSkillMeta(scanRoot, path, info, userSource)
default:
return Resource{}, false
}
@@ -529,14 +503,26 @@ func validateMCPConfig(data []byte) error {
// post-processing (e.g. firstLine for instruction files) by
// inspecting Status==StatusOK.
func (r *Resolver) readFileResource(kind ResourceKind, scanRoot, path string, info fs.FileInfo, userSource string) Resource {
readPath, readInfo, ok, status, errMsg := resolveReadTarget(path, info, scanRoot)
// Attribute the resource to the resolved target rather than
// the path we walked. When several names point at the same
// file (e.g. CLAUDE.md and .cursorrules symlinked to
// AGENTS.md), they share an ID and collapse to a single
// resource via the walk's ID-based dedup, instead of shipping
// identical content multiple times. On resolve failure the
// original path is kept so the error points at the offending
// link.
idPath := path
if ok {
idPath = readPath
}
res := Resource{
ID: resourceID(kind, path),
ID: resourceID(kind, idPath),
Kind: kind,
Source: path,
Source: idPath,
SizeBytes: safeUint64(info.Size()),
SourcePath: userSource,
}
readPath, readInfo, ok, status, errMsg := resolveReadTarget(path, info, scanRoot)
if !ok {
res.Status = status
res.Error = errMsg
@@ -649,11 +635,7 @@ func (r *Resolver) emitSkillsFromContainer(container string, root ScanRoot, out
if !ok {
continue
}
if _, dup := seenID[res.ID]; dup {
continue
}
seenID[res.ID] = struct{}{}
*out = append(*out, res)
appendResource(out, seenID, res)
}
}
@@ -745,15 +727,6 @@ func excluded(r Resource, reason string) Resource {
return r
}
// isSkillsContainer reports whether dir is a recognized skills
// container directory whose immediate children carry SKILL.md
// files. Both bare "skills" and nested "<parent>/skills"
// directories qualify (e.g. ".agents/skills",
// "plugins/foo/skills").
func isSkillsContainer(dir string) bool {
return filepath.Base(dir) == "skills"
}
// resourceID builds a stable resource ID. Kind plus canonical
// source path is enough; sources never collide across kinds for
// v1 because each kind owns a distinct file-name pattern.
+121 -33
View File
@@ -54,7 +54,11 @@ func TestResolver_ProjectAGENTSFile(t *testing.T) {
require.NotEqual(t, [32]byte{}, got.ContentHash)
}
func TestResolver_CaseInsensitiveInstructionNames(t *testing.T) {
// TestResolver_InstructionNamesAreCaseSensitive verifies the
// resolver matches instruction filenames exactly, mirroring
// codex. A lower-case agents.md (for example a generated API
// reference doc) must not be mistaken for an instruction file.
func TestResolver_InstructionNamesAreCaseSensitive(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "agents.md"), "lower\n")
@@ -63,7 +67,8 @@ func TestResolver_CaseInsensitiveInstructionNames(t *testing.T) {
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 2)
require.Len(t, snap.Resources, 1)
require.Equal(t, filepath.Join(dir, "CLAUDE.md"), snap.Resources[0].Source)
}
func TestResolver_SkillsContainerEmitsEachSubdir(t *testing.T) {
@@ -150,10 +155,11 @@ func TestResolver_MCPConfigEmitted(t *testing.T) {
}
// TestResolver_SymlinkInsideScanRootAllowed exercises the
// monorepo case where AGENTS.md is symlinked to shared content
// inside the same workspace tree. The target lives under the
// scan root, so the resolver follows the symlink and emits the
// target bytes as if the symlink were a regular file.
// monorepo case where a top-level AGENTS.md is symlinked to
// shared content elsewhere inside the same workspace tree. The
// target lives under the scan root, so the resolver follows the
// symlink, emits the target bytes, and attributes the resource
// to the resolved target path.
func TestResolver_SymlinkInsideScanRootAllowed(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin privileges on Windows runners")
@@ -169,15 +175,59 @@ func TestResolver_SymlinkInsideScanRootAllowed(t *testing.T) {
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 2)
var linked agentcontext.Resource
for _, res := range snap.Resources {
if res.Source == link {
linked = res
}
// The nested target is not independently recognized (only the
// top-level symlink is), so exactly one resource is emitted,
// carrying the target bytes and attributed to the target.
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.StatusOK, got.Status)
require.Equal(t, target, got.Source)
require.Equal(t, "shared monorepo guidance", string(got.Payload))
}
// TestResolver_SymlinkedInstructionFilesDeduplicated reproduces
// the common repo layout where CLAUDE.md and .cursorrules are
// symlinks to a single AGENTS.md. All three resolve to the same
// file, so the resolver must emit one instruction resource
// attributed to the real AGENTS.md rather than three copies of
// identical content.
func TestResolver_SymlinkedInstructionFilesDeduplicated(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin privileges on Windows runners")
}
require.Equal(t, agentcontext.StatusOK, linked.Status)
require.Equal(t, "shared monorepo guidance", string(linked.Payload))
t.Parallel()
dir := t.TempDir()
agents := filepath.Join(dir, "AGENTS.md")
mustWriteFile(t, agents, "the one true guidance")
require.NoError(t, os.Symlink(agents, filepath.Join(dir, "CLAUDE.md")))
require.NoError(t, os.Symlink(agents, filepath.Join(dir, ".cursorrules")))
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
got := snap.Resources[0]
require.Equal(t, agentcontext.KindInstructionFile, got.Kind)
require.Equal(t, agents, got.Source)
require.Equal(t, "the one true guidance", string(got.Payload))
}
// TestResolver_InstructionFilesOnlyAtScanRoot verifies the
// resolver does not descend into subdirectories to collect
// nested instruction files, mirroring codex. A nested
// site/AGENTS.md is ignored while the top-level one is kept.
func TestResolver_InstructionFilesOnlyAtScanRoot(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "root")
mustWriteFile(t, filepath.Join(dir, "site", "AGENTS.md"), "nested")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
require.Equal(t, filepath.Join(dir, "AGENTS.md"), snap.Resources[0].Source)
require.Equal(t, "root", string(snap.Resources[0].Payload))
}
// TestResolver_SymlinkOutsideScanRootRejected guards the
@@ -250,18 +300,23 @@ func TestResolver_OversizeInstructionFile(t *testing.T) {
func TestResolver_AggregateCapExcludes(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "small")
subA := filepath.Join(dir, "a")
subB := filepath.Join(dir, "b")
mustWriteFile(t, filepath.Join(subA, "AGENTS.md"), "AAAA")
mustWriteFile(t, filepath.Join(subB, "AGENTS.md"), "BBBB")
// Instruction files are only read at a scan root's top level,
// so each contributing file lives at its own scan root.
dirRoot := t.TempDir()
dirA := t.TempDir()
dirB := t.TempDir()
mustWriteFile(t, filepath.Join(dirRoot, "AGENTS.md"), "small")
mustWriteFile(t, filepath.Join(dirA, "AGENTS.md"), "AAAA")
mustWriteFile(t, filepath.Join(dirB, "AGENTS.md"), "BBBB")
// Aggregate cap of 9 bytes lets the first two through but
// excludes the third regardless of which order they
// appear.
// Aggregate cap of 9 bytes lets two of the three (5+4) bytes
// through but excludes the third regardless of order.
r := &agentcontext.Resolver{MaxSnapshotBytes: 9}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
snap := r.Resolve([]agentcontext.ScanRoot{
{Path: dirRoot},
{Path: dirA},
{Path: dirB},
})
var excluded int
for _, res := range snap.Resources {
@@ -274,14 +329,17 @@ func TestResolver_AggregateCapExcludes(t *testing.T) {
func TestResolver_CountCapExcludes(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Instruction files are only read at a scan root's top level,
// so spread the five files across five scan roots.
roots := make([]agentcontext.ScanRoot, 0, 5)
for i := 0; i < 5; i++ {
sub := filepath.Join(dir, "dir", string('a'+rune(i)))
mustWriteFile(t, filepath.Join(sub, "AGENTS.md"), "x")
d := t.TempDir()
mustWriteFile(t, filepath.Join(d, "AGENTS.md"), "x")
roots = append(roots, agentcontext.ScanRoot{Path: d})
}
r := &agentcontext.Resolver{MaxResources: 3}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
snap := r.Resolve(roots)
require.Len(t, snap.Resources, 5)
var excluded int
@@ -293,18 +351,48 @@ func TestResolver_CountCapExcludes(t *testing.T) {
require.Equal(t, 2, excluded)
}
func TestResolver_SkipsVendorAndNodeModules(t *testing.T) {
// TestResolver_MCPConfigOnlyAtScanRoot verifies that .mcp.json is
// recognized only at a scan root's top level. A nested config is
// ignored because the resolver no longer walks the tree.
func TestResolver_MCPConfigOnlyAtScanRoot(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "AGENTS.md"), "root")
mustWriteFile(t, filepath.Join(dir, "node_modules", "deep", "AGENTS.md"), "should not appear")
mustWriteFile(t, filepath.Join(dir, "vendor", "AGENTS.md"), "should not appear either")
mustWriteFile(t, filepath.Join(dir, ".mcp.json"), `{"mcpServers": {}}`)
mustWriteFile(t, filepath.Join(dir, "sub", ".mcp.json"), `{"mcpServers": {}}`)
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
require.Len(t, snap.Resources, 1)
require.Equal(t, filepath.Join(dir, "AGENTS.md"), snap.Resources[0].Source)
require.Equal(t, agentcontext.KindMCPConfig, snap.Resources[0].Kind)
require.Equal(t, filepath.Join(dir, ".mcp.json"), snap.Resources[0].Source)
}
// TestResolver_SkillsOnlyFromFixedContainers verifies skills are
// discovered from the fixed container locations (skills,
// .agents/skills, .claude/skills, .codex/skills) and never from an
// arbitrary skills/ directory nested elsewhere in the tree.
func TestResolver_SkillsOnlyFromFixedContainers(t *testing.T) {
t.Parallel()
dir := t.TempDir()
mustWriteSkill(t, filepath.Join(dir, "skills"), "water-plants", "p")
mustWriteSkill(t, filepath.Join(dir, ".agents", "skills"), "make-coffee", "c")
mustWriteSkill(t, filepath.Join(dir, ".claude", "skills"), "fold-laundry", "l")
mustWriteSkill(t, filepath.Join(dir, ".codex", "skills"), "walk-dog", "d")
// A skills/ directory buried under an arbitrary path is not a
// fixed container location and must be ignored.
mustWriteSkill(t, filepath.Join(dir, "pkg", "skills"), "buried", "b")
r := &agentcontext.Resolver{}
snap := r.Resolve([]agentcontext.ScanRoot{{Path: dir}})
var names []string
for _, res := range snap.Resources {
require.Equal(t, agentcontext.KindSkill, res.Kind)
names = append(names, filepath.Base(res.Source))
}
require.ElementsMatch(t,
[]string{"water-plants", "make-coffee", "fold-laundry", "walk-dog"}, names)
}
func TestResolver_UserSourceAttribution(t *testing.T) {
+37 -53
View File
@@ -3,10 +3,8 @@ package agentcontext
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
@@ -24,33 +22,27 @@ import (
// uses so behavior is consistent across the agent.
const DefaultWatchDebounce = 250 * time.Millisecond
// WatcherOptions parameterizes the recursive watcher.
// WatcherOptions parameterizes the watcher.
type WatcherOptions struct {
Logger slog.Logger
Clock quartz.Clock
Debounce time.Duration
// MaxDepth caps the recursion depth when discovering
// subdirectories to watch. Zero defaults to
// DefaultMaxScanDepth. Callers wiring the watcher to a
// Resolver should pass the resolver's MaxDepth so the
// watcher never misses edits below the scan horizon.
MaxDepth int
// OnChange runs at most once per debounce window. The
// caller must not block; the recommended pattern is a
// non-blocking send on a re-resolve trigger channel.
OnChange func()
}
// Watcher is a recursive fsnotify wrapper. fsnotify does not
// support recursive watches natively on Linux, so we walk every
// scan root at sync time and register each subdirectory
// individually. Inotify ENOSPC degrades the watcher into a
// poll-only mode that still re-resolves on Sync calls.
// Watcher is a fixed-location fsnotify wrapper. It watches only
// the directories that can hold recognized resources (each scan
// root plus its skill containers and immediate skill dirs) rather
// than walking the tree, mirroring the resolver's fixed-location
// discovery. Inotify ENOSPC degrades the watcher into a poll-only
// mode that still re-resolves on Sync calls.
type Watcher struct {
logger slog.Logger
clock quartz.Clock
debounce time.Duration
maxDepth int
onChange func()
mu sync.Mutex
@@ -77,10 +69,6 @@ func NewWatcher(opts WatcherOptions) (*Watcher, error) {
if clock == nil {
clock = quartz.NewReal()
}
maxDepth := opts.MaxDepth
if maxDepth <= 0 {
maxDepth = DefaultMaxScanDepth
}
w, err := fsnotify.NewWatcher()
if err != nil {
@@ -92,7 +80,6 @@ func NewWatcher(opts WatcherOptions) (*Watcher, error) {
logger: opts.Logger,
clock: clock,
debounce: debounce,
maxDepth: maxDepth,
onChange: opts.OnChange,
watched: make(map[string]struct{}),
degraded: "fsnotify init failed: " + err.Error(),
@@ -106,7 +93,6 @@ func NewWatcher(opts WatcherOptions) (*Watcher, error) {
logger: opts.Logger,
clock: clock,
debounce: debounce,
maxDepth: maxDepth,
onChange: opts.OnChange,
watcher: w,
watched: make(map[string]struct{}),
@@ -135,17 +121,18 @@ func (w *Watcher) Degraded() string {
return w.degraded
}
// Sync replaces the set of watched directories with a fresh
// recursive walk of every scan root. Files are not watched
// directly; watching the parent directory catches creates,
// renames, removes, and writes that touch any recognized
// basename. Files that are themselves scan roots are handled by
// watching their parent.
// Sync replaces the set of watched directories with the fixed
// locations that can hold recognized resources: each scan root,
// its skill containers, and the immediate skill subdirectories.
// Files are not watched directly; watching the parent directory
// catches creates, renames, removes, and writes that touch any
// recognized basename. Files that are themselves scan roots are
// handled by watching their parent.
//
// Sync is idempotent and safe to call repeatedly. The lock is
// released around the recursive directory walk so concurrent
// Close, schedule, and the run goroutine are not blocked by a
// slow filesystem.
// released around the directory scan so concurrent Close,
// schedule, and the run goroutine are not blocked by a slow
// filesystem.
func (w *Watcher) Sync(ctx context.Context, roots []ScanRoot) {
w.mu.Lock()
if w.closed {
@@ -168,9 +155,9 @@ func (w *Watcher) Sync(ctx context.Context, roots []ScanRoot) {
}
w.mu.Unlock()
// collectDirs touches the filesystem (filepath.WalkDir on
// every scan root). Compute the desired set outside the
// mutex so a slow walk does not block the run goroutine,
// collectDirs touches the filesystem (stat/ReadDir on every
// scan root and skill container). Compute the desired set
// outside the mutex so it does not block the run goroutine,
// Close, or schedule.
desired := w.collectDirs(roots)
@@ -324,10 +311,13 @@ func (w *Watcher) schedule() {
w.mu.Unlock()
}
// collectDirs walks every scan root and returns the set of
// directories to watch. The maximum depth uses the watcher's
// configured maxDepth so it mirrors the resolver's horizon.
func (w *Watcher) collectDirs(roots []ScanRoot) map[string]struct{} {
// collectDirs returns the set of directories to watch. Discovery
// is fixed-location, mirroring the resolver: for each scan root we
// watch the root directory itself (catching top-level instruction
// and .mcp.json changes), plus every existing skill container and
// its immediate skill subdirectories (catching skill add/remove
// and SKILL.md writes). The watcher never recurses the tree.
func (*Watcher) collectDirs(roots []ScanRoot) map[string]struct{} {
out := make(map[string]struct{})
for _, root := range roots {
if root.Path == "" {
@@ -346,25 +336,19 @@ func (w *Watcher) collectDirs(roots []ScanRoot) map[string]struct{} {
out[filepath.Dir(root.Path)] = struct{}{}
continue
}
// Walk the directory and collect every descendant
// directory up to the depth cap.
rootDepth := strings.Count(filepath.Clean(root.Path), string(os.PathSeparator))
_ = filepath.WalkDir(root.Path, func(path string, d fs.DirEntry, err error) error {
out[root.Path] = struct{}{}
for _, container := range skillContainersFor(root.Path) {
out[container] = struct{}{}
entries, err := os.ReadDir(container)
if err != nil {
return nil
continue
}
if !d.IsDir() {
return nil
for _, e := range entries {
if e.IsDir() {
out[filepath.Join(container, e.Name())] = struct{}{}
}
}
if _, skip := skipDirNames[d.Name()]; skip && path != root.Path {
return fs.SkipDir
}
if strings.Count(path, string(os.PathSeparator))-rootDepth > w.maxDepth {
return fs.SkipDir
}
out[path] = struct{}{}
return nil
})
}
}
return out
}