feat: progressive subdirectory hint discovery (#5291)
As the agent navigates into subdirectories via tool calls (read_file,
terminal, search_files, etc.), automatically discover and load project
context files (AGENTS.md, CLAUDE.md, .cursorrules) from those directories.
Previously, context files were only loaded from the CWD at session start.
If the agent moved into backend/, frontend/, or any subdirectory with its
own AGENTS.md, those instructions were never seen.
Now, SubdirectoryHintTracker watches tool call arguments for file paths
and shell commands, resolves directories, and loads hint files on first
access. Discovered hints are appended to the tool result so the model
gets relevant context at the moment it starts working in a new area —
without modifying the system prompt (preserving prompt caching).
Features:
- Extracts paths from tool args (path, workdir) and shell commands
- Loads AGENTS.md, CLAUDE.md, .cursorrules (first match per directory)
- Deduplicates — each directory loaded at most once per session
- Ignores paths outside the working directory
- Truncates large hint files at 8K chars
- Works on both sequential and concurrent tool execution paths
Inspired by Block/goose SubdirectoryHintTracker.
2026-04-05 12:33:47 -07:00
|
|
|
"""Tests for progressive subdirectory hint discovery."""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import pytest
|
|
|
|
|
from pathlib import Path
|
2026-04-09 07:23:34 +02:00
|
|
|
from unittest.mock import patch
|
feat: progressive subdirectory hint discovery (#5291)
As the agent navigates into subdirectories via tool calls (read_file,
terminal, search_files, etc.), automatically discover and load project
context files (AGENTS.md, CLAUDE.md, .cursorrules) from those directories.
Previously, context files were only loaded from the CWD at session start.
If the agent moved into backend/, frontend/, or any subdirectory with its
own AGENTS.md, those instructions were never seen.
Now, SubdirectoryHintTracker watches tool call arguments for file paths
and shell commands, resolves directories, and loads hint files on first
access. Discovered hints are appended to the tool result so the model
gets relevant context at the moment it starts working in a new area —
without modifying the system prompt (preserving prompt caching).
Features:
- Extracts paths from tool args (path, workdir) and shell commands
- Loads AGENTS.md, CLAUDE.md, .cursorrules (first match per directory)
- Deduplicates — each directory loaded at most once per session
- Ignores paths outside the working directory
- Truncates large hint files at 8K chars
- Works on both sequential and concurrent tool execution paths
Inspired by Block/goose SubdirectoryHintTracker.
2026-04-05 12:33:47 -07:00
|
|
|
|
|
|
|
|
from agent.subdirectory_hints import SubdirectoryHintTracker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def project(tmp_path):
|
|
|
|
|
"""Create a mock project tree with hint files in subdirectories."""
|
|
|
|
|
# Root — already loaded at startup
|
|
|
|
|
(tmp_path / "AGENTS.md").write_text("Root project instructions")
|
|
|
|
|
|
|
|
|
|
# backend/ — has its own AGENTS.md
|
|
|
|
|
backend = tmp_path / "backend"
|
|
|
|
|
backend.mkdir()
|
|
|
|
|
(backend / "AGENTS.md").write_text("Backend-specific instructions:\n- Use FastAPI\n- Always add type hints")
|
|
|
|
|
|
|
|
|
|
# backend/src/ — no hints
|
|
|
|
|
(backend / "src").mkdir()
|
|
|
|
|
(backend / "src" / "main.py").write_text("print('hello')")
|
|
|
|
|
|
|
|
|
|
# frontend/ — has CLAUDE.md
|
|
|
|
|
frontend = tmp_path / "frontend"
|
|
|
|
|
frontend.mkdir()
|
|
|
|
|
(frontend / "CLAUDE.md").write_text("Frontend rules:\n- Use TypeScript\n- No any types")
|
|
|
|
|
|
|
|
|
|
# docs/ — no hints
|
|
|
|
|
(tmp_path / "docs").mkdir()
|
|
|
|
|
(tmp_path / "docs" / "README.md").write_text("Documentation")
|
|
|
|
|
|
|
|
|
|
# deep/nested/path/ — has .cursorrules
|
|
|
|
|
deep = tmp_path / "deep" / "nested" / "path"
|
|
|
|
|
deep.mkdir(parents=True)
|
|
|
|
|
(deep / ".cursorrules").write_text("Cursor rules for nested path")
|
|
|
|
|
|
|
|
|
|
return tmp_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSubdirectoryHintTracker:
|
|
|
|
|
"""Unit tests for SubdirectoryHintTracker."""
|
|
|
|
|
|
|
|
|
|
def test_working_dir_not_loaded(self, project):
|
|
|
|
|
"""Working dir is pre-marked as loaded (startup handles it)."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
# Reading a file in the root should NOT trigger hints
|
|
|
|
|
result = tracker.check_tool_call("read_file", {"path": str(project / "AGENTS.md")})
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
def test_discovers_agents_md_via_ancestor_walk(self, project):
|
|
|
|
|
"""Reading backend/src/main.py discovers backend/AGENTS.md via ancestor walk."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "backend" / "src" / "main.py")}
|
|
|
|
|
)
|
|
|
|
|
# backend/src/ has no hints, but ancestor walk finds backend/AGENTS.md
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Backend-specific instructions" in result
|
|
|
|
|
# Second read in same subtree should not re-trigger
|
|
|
|
|
result2 = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "backend" / "AGENTS.md")}
|
|
|
|
|
)
|
|
|
|
|
assert result2 is None # backend/ already loaded
|
|
|
|
|
|
|
|
|
|
def test_discovers_claude_md(self, project):
|
|
|
|
|
"""Frontend CLAUDE.md should be discovered."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "frontend" / "index.ts")}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Frontend rules" in result
|
|
|
|
|
|
|
|
|
|
def test_no_duplicate_loading(self, project):
|
|
|
|
|
"""Same directory should not be loaded twice."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result1 = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "frontend" / "a.ts")}
|
|
|
|
|
)
|
|
|
|
|
assert result1 is not None
|
|
|
|
|
|
|
|
|
|
result2 = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "frontend" / "b.ts")}
|
|
|
|
|
)
|
|
|
|
|
assert result2 is None # already loaded
|
|
|
|
|
|
|
|
|
|
def test_no_hints_in_empty_directory(self, project):
|
|
|
|
|
"""Directories without hint files return None."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "docs" / "README.md")}
|
|
|
|
|
)
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
def test_terminal_command_path_extraction(self, project):
|
|
|
|
|
"""Paths extracted from terminal commands."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"terminal", {"command": f"cat {project / 'frontend' / 'index.ts'}"}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Frontend rules" in result
|
|
|
|
|
|
|
|
|
|
def test_terminal_cd_command(self, project):
|
|
|
|
|
"""cd into a directory with hints."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"terminal", {"command": f"cd {project / 'backend'} && ls"}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Backend-specific instructions" in result
|
|
|
|
|
|
|
|
|
|
def test_relative_path(self, project):
|
|
|
|
|
"""Relative paths resolved against working_dir."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": "frontend/index.ts"}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Frontend rules" in result
|
|
|
|
|
|
|
|
|
|
def test_outside_working_dir_still_checked(self, tmp_path, project):
|
|
|
|
|
"""Paths outside working_dir are still checked for hints."""
|
|
|
|
|
other_project = tmp_path / "other"
|
|
|
|
|
other_project.mkdir()
|
|
|
|
|
(other_project / "AGENTS.md").write_text("Other project rules")
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(other_project / "file.py")}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Other project rules" in result
|
|
|
|
|
|
|
|
|
|
def test_workdir_arg(self, project):
|
|
|
|
|
"""The workdir argument from terminal tool is checked."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"terminal", {"command": "ls", "workdir": str(project / "frontend")}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Frontend rules" in result
|
|
|
|
|
|
|
|
|
|
def test_deeply_nested_cursorrules(self, project):
|
|
|
|
|
"""Deeply nested .cursorrules should be discovered."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "deep" / "nested" / "path" / "file.py")}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Cursor rules for nested path" in result
|
|
|
|
|
|
|
|
|
|
def test_hint_format_includes_path(self, project):
|
|
|
|
|
"""Discovered hints should indicate which file they came from."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "backend" / "file.py")}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "Subdirectory context discovered:" in result
|
|
|
|
|
assert "AGENTS.md" in result
|
|
|
|
|
|
|
|
|
|
def test_truncation_of_large_hints(self, tmp_path):
|
|
|
|
|
"""Hint files over the limit are truncated."""
|
|
|
|
|
sub = tmp_path / "bigdir"
|
|
|
|
|
sub.mkdir()
|
|
|
|
|
(sub / "AGENTS.md").write_text("x" * 20_000)
|
|
|
|
|
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(tmp_path))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(sub / "file.py")}
|
|
|
|
|
)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "truncated" in result.lower()
|
|
|
|
|
# Should be capped
|
|
|
|
|
assert len(result) < 20_000
|
|
|
|
|
|
|
|
|
|
def test_empty_args(self, project):
|
|
|
|
|
"""Empty args should not crash."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
assert tracker.check_tool_call("read_file", {}) is None
|
|
|
|
|
assert tracker.check_tool_call("terminal", {"command": ""}) is None
|
|
|
|
|
|
|
|
|
|
def test_url_in_command_ignored(self, project):
|
|
|
|
|
"""URLs in shell commands should not be treated as paths."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"terminal", {"command": "curl https://example.com/frontend/api"}
|
|
|
|
|
)
|
|
|
|
|
assert result is None
|
2026-04-09 07:23:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPermissionErrorHandling:
|
|
|
|
|
"""Regression tests for PermissionError in filesystem checks (ref #6214)."""
|
|
|
|
|
|
|
|
|
|
def test_is_valid_subdir_permission_error(self, tmp_path):
|
|
|
|
|
"""_is_valid_subdir should return False when is_dir() raises PermissionError."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(tmp_path))
|
|
|
|
|
restricted = tmp_path / "restricted"
|
|
|
|
|
restricted.mkdir()
|
|
|
|
|
with patch.object(Path, "is_dir", side_effect=PermissionError("Permission denied")):
|
|
|
|
|
assert tracker._is_valid_subdir(restricted) is False
|
|
|
|
|
|
|
|
|
|
def test_load_hints_permission_error_on_is_file(self, tmp_path):
|
|
|
|
|
"""_load_hints_for_directory should skip files when is_file() raises PermissionError."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(tmp_path))
|
|
|
|
|
restricted = tmp_path / "restricted"
|
|
|
|
|
restricted.mkdir()
|
|
|
|
|
original_is_file = Path.is_file
|
|
|
|
|
def patched_is_file(self):
|
|
|
|
|
if "restricted" in str(self):
|
|
|
|
|
raise PermissionError("Permission denied")
|
|
|
|
|
return original_is_file(self)
|
|
|
|
|
with patch.object(Path, "is_file", patched_is_file):
|
|
|
|
|
result = tracker._load_hints_for_directory(restricted)
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
def test_check_tool_call_survives_inaccessible_path(self, project):
|
|
|
|
|
"""Full check_tool_call should not crash when a path is inaccessible."""
|
|
|
|
|
tracker = SubdirectoryHintTracker(working_dir=str(project))
|
|
|
|
|
original_is_dir = Path.is_dir
|
|
|
|
|
def patched_is_dir(self):
|
|
|
|
|
if "backend" in str(self) and "src" not in str(self):
|
|
|
|
|
raise PermissionError("Permission denied")
|
|
|
|
|
return original_is_dir(self)
|
|
|
|
|
with patch.object(Path, "is_dir", patched_is_dir):
|
|
|
|
|
# Should not raise — gracefully skip the inaccessible directory
|
|
|
|
|
result = tracker.check_tool_call(
|
|
|
|
|
"read_file", {"path": str(project / "backend" / "src" / "main.py")}
|
|
|
|
|
)
|
|
|
|
|
# Result may be None (backend skipped) — the key point is no crash
|
|
|
|
|
assert result is None or isinstance(result, str)
|