Skip to content

Commit 39a73d4

Browse files
authored
feat: dynamically resolve AGENTS.md files from subdirectories as agent explores them (anomalyco#10678)
1 parent b1fbfa7 commit 39a73d4

16 files changed

Lines changed: 282 additions & 106 deletions

File tree

packages/opencode/src/cli/cmd/debug/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ async function createToolContext(agent: Agent.Info) {
153153
callID: Identifier.ascending("part"),
154154
agent: agent.name,
155155
abort: new AbortController().signal,
156+
messages: [],
156157
metadata: () => {},
157158
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
158159
for (const pattern of req.patterns) {

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,10 +1693,29 @@ function Glob(props: ToolProps<typeof GlobTool>) {
16931693
}
16941694

16951695
function Read(props: ToolProps<typeof ReadTool>) {
1696+
const { theme } = useTheme()
1697+
const loaded = createMemo(() => {
1698+
if (props.part.state.status !== "completed") return []
1699+
if (props.part.state.time.compacted) return []
1700+
const value = props.metadata.loaded
1701+
if (!value || !Array.isArray(value)) return []
1702+
return value.filter((p): p is string => typeof p === "string")
1703+
})
16961704
return (
1697-
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
1698-
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
1699-
</InlineTool>
1705+
<>
1706+
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
1707+
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
1708+
</InlineTool>
1709+
<For each={loaded()}>
1710+
{(filepath) => (
1711+
<box paddingLeft={3}>
1712+
<text paddingLeft={3} fg={theme.textMuted}>
1713+
↳ Loaded {normalizePath(filepath)}
1714+
</text>
1715+
</box>
1716+
)}
1717+
</For>
1718+
</>
17001719
)
17011720
}
17021721

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import path from "path"
2+
import os from "os"
3+
import { Global } from "../global"
4+
import { Filesystem } from "../util/filesystem"
5+
import { Config } from "../config/config"
6+
import { Instance } from "../project/instance"
7+
import { Flag } from "@/flag/flag"
8+
import { Log } from "../util/log"
9+
import type { MessageV2 } from "./message-v2"
10+
11+
const log = Log.create({ service: "instruction" })
12+
13+
const FILES = [
14+
"AGENTS.md",
15+
"CLAUDE.md",
16+
"CONTEXT.md", // deprecated
17+
]
18+
19+
function globalFiles() {
20+
const files = [path.join(Global.Path.config, "AGENTS.md")]
21+
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
22+
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
23+
}
24+
if (Flag.OPENCODE_CONFIG_DIR) {
25+
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
26+
}
27+
return files
28+
}
29+
30+
async function resolveRelative(instruction: string): Promise<string[]> {
31+
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
32+
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
33+
}
34+
if (!Flag.OPENCODE_CONFIG_DIR) {
35+
log.warn(
36+
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
37+
)
38+
return []
39+
}
40+
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
41+
}
42+
43+
export namespace InstructionPrompt {
44+
export async function systemPaths() {
45+
const config = await Config.get()
46+
const paths = new Set<string>()
47+
48+
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
49+
for (const file of FILES) {
50+
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
51+
if (matches.length > 0) {
52+
matches.forEach((p) => paths.add(path.resolve(p)))
53+
break
54+
}
55+
}
56+
}
57+
58+
for (const file of globalFiles()) {
59+
if (await Bun.file(file).exists()) {
60+
paths.add(path.resolve(file))
61+
break
62+
}
63+
}
64+
65+
if (config.instructions) {
66+
for (let instruction of config.instructions) {
67+
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
68+
if (instruction.startsWith("~/")) {
69+
instruction = path.join(os.homedir(), instruction.slice(2))
70+
}
71+
const matches = path.isAbsolute(instruction)
72+
? await Array.fromAsync(
73+
new Bun.Glob(path.basename(instruction)).scan({
74+
cwd: path.dirname(instruction),
75+
absolute: true,
76+
onlyFiles: true,
77+
}),
78+
).catch(() => [])
79+
: await resolveRelative(instruction)
80+
matches.forEach((p) => paths.add(path.resolve(p)))
81+
}
82+
}
83+
84+
return paths
85+
}
86+
87+
export async function system() {
88+
const config = await Config.get()
89+
const paths = await systemPaths()
90+
91+
const files = Array.from(paths).map(async (p) => {
92+
const content = await Bun.file(p)
93+
.text()
94+
.catch(() => "")
95+
return content ? "Instructions from: " + p + "\n" + content : ""
96+
})
97+
98+
const urls: string[] = []
99+
if (config.instructions) {
100+
for (const instruction of config.instructions) {
101+
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
102+
urls.push(instruction)
103+
}
104+
}
105+
}
106+
const fetches = urls.map((url) =>
107+
fetch(url, { signal: AbortSignal.timeout(5000) })
108+
.then((res) => (res.ok ? res.text() : ""))
109+
.catch(() => "")
110+
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
111+
)
112+
113+
return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
114+
}
115+
116+
export function loaded(messages: MessageV2.WithParts[]) {
117+
const paths = new Set<string>()
118+
for (const msg of messages) {
119+
for (const part of msg.parts) {
120+
if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
121+
if (part.state.time.compacted) continue
122+
const loaded = part.state.metadata?.loaded
123+
if (!loaded || !Array.isArray(loaded)) continue
124+
for (const p of loaded) {
125+
if (typeof p === "string") paths.add(p)
126+
}
127+
}
128+
}
129+
}
130+
return paths
131+
}
132+
133+
export async function find(dir: string) {
134+
for (const file of FILES) {
135+
const filepath = path.resolve(path.join(dir, file))
136+
if (await Bun.file(filepath).exists()) return filepath
137+
}
138+
}
139+
140+
export async function resolve(messages: MessageV2.WithParts[], filepath: string) {
141+
const system = await systemPaths()
142+
const already = loaded(messages)
143+
const results: { filepath: string; content: string }[] = []
144+
145+
let current = path.dirname(path.resolve(filepath))
146+
const root = path.resolve(Instance.directory)
147+
148+
while (current.startsWith(root)) {
149+
const found = await find(current)
150+
if (found && !system.has(found) && !already.has(found)) {
151+
const content = await Bun.file(found)
152+
.text()
153+
.catch(() => undefined)
154+
if (content) {
155+
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
156+
}
157+
}
158+
if (current === root) break
159+
current = path.dirname(current)
160+
}
161+
162+
return results
163+
}
164+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ export namespace MessageV2 {
631631
sessionID: Identifier.schema("session"),
632632
messageID: Identifier.schema("message"),
633633
}),
634-
async (input) => {
634+
async (input): Promise<WithParts> => {
635635
return {
636636
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
637637
parts: await parts(input.messageID),

packages/opencode/src/session/prompt.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Instance } from "../project/instance"
1515
import { Bus } from "../bus"
1616
import { ProviderTransform } from "../provider/transform"
1717
import { SystemPrompt } from "./system"
18+
import { InstructionPrompt } from "./instruction"
1819
import { Plugin } from "../plugin"
1920
import PROMPT_PLAN from "../session/prompt/plan.txt"
2021
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
@@ -386,6 +387,7 @@ export namespace SessionPrompt {
386387
abort,
387388
callID: part.callID,
388389
extra: { bypassAgentCheck: true },
390+
messages: msgs,
389391
async metadata(input) {
390392
await Session.updatePart({
391393
...part,
@@ -561,6 +563,7 @@ export namespace SessionPrompt {
561563
tools: lastUser.tools,
562564
processor,
563565
bypassAgentCheck,
566+
messages: msgs,
564567
})
565568

566569
if (step === 1) {
@@ -598,7 +601,7 @@ export namespace SessionPrompt {
598601
agent,
599602
abort,
600603
sessionID,
601-
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
604+
system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
602605
messages: [
603606
...MessageV2.toModelMessages(sessionMessages, model),
604607
...(isLastStep
@@ -650,6 +653,7 @@ export namespace SessionPrompt {
650653
tools?: Record<string, boolean>
651654
processor: SessionProcessor.Info
652655
bypassAgentCheck: boolean
656+
messages: MessageV2.WithParts[]
653657
}) {
654658
using _ = log.time("resolveTools")
655659
const tools: Record<string, AITool> = {}
@@ -661,6 +665,7 @@ export namespace SessionPrompt {
661665
callID: options.toolCallId,
662666
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
663667
agent: input.agent.name,
668+
messages: input.messages,
664669
metadata: async (val: { title?: string; metadata?: any }) => {
665670
const match = input.processor.partFromToolCall(options.toolCallId)
666671
if (match && match.state.status === "running") {
@@ -1008,6 +1013,7 @@ export namespace SessionPrompt {
10081013
agent: input.agent!,
10091014
messageID: info.id,
10101015
extra: { bypassCwdCheck: true, model },
1016+
messages: [],
10111017
metadata: async () => {},
10121018
ask: async () => {},
10131019
}
@@ -1069,6 +1075,7 @@ export namespace SessionPrompt {
10691075
agent: input.agent!,
10701076
messageID: info.id,
10711077
extra: { bypassCwdCheck: true },
1078+
messages: [],
10721079
metadata: async () => {},
10731080
ask: async () => {},
10741081
}
Lines changed: 0 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,14 @@
11
import { Ripgrep } from "../file/ripgrep"
2-
import { Global } from "../global"
3-
import { Filesystem } from "../util/filesystem"
4-
import { Config } from "../config/config"
5-
import { Log } from "../util/log"
62

73
import { Instance } from "../project/instance"
8-
import path from "path"
9-
import os from "os"
104

115
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
126
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
137
import PROMPT_BEAST from "./prompt/beast.txt"
148
import PROMPT_GEMINI from "./prompt/gemini.txt"
15-
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
169

1710
import PROMPT_CODEX from "./prompt/codex_header.txt"
1811
import type { Provider } from "@/provider/provider"
19-
import { Flag } from "@/flag/flag"
20-
21-
const log = Log.create({ service: "system-prompt" })
22-
23-
async function resolveRelativeInstruction(instruction: string): Promise<string[]> {
24-
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
25-
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
26-
}
27-
if (!Flag.OPENCODE_CONFIG_DIR) {
28-
log.warn(
29-
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
30-
)
31-
return []
32-
}
33-
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
34-
}
3512

3613
export namespace SystemPrompt {
3714
export function instructions() {
@@ -72,81 +49,4 @@ export namespace SystemPrompt {
7249
].join("\n"),
7350
]
7451
}
75-
76-
const LOCAL_RULE_FILES = [
77-
"AGENTS.md",
78-
"CLAUDE.md",
79-
"CONTEXT.md", // deprecated
80-
]
81-
const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
82-
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
83-
GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
84-
}
85-
86-
if (Flag.OPENCODE_CONFIG_DIR) {
87-
GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
88-
}
89-
90-
export async function custom() {
91-
const config = await Config.get()
92-
const paths = new Set<string>()
93-
94-
// Only scan local rule files when project discovery is enabled
95-
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
96-
for (const localRuleFile of LOCAL_RULE_FILES) {
97-
const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
98-
if (matches.length > 0) {
99-
matches.forEach((path) => paths.add(path))
100-
break
101-
}
102-
}
103-
}
104-
105-
for (const globalRuleFile of GLOBAL_RULE_FILES) {
106-
if (await Bun.file(globalRuleFile).exists()) {
107-
paths.add(globalRuleFile)
108-
break
109-
}
110-
}
111-
112-
const urls: string[] = []
113-
if (config.instructions) {
114-
for (let instruction of config.instructions) {
115-
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
116-
urls.push(instruction)
117-
continue
118-
}
119-
if (instruction.startsWith("~/")) {
120-
instruction = path.join(os.homedir(), instruction.slice(2))
121-
}
122-
let matches: string[] = []
123-
if (path.isAbsolute(instruction)) {
124-
matches = await Array.fromAsync(
125-
new Bun.Glob(path.basename(instruction)).scan({
126-
cwd: path.dirname(instruction),
127-
absolute: true,
128-
onlyFiles: true,
129-
}),
130-
).catch(() => [])
131-
} else {
132-
matches = await resolveRelativeInstruction(instruction)
133-
}
134-
matches.forEach((path) => paths.add(path))
135-
}
136-
}
137-
138-
const foundFiles = Array.from(paths).map((p) =>
139-
Bun.file(p)
140-
.text()
141-
.catch(() => "")
142-
.then((x) => "Instructions from: " + p + "\n" + x),
143-
)
144-
const foundUrls = urls.map((url) =>
145-
fetch(url, { signal: AbortSignal.timeout(5000) })
146-
.then((res) => (res.ok ? res.text() : ""))
147-
.catch(() => "")
148-
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
149-
)
150-
return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean))
151-
}
15252
}

0 commit comments

Comments
 (0)