From 67148a410cac19ef268406b0a8946614e1d9701b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 08:27:29 -0500 Subject: [PATCH 1/7] feat: dynamically resolve AGENTS.md files from subdirectories as agent explores them --- packages/opencode/src/cli/cmd/debug/agent.ts | 1 + packages/opencode/src/session/instruction.ts | 147 ++++++++++++++++++ packages/opencode/src/session/prompt.ts | 9 +- packages/opencode/src/session/system.ts | 100 ------------ packages/opencode/src/tool/read.ts | 10 ++ packages/opencode/src/tool/tool.ts | 1 + .../opencode/test/tool/apply_patch.test.ts | 1 + packages/opencode/test/tool/bash.test.ts | 1 + .../test/tool/external-directory.test.ts | 1 + packages/opencode/test/tool/grep.test.ts | 1 + packages/opencode/test/tool/question.test.ts | 1 + packages/opencode/test/tool/read.test.ts | 1 + 12 files changed, 173 insertions(+), 101 deletions(-) create mode 100644 packages/opencode/src/session/instruction.ts diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index d1236ff40b..fe30034859 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/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) { for (const pattern of req.patterns) { diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts new file mode 100644 index 0000000000..436d0b66f1 --- /dev/null +++ b/packages/opencode/src/session/instruction.ts @@ -0,0 +1,147 @@ +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"] + +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 { + 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() + + 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() + 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 filepath = part.state.metadata?.loaded + if (filepath) paths.add(filepath) + } + } + } + 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[], dir: string) { + const filepath = await find(dir) + if (!filepath) return undefined + + if ((await systemPaths()).has(filepath)) return undefined + if (loaded(messages).has(filepath)) return undefined + + const content = await Bun.file(filepath) + .text() + .catch(() => undefined) + if (!content) return undefined + + return { filepath, content: "Instructions from: " + filepath + "\n" + content } + } +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8554b44a72..464086fa62 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/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 processor: SessionProcessor.Info bypassAgentCheck: boolean + messages: MessageV2.WithParts[] }) { using _ = log.time("resolveTools") const tools: Record = {} @@ -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 () => {}, } diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index b055bd10e7..d34a086fe4 100644 --- a/packages/opencode/src/session/system.ts +++ b/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 { - 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() - - // 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)) - } } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 3b1484cbc0..abb4241417 100644 --- a/packages/opencode/src/tool/read.ts +++ b/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,9 @@ export const ReadTool = Tool.define("read", { throw new Error(`File not found: ${filepath}`) } + const dir = path.dirname(filepath) + const instruction = await InstructionPrompt.resolve(ctx.messages, dir) + // 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 +76,7 @@ export const ReadTool = Tool.define("read", { metadata: { preview: msg, truncated: false, + ...(instruction && { loaded: instruction.filepath }), }, attachments: [ { @@ -133,12 +138,17 @@ export const ReadTool = Tool.define("read", { LSP.touchFile(filepath, false) FileTime.read(ctx.sessionID, filepath) + if (instruction) { + output += `\n\n\n${instruction.content}\n` + } + return { title, output, metadata: { preview, truncated, + ...(instruction && { loaded: instruction.filepath }), }, } }, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 78ab325af4..3d17ea192d 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/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): Promise } diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index de48b074fe..a08e235885 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -11,6 +11,7 @@ const baseCtx = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 750ff8193e..454293c8fb 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -12,6 +12,7 @@ const ctx = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, ask: async () => {}, } diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index b21f6a9715..33c5e2c739 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -11,6 +11,7 @@ const baseCtx: Omit = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, } diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index a79d931575..e774580df6 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -10,6 +10,7 @@ const ctx = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, ask: async () => {}, } diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index c11b5ed6ac..4a436186db 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/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 () => {}, } diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7250bd2fd1..94d009f8dc 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -14,6 +14,7 @@ const ctx = { callID: "", agent: "build", abort: AbortSignal.any([]), + messages: [], metadata: () => {}, ask: async () => {}, } From e2d9638b3c93cd41909804bfa127e9b7099b135d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 08:44:29 -0500 Subject: [PATCH 2/7] cleanup --- .../src/cli/cmd/tui/routes/session/index.tsx | 21 ++++++++++++++++--- packages/opencode/src/session/instruction.ts | 6 +++++- packages/web/src/content/docs/rules.mdx | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 2a5a901a21..822c66b203 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1705,10 +1705,25 @@ function Glob(props: ToolProps) { } function Read(props: ToolProps) { + const { theme } = useTheme() + const loaded = createMemo(() => { + if (props.part.state.status !== "completed") return + if (props.part.state.time.compacted) return + return props.metadata.loaded + }) return ( - - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} - + <> + + Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + + + + + ↳ Loaded {normalizePath(loaded()!)} + + + + ) } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 436d0b66f1..33db55a20e 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -10,7 +10,11 @@ import type { MessageV2 } from "./message-v2" const log = Log.create({ service: "instruction" }) -const FILES = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"] +const FILES = [ + "AGENTS.md", + "CLAUDE.md", + "CONTEXT.md", // deprecated +] function globalFiles() { const files = [path.join(Global.Path.config, "AGENTS.md")] diff --git a/packages/web/src/content/docs/rules.mdx b/packages/web/src/content/docs/rules.mdx index 3a170019a7..26e6de9066 100644 --- a/packages/web/src/content/docs/rules.mdx +++ b/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) From 1187b38c4cadbaf06d911533ce27f80506019a75 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 08:49:44 -0500 Subject: [PATCH 3/7] tweak --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 822c66b203..aceb0c5096 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1719,7 +1719,7 @@ function Read(props: ToolProps) { - ↳ Loaded {normalizePath(loaded()!)} + ↳ Loaded {normalizePath(loaded())} From 6291973fa38c42b860e647667a71dd22a8735374 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 08:56:54 -0500 Subject: [PATCH 4/7] tweak: ensure typescript doesnt shit the bed --- packages/opencode/src/session/message-v2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 83ca72addb..7c8689037e 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/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 => { return { info: await Storage.read(["message", input.sessionID, input.messageID]), parts: await parts(input.messageID), From 7f30ae93c82b8b55749796920819d22ec9803051 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 09:04:36 -0500 Subject: [PATCH 5/7] fixes --- .../src/cli/cmd/tui/routes/session/index.tsx | 24 +++++++----- packages/opencode/src/session/instruction.ts | 39 +++++++++++++------ packages/opencode/src/tool/read.ts | 11 +++--- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index aceb0c5096..8929fdbdc4 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1707,22 +1707,26 @@ function Glob(props: ToolProps) { function Read(props: ToolProps) { const { theme } = useTheme() const loaded = createMemo(() => { - if (props.part.state.status !== "completed") return - if (props.part.state.time.compacted) return - return props.metadata.loaded + if (props.part.state.status !== "completed") return [] + if (props.part.state.time.compacted) return [] + const value = props.metadata.loaded + if (!value) return [] + return Array.isArray(value) ? value : [value] }) return ( <> Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} - - - - ↳ Loaded {normalizePath(loaded())} - - - + + {(filepath) => ( + + + ↳ Loaded {normalizePath(filepath)} + + + )} + ) } diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 33db55a20e..7ea65e86c2 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -119,8 +119,13 @@ export namespace InstructionPrompt { for (const part of msg.parts) { if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") { if (part.state.time.compacted) continue - const filepath = part.state.metadata?.loaded - if (filepath) paths.add(filepath) + const loaded = part.state.metadata?.loaded + if (!loaded) continue + if (Array.isArray(loaded)) { + loaded.forEach((p) => paths.add(p)) + } else { + paths.add(loaded) + } } } } @@ -134,18 +139,28 @@ export namespace InstructionPrompt { } } - export async function resolve(messages: MessageV2.WithParts[], dir: string) { - const filepath = await find(dir) - if (!filepath) return undefined + export async function resolve(messages: MessageV2.WithParts[], filepath: string) { + const system = await systemPaths() + const already = loaded(messages) + const results: { filepath: string; content: string }[] = [] - if ((await systemPaths()).has(filepath)) return undefined - if (loaded(messages).has(filepath)) return undefined + let current = path.dirname(path.resolve(filepath)) + const root = path.resolve(Instance.directory) - const content = await Bun.file(filepath) - .text() - .catch(() => undefined) - if (!content) return undefined + 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 { filepath, content: "Instructions from: " + filepath + "\n" + content } + return results } } diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index abb4241417..028a007ccc 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -60,8 +60,7 @@ export const ReadTool = Tool.define("read", { throw new Error(`File not found: ${filepath}`) } - const dir = path.dirname(filepath) - const instruction = await InstructionPrompt.resolve(ctx.messages, dir) + const instructions = await InstructionPrompt.resolve(ctx.messages, filepath) // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files) const isImage = @@ -76,7 +75,7 @@ export const ReadTool = Tool.define("read", { metadata: { preview: msg, truncated: false, - ...(instruction && { loaded: instruction.filepath }), + ...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }), }, attachments: [ { @@ -138,8 +137,8 @@ export const ReadTool = Tool.define("read", { LSP.touchFile(filepath, false) FileTime.read(ctx.sessionID, filepath) - if (instruction) { - output += `\n\n\n${instruction.content}\n` + if (instructions.length > 0) { + output += `\n\n\n${instructions.map((i) => i.content).join("\n\n")}\n` } return { @@ -148,7 +147,7 @@ export const ReadTool = Tool.define("read", { metadata: { preview, truncated, - ...(instruction && { loaded: instruction.filepath }), + ...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }), }, } }, From 033ee50508c5c1ff7cfd10fd30b43468bde5253b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 09:07:33 -0500 Subject: [PATCH 6/7] fixes --- .../opencode/src/cli/cmd/tui/routes/session/index.tsx | 4 ++-- packages/opencode/src/session/instruction.ts | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8929fdbdc4..db55fcc7b8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1710,8 +1710,8 @@ function Read(props: ToolProps) { if (props.part.state.status !== "completed") return [] if (props.part.state.time.compacted) return [] const value = props.metadata.loaded - if (!value) return [] - return Array.isArray(value) ? value : [value] + if (!value || !Array.isArray(value)) return [] + return value.filter((p): p is string => typeof p === "string") }) return ( <> diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 7ea65e86c2..d413e80f69 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -120,11 +120,9 @@ export namespace InstructionPrompt { 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) continue - if (Array.isArray(loaded)) { - loaded.forEach((p) => paths.add(p)) - } else { - paths.add(loaded) + if (!loaded || !Array.isArray(loaded)) continue + for (const p of loaded) { + if (typeof p === "string") paths.add(p) } } } From 225345a050b9d74c0c626353fcd330031e4025e8 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 10:44:57 -0500 Subject: [PATCH 7/7] add tests --- .../opencode/test/session/instruction.test.ts | 46 +++++++++++++++++++ packages/opencode/test/tool/read.test.ts | 23 ++++++++++ 2 files changed, 69 insertions(+) create mode 100644 packages/opencode/test/session/instruction.test.ts diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts new file mode 100644 index 0000000000..2c44a266e0 --- /dev/null +++ b/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")) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 94d009f8dc..afa14bc6cb 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -331,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")) + }, + }) + }) +})