소스 검색

feat: dynamically resolve AGENTS.md files from subdirectories as agent explores them (#10678)

Aiden Cline 3 주 전
부모
커밋
39a73d4894

+ 1 - 0
packages/opencode/src/cli/cmd/debug/agent.ts

@@ -153,6 +153,7 @@ async function createToolContext(agent: Agent.Info) {
     callID: Identifier.ascending("part"),
     agent: agent.name,
     abort: new AbortController().signal,
+    messages: [],
     metadata: () => {},
     async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
       for (const pattern of req.patterns) {

+ 22 - 3
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -1693,10 +1693,29 @@ function Glob(props: ToolProps<typeof GlobTool>) {
 }
 
 function Read(props: ToolProps<typeof ReadTool>) {
+  const { theme } = useTheme()
+  const loaded = createMemo(() => {
+    if (props.part.state.status !== "completed") return []
+    if (props.part.state.time.compacted) return []
+    const value = props.metadata.loaded
+    if (!value || !Array.isArray(value)) return []
+    return value.filter((p): p is string => typeof p === "string")
+  })
   return (
-    <InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
-      Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
-    </InlineTool>
+    <>
+      <InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
+        Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
+      </InlineTool>
+      <For each={loaded()}>
+        {(filepath) => (
+          <box paddingLeft={3}>
+            <text paddingLeft={3} fg={theme.textMuted}>
+              ↳ Loaded {normalizePath(filepath)}
+            </text>
+          </box>
+        )}
+      </For>
+    </>
   )
 }
 

+ 164 - 0
packages/opencode/src/session/instruction.ts

@@ -0,0 +1,164 @@
+import path from "path"
+import os from "os"
+import { Global } from "../global"
+import { Filesystem } from "../util/filesystem"
+import { Config } from "../config/config"
+import { Instance } from "../project/instance"
+import { Flag } from "@/flag/flag"
+import { Log } from "../util/log"
+import type { MessageV2 } from "./message-v2"
+
+const log = Log.create({ service: "instruction" })
+
+const FILES = [
+  "AGENTS.md",
+  "CLAUDE.md",
+  "CONTEXT.md", // deprecated
+]
+
+function globalFiles() {
+  const files = [path.join(Global.Path.config, "AGENTS.md")]
+  if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
+    files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
+  }
+  if (Flag.OPENCODE_CONFIG_DIR) {
+    files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
+  }
+  return files
+}
+
+async function resolveRelative(instruction: string): Promise<string[]> {
+  if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+    return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
+  }
+  if (!Flag.OPENCODE_CONFIG_DIR) {
+    log.warn(
+      `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
+    )
+    return []
+  }
+  return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
+}
+
+export namespace InstructionPrompt {
+  export async function systemPaths() {
+    const config = await Config.get()
+    const paths = new Set<string>()
+
+    if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+      for (const file of FILES) {
+        const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
+        if (matches.length > 0) {
+          matches.forEach((p) => paths.add(path.resolve(p)))
+          break
+        }
+      }
+    }
+
+    for (const file of globalFiles()) {
+      if (await Bun.file(file).exists()) {
+        paths.add(path.resolve(file))
+        break
+      }
+    }
+
+    if (config.instructions) {
+      for (let instruction of config.instructions) {
+        if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
+        if (instruction.startsWith("~/")) {
+          instruction = path.join(os.homedir(), instruction.slice(2))
+        }
+        const matches = path.isAbsolute(instruction)
+          ? await Array.fromAsync(
+              new Bun.Glob(path.basename(instruction)).scan({
+                cwd: path.dirname(instruction),
+                absolute: true,
+                onlyFiles: true,
+              }),
+            ).catch(() => [])
+          : await resolveRelative(instruction)
+        matches.forEach((p) => paths.add(path.resolve(p)))
+      }
+    }
+
+    return paths
+  }
+
+  export async function system() {
+    const config = await Config.get()
+    const paths = await systemPaths()
+
+    const files = Array.from(paths).map(async (p) => {
+      const content = await Bun.file(p)
+        .text()
+        .catch(() => "")
+      return content ? "Instructions from: " + p + "\n" + content : ""
+    })
+
+    const urls: string[] = []
+    if (config.instructions) {
+      for (const instruction of config.instructions) {
+        if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
+          urls.push(instruction)
+        }
+      }
+    }
+    const fetches = urls.map((url) =>
+      fetch(url, { signal: AbortSignal.timeout(5000) })
+        .then((res) => (res.ok ? res.text() : ""))
+        .catch(() => "")
+        .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
+    )
+
+    return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
+  }
+
+  export function loaded(messages: MessageV2.WithParts[]) {
+    const paths = new Set<string>()
+    for (const msg of messages) {
+      for (const part of msg.parts) {
+        if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
+          if (part.state.time.compacted) continue
+          const loaded = part.state.metadata?.loaded
+          if (!loaded || !Array.isArray(loaded)) continue
+          for (const p of loaded) {
+            if (typeof p === "string") paths.add(p)
+          }
+        }
+      }
+    }
+    return paths
+  }
+
+  export async function find(dir: string) {
+    for (const file of FILES) {
+      const filepath = path.resolve(path.join(dir, file))
+      if (await Bun.file(filepath).exists()) return filepath
+    }
+  }
+
+  export async function resolve(messages: MessageV2.WithParts[], filepath: string) {
+    const system = await systemPaths()
+    const already = loaded(messages)
+    const results: { filepath: string; content: string }[] = []
+
+    let current = path.dirname(path.resolve(filepath))
+    const root = path.resolve(Instance.directory)
+
+    while (current.startsWith(root)) {
+      const found = await find(current)
+      if (found && !system.has(found) && !already.has(found)) {
+        const content = await Bun.file(found)
+          .text()
+          .catch(() => undefined)
+        if (content) {
+          results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
+        }
+      }
+      if (current === root) break
+      current = path.dirname(current)
+    }
+
+    return results
+  }
+}

+ 1 - 1
packages/opencode/src/session/message-v2.ts

@@ -631,7 +631,7 @@ export namespace MessageV2 {
       sessionID: Identifier.schema("session"),
       messageID: Identifier.schema("message"),
     }),
-    async (input) => {
+    async (input): Promise<WithParts> => {
       return {
         info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
         parts: await parts(input.messageID),

+ 8 - 1
packages/opencode/src/session/prompt.ts

@@ -15,6 +15,7 @@ import { Instance } from "../project/instance"
 import { Bus } from "../bus"
 import { ProviderTransform } from "../provider/transform"
 import { SystemPrompt } from "./system"
+import { InstructionPrompt } from "./instruction"
 import { Plugin } from "../plugin"
 import PROMPT_PLAN from "../session/prompt/plan.txt"
 import BUILD_SWITCH from "../session/prompt/build-switch.txt"
@@ -386,6 +387,7 @@ export namespace SessionPrompt {
           abort,
           callID: part.callID,
           extra: { bypassAgentCheck: true },
+          messages: msgs,
           async metadata(input) {
             await Session.updatePart({
               ...part,
@@ -561,6 +563,7 @@ export namespace SessionPrompt {
         tools: lastUser.tools,
         processor,
         bypassAgentCheck,
+        messages: msgs,
       })
 
       if (step === 1) {
@@ -598,7 +601,7 @@ export namespace SessionPrompt {
         agent,
         abort,
         sessionID,
-        system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
+        system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
         messages: [
           ...MessageV2.toModelMessages(sessionMessages, model),
           ...(isLastStep
@@ -650,6 +653,7 @@ export namespace SessionPrompt {
     tools?: Record<string, boolean>
     processor: SessionProcessor.Info
     bypassAgentCheck: boolean
+    messages: MessageV2.WithParts[]
   }) {
     using _ = log.time("resolveTools")
     const tools: Record<string, AITool> = {}
@@ -661,6 +665,7 @@ export namespace SessionPrompt {
       callID: options.toolCallId,
       extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
       agent: input.agent.name,
+      messages: input.messages,
       metadata: async (val: { title?: string; metadata?: any }) => {
         const match = input.processor.partFromToolCall(options.toolCallId)
         if (match && match.state.status === "running") {
@@ -1008,6 +1013,7 @@ export namespace SessionPrompt {
                       agent: input.agent!,
                       messageID: info.id,
                       extra: { bypassCwdCheck: true, model },
+                      messages: [],
                       metadata: async () => {},
                       ask: async () => {},
                     }
@@ -1069,6 +1075,7 @@ export namespace SessionPrompt {
                   agent: input.agent!,
                   messageID: info.id,
                   extra: { bypassCwdCheck: true },
+                  messages: [],
                   metadata: async () => {},
                   ask: async () => {},
                 }

+ 0 - 100
packages/opencode/src/session/system.ts

@@ -1,37 +1,14 @@
 import { Ripgrep } from "../file/ripgrep"
-import { Global } from "../global"
-import { Filesystem } from "../util/filesystem"
-import { Config } from "../config/config"
-import { Log } from "../util/log"
 
 import { Instance } from "../project/instance"
-import path from "path"
-import os from "os"
 
 import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
 import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
 import PROMPT_BEAST from "./prompt/beast.txt"
 import PROMPT_GEMINI from "./prompt/gemini.txt"
-import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
 
 import PROMPT_CODEX from "./prompt/codex_header.txt"
 import type { Provider } from "@/provider/provider"
-import { Flag } from "@/flag/flag"
-
-const log = Log.create({ service: "system-prompt" })
-
-async function resolveRelativeInstruction(instruction: string): Promise<string[]> {
-  if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
-    return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
-  }
-  if (!Flag.OPENCODE_CONFIG_DIR) {
-    log.warn(
-      `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
-    )
-    return []
-  }
-  return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
-}
 
 export namespace SystemPrompt {
   export function instructions() {
@@ -72,81 +49,4 @@ export namespace SystemPrompt {
       ].join("\n"),
     ]
   }
-
-  const LOCAL_RULE_FILES = [
-    "AGENTS.md",
-    "CLAUDE.md",
-    "CONTEXT.md", // deprecated
-  ]
-  const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
-  if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
-    GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
-  }
-
-  if (Flag.OPENCODE_CONFIG_DIR) {
-    GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
-  }
-
-  export async function custom() {
-    const config = await Config.get()
-    const paths = new Set<string>()
-
-    // Only scan local rule files when project discovery is enabled
-    if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
-      for (const localRuleFile of LOCAL_RULE_FILES) {
-        const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
-        if (matches.length > 0) {
-          matches.forEach((path) => paths.add(path))
-          break
-        }
-      }
-    }
-
-    for (const globalRuleFile of GLOBAL_RULE_FILES) {
-      if (await Bun.file(globalRuleFile).exists()) {
-        paths.add(globalRuleFile)
-        break
-      }
-    }
-
-    const urls: string[] = []
-    if (config.instructions) {
-      for (let instruction of config.instructions) {
-        if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
-          urls.push(instruction)
-          continue
-        }
-        if (instruction.startsWith("~/")) {
-          instruction = path.join(os.homedir(), instruction.slice(2))
-        }
-        let matches: string[] = []
-        if (path.isAbsolute(instruction)) {
-          matches = await Array.fromAsync(
-            new Bun.Glob(path.basename(instruction)).scan({
-              cwd: path.dirname(instruction),
-              absolute: true,
-              onlyFiles: true,
-            }),
-          ).catch(() => [])
-        } else {
-          matches = await resolveRelativeInstruction(instruction)
-        }
-        matches.forEach((path) => paths.add(path))
-      }
-    }
-
-    const foundFiles = Array.from(paths).map((p) =>
-      Bun.file(p)
-        .text()
-        .catch(() => "")
-        .then((x) => "Instructions from: " + p + "\n" + x),
-    )
-    const foundUrls = urls.map((url) =>
-      fetch(url, { signal: AbortSignal.timeout(5000) })
-        .then((res) => (res.ok ? res.text() : ""))
-        .catch(() => "")
-        .then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
-    )
-    return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean))
-  }
 }

+ 9 - 0
packages/opencode/src/tool/read.ts

@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
 import { Instance } from "../project/instance"
 import { Identifier } from "../id/id"
 import { assertExternalDirectory } from "./external-directory"
+import { InstructionPrompt } from "../session/instruction"
 
 const DEFAULT_READ_LIMIT = 2000
 const MAX_LINE_LENGTH = 2000
@@ -59,6 +60,8 @@ export const ReadTool = Tool.define("read", {
       throw new Error(`File not found: ${filepath}`)
     }
 
+    const instructions = await InstructionPrompt.resolve(ctx.messages, filepath)
+
     // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
     const isImage =
       file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
@@ -72,6 +75,7 @@ export const ReadTool = Tool.define("read", {
         metadata: {
           preview: msg,
           truncated: false,
+          ...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
         },
         attachments: [
           {
@@ -133,12 +137,17 @@ export const ReadTool = Tool.define("read", {
     LSP.touchFile(filepath, false)
     FileTime.read(ctx.sessionID, filepath)
 
+    if (instructions.length > 0) {
+      output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
+    }
+
     return {
       title,
       output,
       metadata: {
         preview,
         truncated,
+        ...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
       },
     }
   },

+ 1 - 0
packages/opencode/src/tool/tool.ts

@@ -20,6 +20,7 @@ export namespace Tool {
     abort: AbortSignal
     callID?: string
     extra?: { [key: string]: any }
+    messages: MessageV2.WithParts[]
     metadata(input: { title?: string; metadata?: M }): void
     ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
   }

+ 46 - 0
packages/opencode/test/session/instruction.test.ts

@@ -0,0 +1,46 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { InstructionPrompt } from "../../src/session/instruction"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+describe("InstructionPrompt.resolve", () => {
+  test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
+        await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const system = await InstructionPrompt.systemPaths()
+        expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
+
+        const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"))
+        expect(results).toEqual([])
+      },
+    })
+  })
+
+  test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
+        await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const system = await InstructionPrompt.systemPaths()
+        expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
+
+        const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts"))
+        expect(results.length).toBe(1)
+        expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
+      },
+    })
+  })
+})

+ 1 - 0
packages/opencode/test/tool/apply_patch.test.ts

@@ -11,6 +11,7 @@ const baseCtx = {
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),
+  messages: [],
   metadata: () => {},
 }
 

+ 1 - 0
packages/opencode/test/tool/bash.test.ts

@@ -12,6 +12,7 @@ const ctx = {
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),
+  messages: [],
   metadata: () => {},
   ask: async () => {},
 }

+ 1 - 0
packages/opencode/test/tool/external-directory.test.ts

@@ -11,6 +11,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),
+  messages: [],
   metadata: () => {},
 }
 

+ 1 - 0
packages/opencode/test/tool/grep.test.ts

@@ -10,6 +10,7 @@ const ctx = {
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),
+  messages: [],
   metadata: () => {},
   ask: async () => {},
 }

+ 1 - 0
packages/opencode/test/tool/question.test.ts

@@ -9,6 +9,7 @@ const ctx = {
   callID: "test-call",
   agent: "test-agent",
   abort: AbortSignal.any([]),
+  messages: [],
   metadata: () => {},
   ask: async () => {},
 }

+ 24 - 0
packages/opencode/test/tool/read.test.ts

@@ -14,6 +14,7 @@ const ctx = {
   callID: "",
   agent: "build",
   abort: AbortSignal.any([]),
+  messages: [],
   metadata: () => {},
   ask: async () => {},
 }
@@ -330,3 +331,26 @@ root_type Monster;`
     })
   })
 })
+
+describe("tool.read loaded instructions", () => {
+  test("loads AGENTS.md from parent directory and includes in metadata", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
+        await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content")
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const read = await ReadTool.init()
+        const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
+        expect(result.output).toContain("test content")
+        expect(result.output).toContain("system-reminder")
+        expect(result.output).toContain("Test Instructions")
+        expect(result.metadata.loaded).toBeDefined()
+        expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md"))
+      },
+    })
+  })
+})

+ 1 - 1
packages/web/src/content/docs/rules.mdx

@@ -88,7 +88,7 @@ export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills
 
 When opencode starts, it looks for rule files in this order:
 
-1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`)
+1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`)
 2. **Global file** at `~/.config/opencode/AGENTS.md`
 3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled)