diff --git a/.changeset/nested-agents-md-rules.md b/.changeset/nested-agents-md-rules.md new file mode 100644 index 00000000000..4376010994d --- /dev/null +++ b/.changeset/nested-agents-md-rules.md @@ -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. diff --git a/apps/kilocode-docs/docs/advanced-usage/migrating-from-cursor-windsurf.md b/apps/kilocode-docs/docs/advanced-usage/migrating-from-cursor-windsurf.md index 390a711720f..4dab68fe98c 100644 --- a/apps/kilocode-docs/docs/advanced-usage/migrating-from-cursor-windsurf.md +++ b/apps/kilocode-docs/docs/advanced-usage/migrating-from-cursor-windsurf.md @@ -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 @@ -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 diff --git a/src/core/fs/find-up.ts b/src/core/fs/find-up.ts new file mode 100644 index 00000000000..43b7f8a994f --- /dev/null +++ b/src/core/fs/find-up.ts @@ -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 +} diff --git a/src/core/prompts/sections/__tests__/agent-rules.spec.ts b/src/core/prompts/sections/__tests__/agent-rules.spec.ts new file mode 100644 index 00000000000..d679b6b1397 --- /dev/null +++ b/src/core/prompts/sections/__tests__/agent-rules.spec.ts @@ -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") + }) +}) diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index 927d3585c9d..26d8b3fb671 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -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" }) diff --git a/src/core/prompts/sections/agent-rules.ts b/src/core/prompts/sections/agent-rules.ts new file mode 100644 index 00000000000..93e453ffaf8 --- /dev/null +++ b/src/core/prompts/sections/agent-rules.ts @@ -0,0 +1,200 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import { findUpDirectoryChain } from "../../fs/find-up" + +const AGENT_RULE_FILENAME = "AGENTS.md" +const MAX_AGENT_RULE_FILES = 6 +const MAX_AGENT_RULE_CHARS = 10000 +const AGENT_RULES_PRELOADED_NOTICE = + "(The following rules are loaded automatically from AGENTS.md files and are already available in this context. Do not use read_file for AGENTS.md unless troubleshooting.)\n" + +type AgentRuleContent = { + filename: string + content: string + orderIndex: number +} + +type AgentRuleFile = { + filename: string + resolvedPath: string + content: string + orderIndex: number +} + +type AgentRulesOptions = { + cwd: string + activePath?: string + readFile: (filePath: string) => Promise +} + +function normalizePathForCompare(value: string): string { + return path.resolve(value) +} + +function isPathWithin(parent: string, child: string): boolean { + if (!parent || !child) { + return false + } + const relativePath = path.relative(parent, child) + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) +} + +function getAgentRulesSearchStart(cwd: string, activePath?: string): string { + if (!activePath) { + return cwd + } + const activeDir = path.dirname(activePath) + return isPathWithin(cwd, activeDir) ? activeDir : cwd +} + +function toPosixPath(value: string): string { + return value.replace(/\\/g, "/") +} + +async function resolveAgentRuleFilePath(agentPath: string): Promise { + const lstat = typeof fs.lstat === "function" ? fs.lstat : undefined + if (!lstat) { + return agentPath + } + + try { + const stats = await lstat(agentPath) + if (!stats?.isSymbolicLink || !stats.isSymbolicLink()) { + return agentPath + } + } catch (error) { + return null + } + + const readlink = typeof fs.readlink === "function" ? fs.readlink : undefined + const stat = typeof fs.stat === "function" ? fs.stat : undefined + if (!readlink || !stat) { + return agentPath + } + + try { + const linkTarget = await readlink(agentPath) + const resolvedTarget = path.resolve(path.dirname(agentPath), linkTarget) + const resolvedStats = await stat(resolvedTarget) + if (!resolvedStats?.isFile || !resolvedStats.isFile()) { + return null + } + return resolvedTarget + } catch (error) { + return agentPath + } +} + +async function readAgentRulesFile( + filePath: string, + readFile: (filePath: string) => Promise, +): Promise<{ resolvedPath: string; content: string } | null> { + const resolvedPath = await resolveAgentRuleFilePath(filePath) + if (!resolvedPath) { + return null + } + const content = await readFile(resolvedPath) + if (!content) { + return null + } + return { resolvedPath, content } +} + +function truncateAgentRulesContent(content: string, maxLength: number): string { + if (content.length <= maxLength) { + return content + } + const truncationNote = "\n[truncated]" + if (maxLength <= truncationNote.length) { + return content.slice(0, maxLength) + } + return content.slice(0, maxLength - truncationNote.length) + truncationNote +} + +function buildAgentRulesOutput(ruleContents: AgentRuleContent[]): string { + if (ruleContents.length === 0) { + return "" + } + + const prioritizedRules = [...ruleContents].sort((a, b) => b.orderIndex - a.orderIndex) + const selectedRules: Array<{ header: string; content: string; orderIndex: number }> = [] + let remainingChars = MAX_AGENT_RULE_CHARS - (AGENT_RULES_PRELOADED_NOTICE.length + 1) + if (remainingChars <= 0) { + return "" + } + + for (const rule of prioritizedRules) { + const header = `# Agent Rules Standard (${rule.filename}):\n` + if (remainingChars <= header.length) { + continue + } + const maxContentLength = remainingChars - header.length + const truncatedContent = truncateAgentRulesContent(rule.content, maxContentLength) + selectedRules.push({ + header, + content: truncatedContent, + orderIndex: rule.orderIndex, + }) + remainingChars -= header.length + truncatedContent.length + if (remainingChars <= 0) { + break + } + } + + const body = selectedRules + .sort((a, b) => a.orderIndex - b.orderIndex) + .map((rule) => `${rule.header}${rule.content}`) + .join("\n\n") + + return body ? `${AGENT_RULES_PRELOADED_NOTICE}\n${body}` : "" +} + +export async function loadAgentRulesContent(options: AgentRulesOptions): Promise { + const normalizedCwd = normalizePathForCompare(options.cwd) + const searchStart = normalizePathForCompare(getAgentRulesSearchStart(normalizedCwd, options.activePath)) + const startDir = isPathWithin(normalizedCwd, searchStart) ? searchStart : normalizedCwd + + const directories = findUpDirectoryChain(startDir, normalizedCwd).reverse() + const seenPaths = new Set() + const localRules: AgentRuleFile[] = [] + + for (const dirPath of directories) { + const agentPath = path.join(dirPath, AGENT_RULE_FILENAME) + const rule = await readAgentRulesFile(agentPath, options.readFile) + if (!rule || seenPaths.has(rule.resolvedPath)) { + continue + } + seenPaths.add(rule.resolvedPath) + const label = toPosixPath(path.relative(normalizedCwd, agentPath) || AGENT_RULE_FILENAME) + localRules.push({ + filename: label, + resolvedPath: rule.resolvedPath, + content: rule.content, + orderIndex: localRules.length, + }) + } + + const globalAgentsPath = path.join(os.homedir(), ".kilocode", AGENT_RULE_FILENAME) + const globalRule = await readAgentRulesFile(globalAgentsPath, options.readFile) + + const ruleContents: AgentRuleContent[] = [] + let globalCount = 0 + if (globalRule && !seenPaths.has(globalRule.resolvedPath)) { + ruleContents.push({ + filename: "~/.kilocode/AGENTS.md", + content: globalRule.content, + orderIndex: -1, + }) + globalCount = 1 + } + + const maxLocalFiles = Math.max(0, MAX_AGENT_RULE_FILES - globalCount) + const localRulesLimited = maxLocalFiles > 0 ? localRules.slice(-maxLocalFiles) : [] + + for (const rule of localRulesLimited) { + ruleContents.push({ filename: rule.filename, content: rule.content, orderIndex: rule.orderIndex }) + } + + return buildAgentRulesOutput(ruleContents) +} diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 7b6a7289d2f..df30b4a1570 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -26,6 +26,7 @@ import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" import { LANGUAGES } from "../../../shared/language" import { ClineRulesToggles } from "../../../shared/cline-rules" // kilocode_change import { getRooDirectoriesForCwd } from "../../../services/roo-config" +import { loadAgentRulesContent } from "./agent-rules" /** * Safely read a file and return its trimmed content @@ -251,50 +252,19 @@ export async function loadRuleFiles(cwd: string): Promise { } /** - * Load AGENTS.md or AGENT.md file from the project root if it exists - * Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility + * Load AGENTS.md file from the project scope if it exists */ async function loadAgentRulesFile(cwd: string): Promise { - // Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative) - const filenames = ["AGENTS.md", "AGENT.md"] - - for (const filename of filenames) { - try { - const agentPath = path.join(cwd, filename) - let resolvedPath = agentPath - - // Check if file exists and handle symlinks - try { - const stats = await fs.lstat(agentPath) - if (stats.isSymbolicLink()) { - // Create a temporary fileInfo array to use with resolveSymLink - const fileInfo: Array<{ originalPath: string; resolvedPath: string }> = [] - - // Use the existing resolveSymLink function to handle symlink resolution - await resolveSymLink(agentPath, fileInfo, 0) - - // Extract the resolved path from fileInfo - // kilocode_change start - add null check for fileInfo[0] - if (fileInfo.length > 0 && fileInfo[0]) { - // kilocode_change end - resolvedPath = fileInfo[0].resolvedPath - } - } - } catch (err) { - // If lstat fails (file doesn't exist), try next filename - continue - } - - // Read the content from the resolved path - const content = await safeReadFile(resolvedPath) - if (content) { - return `# Agent Rules Standard (${filename}):\n${content}` - } - } catch (err) { - // Silently ignore errors - agent rules files are optional - } + try { + const activePath = vscodeAPI?.window?.activeTextEditor?.document?.uri?.fsPath + return await loadAgentRulesContent({ + cwd, + ...(activePath ? { activePath } : {}), + readFile: safeReadFile, + }) + } catch (error) { + return "" } - return "" } export async function addCustomInstructions(