Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/nested-agents-md-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Load nested `AGENTS.md` files hierarchically (plus optional `~/.kilocode/AGENTS.md`) with size/file-count limits to avoid overlong prompt contexts.
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,9 @@ ls -la AGENTS.md
# That's it - Kilo Code loads it automatically (enabled by default)
```

**Important:** Use uppercase `AGENTS.md` (not `agents.md`). Kilo Code also accepts `AGENT.md` (singular) as a fallback.
**Important:** Use uppercase `AGENTS.md` (not `agents.md`).

**Note:** Both `AGENTS.md` and `AGENT.md` are write-protected files in Kilo Code and require user approval to modify.
**Note:** `AGENTS.md` is a write-protected file in Kilo Code and requires user approval to modify.

## Understanding Mode-Specific Rules

Expand Down Expand Up @@ -358,8 +358,8 @@ Cursor's nested directories don't map to Kilo Code. Flatten with descriptive nam

### AGENTS.md Not Loading

- **Verify filename:** Must be `AGENTS.md` or `AGENT.md` (uppercase)
- **Check location:** Must be at project root
- **Verify filename:** Must be `AGENTS.md` (uppercase)
- **Check location:** Must be at project root (or inside the subproject directory you’re working in)
- **Check setting:** Verify "Use Agent Rules" is enabled in Kilo Code settings (enabled by default)
- **Reload:** Restart VS Code if needed

Expand Down
29 changes: 29 additions & 0 deletions src/core/fs/find-up.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import path from "path"

/**
* Returns a directory chain walking upward from `startDir` toward the filesystem root.
* If `stopDir` is provided and encountered, the chain stops (inclusive).
*
* The returned array is ordered from closest (`startDir`) to farthest (parents).
*/
export function findUpDirectoryChain(startDir: string, stopDir?: string): string[] {
const normalizedStart = path.resolve(startDir)
const normalizedStop = stopDir ? path.resolve(stopDir) : undefined

const directories: string[] = []
let current = normalizedStart

while (true) {
directories.push(current)
if (normalizedStop && current === normalizedStop) {
break
}
const parent = path.dirname(current)
if (parent === current) {
break
}
current = parent
}

return directories
}
155 changes: 155 additions & 0 deletions src/core/prompts/sections/__tests__/agent-rules.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// npx vitest core/prompts/sections/__tests__/agent-rules.spec.ts

import path from "path"

const { mockLstat, mockReadlink, mockStat } = vi.hoisted(() => ({
mockLstat: vi.fn(),
mockReadlink: vi.fn(),
mockStat: vi.fn(),
}))

vi.mock("fs/promises", () => ({
default: {
lstat: mockLstat,
readlink: mockReadlink,
stat: mockStat,
},
}))

vi.mock("os", () => ({
default: {
homedir: () => "/home/user",
},
homedir: () => "/home/user",
}))

import { loadAgentRulesContent } from "../agent-rules"

describe("loadAgentRulesContent", () => {
beforeEach(() => {
vi.clearAllMocks()
mockLstat.mockResolvedValue({
isSymbolicLink: () => false,
})
})

it("includes nested AGENTS.md rules from cwd to active path", async () => {
const readFile = vi.fn(async (filePath: string) => {
const normalized = filePath.replace(/\\/g, "/")
if (normalized.endsWith("/repo/AGENTS.md")) {
return "Root rules"
}
if (normalized.endsWith("/repo/services/AGENTS.md")) {
return "Service rules"
}
return ""
})

const result = await loadAgentRulesContent({
cwd: "/repo",
activePath: "/repo/services/service.ts",
readFile,
})

expect(result).toContain("Root rules")
expect(result).toContain("Service rules")
expect(result.indexOf("Root rules")).toBeLessThan(result.indexOf("Service rules"))
})

it("includes global AGENTS.md before local rules", async () => {
const readFile = vi.fn(async (filePath: string) => {
const normalized = filePath.replace(/\\/g, "/")
if (normalized.endsWith("/home/user/.kilocode/AGENTS.md")) {
return "Global rules"
}
if (normalized.endsWith("/repo/AGENTS.md")) {
return "Local rules"
}
return ""
})

const result = await loadAgentRulesContent({
cwd: "/repo",
activePath: "/repo/service.ts",
readFile,
})

expect(result).toContain("Global rules")
expect(result).toContain("Local rules")
expect(result.indexOf("Global rules")).toBeLessThan(result.indexOf("Local rules"))
})

it("truncates rules when content exceeds the size limit", async () => {
const longContent = "a".repeat(20000)
const readFile = vi.fn(async (filePath: string) => {
const normalized = filePath.replace(/\\/g, "/")
if (normalized.endsWith("/repo/AGENTS.md")) {
return longContent
}
return ""
})

const result = await loadAgentRulesContent({
cwd: "/repo",
activePath: "/repo/service.ts",
readFile,
})

expect(result).toContain("[truncated]")
})

it("limits the number of nested AGENTS.md files", async () => {
const segments = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
const dirs: string[] = ["/repo"]
for (const segment of segments) {
dirs.push(path.posix.join(dirs[dirs.length - 1]!, segment))
}

const readFile = vi.fn(async (filePath: string) => {
const normalized = filePath.replace(/\\/g, "/").replace(/^[a-zA-Z]:/, "")
const parentDir = path.posix.dirname(normalized)
const index = dirs.indexOf(parentDir)
if (index === -1) {
return ""
}
const name = index === 0 ? "root" : segments[index - 1]
return `rules-${name}`
})

const result = await loadAgentRulesContent({
cwd: "/repo",
activePath: path.posix.join(dirs[dirs.length - 1]!, "file.ts"),
readFile,
})

for (const name of ["e", "f", "g", "h", "i", "j"]) {
expect(result).toContain(`rules-${name}`)
}

for (const name of ["root", "a", "b", "c", "d"]) {
expect(result).not.toContain(`rules-${name}`)
}
})

it("falls back to cwd when active path is outside cwd", async () => {
const readFile = vi.fn(async (filePath: string) => {
const normalized = filePath.replace(/\\/g, "/")
if (normalized.endsWith("/repo/AGENTS.md")) {
return "Root rules"
}
if (normalized.endsWith("/other/AGENTS.md")) {
return "Other rules"
}
return ""
})

const result = await loadAgentRulesContent({
cwd: "/repo",
activePath: "/other/file.ts",
readFile,
})

expect(result).toContain("Root rules")
expect(result).not.toContain("Other rules")
})
})
94 changes: 0 additions & 94 deletions src/core/prompts/sections/__tests__/custom-instructions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,100 +940,6 @@ describe("addCustomInstructions", () => {
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"), "utf-8")
})

it("should load AGENT.md (singular) when AGENTS.md is not found", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })

// Mock lstat to indicate AGENTS.md doesn't exist but AGENT.md does
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.reject({ code: "ENOENT" })
}
if (pathStr.endsWith("AGENT.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})

readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENT.md")) {
return Promise.resolve("Agent rules from AGENT.md file (singular)")
}
return Promise.reject({ code: "ENOENT" })
})

const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{
settings: {
maxConcurrentFileReads: 5,
todoListEnabled: true,
useAgentRules: true,
newTaskRequireTodos: false,
},
},
)

expect(result).toContain("# Agent Rules Standard (AGENT.md):")
expect(result).toContain("Agent rules from AGENT.md file (singular)")
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("AGENT.md"), "utf-8")
})

it("should prefer AGENTS.md over AGENT.md when both exist", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })

// Mock lstat to indicate both files exist
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md") || pathStr.endsWith("AGENT.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})

readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve("Agent rules from AGENTS.md file (plural)")
}
if (pathStr.endsWith("AGENT.md")) {
return Promise.resolve("Agent rules from AGENT.md file (singular)")
}
return Promise.reject({ code: "ENOENT" })
})

const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{
settings: {
maxConcurrentFileReads: 5,
todoListEnabled: true,
useAgentRules: true,
newTaskRequireTodos: false,
},
},
)

// Should contain AGENTS.md content (preferred) and not AGENT.md
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
expect(result).toContain("Agent rules from AGENTS.md file (plural)")
expect(result).not.toContain("Agent rules from AGENT.md file (singular)")
expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining("AGENTS.md"), "utf-8")
})

it("should return empty string when no instructions provided", async () => {
// Simulate no .kilocode/rules directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
Expand Down
Loading