feat: dynamically resolve AGENTS.md files from subdirectories as agent explores them
This commit is contained in:
parent
022265829e
commit
67148a410c
12 changed files with 173 additions and 101 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
147
packages/opencode/src/session/instruction.ts
Normal file
147
packages/opencode/src/session/instruction.ts
Normal file
|
|
@ -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<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 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<system-reminder>\n${instruction.content}\n</system-reminder>`
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
output,
|
||||
metadata: {
|
||||
preview,
|
||||
truncated,
|
||||
...(instruction && { loaded: instruction.filepath }),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const baseCtx = {
|
|||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const ctx = {
|
|||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
|||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const ctx = {
|
|||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const ctx = {
|
|||
callID: "test-call",
|
||||
agent: "test-agent",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const ctx = {
|
|||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue