fix(core): load configured instructions (#55539)

Co-authored-by: vegerot <5297725+vegerot@users.noreply.github.com>
This commit is contained in:
Filip
2026-10-15 00:34:17 +02:00
committed by GitHub
Unverified
parent 7291f3e07a
commit 1e63a3bb73
5 changed files with 305 additions and 34 deletions
+111 -15
View File
@@ -4,13 +4,14 @@ import { define } from "@opencode/plugin/effect/plugin"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { sameDirectory } from "@opencode/util/path"
import { dirname, join } from "path"
import { Effect, PubSub, Semaphore, Stream } from "effect"
import path, { dirname, join } from "path"
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/http"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { InstructionDiscovery } from "../../instruction-discovery.js"
import { Instructions } from "../../instructions/index.js"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
type Loaded =
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
@@ -18,17 +19,19 @@ type Loaded =
export const Plugin = define({
id: "opencode.config.instruction",
effect: Effect.fn(function* () {
effect: Effect.fn(function* (ctx) {
const discovery = yield* InstructionDiscovery.Service
// Nothing this plugin watches or loads can contribute when both scopes
// are disabled; skip the resolves, the watcher fiber, and the transform.
if (!discovery.project && !discovery.global) return
// Activate even when both discovery scopes are disabled: configured `instructions`
// entries are explicit requests, not discovery.
yield* Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const changes = yield* PubSub.sliding<string>(1)
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const root = yield* fs.resolve(location.project.directory)
@@ -37,6 +40,10 @@ export const Plugin = define({
const stop = FSUtil.contains(home, start) ? home : root
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
const configured: { sources: string[]; remote: Map<string, InstructionDiscovery.File> } = {
sources: [],
remote: new Map(),
}
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
// The ancestor walk can reach the global file when the location sits
@@ -49,15 +56,15 @@ export const Plugin = define({
.filter((file) => discovery.global || file !== globalFile)
: []),
]
for (const path of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path, type: "file" })
for (const file of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path: file, type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
const content = yield* fs.readFileStringSafe(path)
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (filepath: string) {
const content = yield* fs.readFileStringSafe(filepath)
if (content !== undefined) return new InstructionDiscovery.File({ path: filepath, content })
yield* Effect.logDebug("instruction file skipped", { path: filepath, reason: "unavailable" })
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
@@ -78,6 +85,83 @@ export const Plugin = define({
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
})
// Relative patterns search upward from the location to the project root. Without
// project discovery they resolve against the global config directory instead.
const match = Effect.fn("ConfigInstructionPlugin.match")(function* (source: string) {
const expanded = source.startsWith("~/") ? path.join(global.home, source.slice(2)) : source
const options = { absolute: true, include: "file", dot: true } as const
if (path.isAbsolute(expanded))
return yield* fs
.scan(path.basename(expanded), { ...options, cwd: path.dirname(expanded) })
.pipe(Effect.orElseSucceed(() => [] as string[]))
if (project) return yield* fs.globUp(expanded, start, root)
if (!discovery.global) return []
return yield* fs
.scan(expanded, { ...options, cwd: global.config })
.pipe(Effect.orElseSucceed(() => [] as string[]))
})
const configuredSource = Effect.fn("ConfigInstructionPlugin.configuredSource")(function* () {
const files: InstructionDiscovery.File[] = []
const watched = new Set<string>()
for (const source of configured.sources) {
if (isUrl(source)) {
const file = configured.remote.get(source)
if (file) files.push(file)
continue
}
for (const item of (yield* match(source)).toSorted()) {
const resolved = yield* fs.resolve(item)
if (watched.has(resolved)) continue
watched.add(resolved)
const file = yield* read(resolved)
if (file) files.push(file)
}
}
for (const [key] of Array.from(watches)) if (!watched.has(key)) yield* FiberMap.remove(watches, key)
for (const file of watched) {
const updates = yield* watcher.subscribe({ path: file, type: "file" })
yield* FiberMap.run(watches, file, updates.pipe(Stream.runForEach(publish)), {
onlyIfMissing: true,
startImmediately: true,
})
}
return files
})
// Remote instructions are fetched only when config loads, not on every file rescan.
// A failed fetch keeps the last good copy or skips the URL, so an unreachable URL
// never makes the whole instruction set unavailable.
const reconfigure = Effect.fn("ConfigInstructionPlugin.reconfigure")(
function* () {
const entries = yield* config.entries()
const sources = Array.from(
new Set(entries.flatMap((entry) => (entry.type === "document" ? (entry.info.instructions ?? []) : []))),
)
const fetched = yield* Effect.forEach(
sources.filter(isUrl),
(url) =>
HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.text),
Effect.timeout("5 seconds"),
Effect.map((content) => new InstructionDiscovery.File({ path: url, content })),
Effect.catchCause((cause) =>
Effect.logWarning("failed to fetch instruction url", { url, cause }).pipe(
Effect.as(configured.remote.get(url)),
),
),
),
{ concurrency: 4 },
)
configured.sources = sources
configured.remote = new Map(
fetched.filter((file) => file !== undefined).map((file) => [file.path, file] as const),
)
},
(effect) => lock.withPermit(effect),
)
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchCause((cause) =>
@@ -92,10 +176,11 @@ export const Plugin = define({
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
configured: isolate("configured", configuredSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
Array.isArray(sources.global) && Array.isArray(sources.project) && Array.isArray(sources.configured)
? { type: "available", files: [...sources.global, ...sources.project, ...sources.configured] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
@@ -115,6 +200,13 @@ export const Plugin = define({
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
// Subscribe before the first config read so an update in between still triggers a reload.
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reconfigure().pipe(Effect.andThen(refresh()), Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* reconfigure()
yield* refresh()
yield* discovery.transform((editor) => {
if (loaded.current.type === "unavailable") {
@@ -141,3 +233,7 @@ function ancestorDirectories(start: string, stop: string): string[] {
if (sameDirectory(start, stop)) return [start]
return [start, ...ancestorDirectories(dirname(start), stop)]
}
function isUrl(source: string) {
return URL.canParse(source) && /^https?:$/.test(new URL(source).protocol)
}
+6 -6
View File
@@ -5,11 +5,11 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
import { createPatch } from "diff"
import { Bus } from "./bus.js"
import { Instructions } from "./instructions/index.js"
import { AbsolutePath } from "./schema.js"
import { State } from "./state.js"
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
path: AbsolutePath,
// An absolute file path, or the URL of a configured remote instruction.
path: Schema.String,
content: Schema.String,
}) {}
@@ -21,7 +21,7 @@ export const Event = {
}
export type Data = {
files: Map<AbsolutePath, Types.DeepMutable<File>>
files: Map<string, Types.DeepMutable<File>>
available: boolean
}
@@ -64,12 +64,12 @@ export const layer = (options?: Options) =>
list: () => Array.from(editor.files.values()),
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
update: (path, update) => {
const current = editor.files.get(AbsolutePath.make(path))
const current = editor.files.get(path)
if (!current) return
update(current)
current.path = AbsolutePath.make(path)
current.path = path
},
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
remove: (path) => editor.files.delete(path),
unavailable: () => {
editor.available = false
},
@@ -1,14 +1,18 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/http"
import fs from "fs/promises"
import path from "path"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Bus } from "@opencode/core/bus"
import { Config } from "@opencode/core/config"
import { ConfigInstructionPlugin } from "@opencode/core/config/plugin/instruction"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
import { Location } from "@opencode/core/location"
import { AbsolutePath } from "@opencode/core/schema"
import { Document, Info } from "@opencode/schema/config"
import { Event } from "@opencode/schema/event"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -26,13 +30,25 @@ const instructionLayer = (input: {
home?: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
instructions?: string[]
http?: HttpClient.HttpClient
project?: boolean
}) => {
const watcher = Watcher.testLayer
const config = Config.testLayer(input.instructions ? [configured(input.instructions)] : [])
return Layer.mergeAll(
AppNodeBuilder.build(
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
LayerNode.group([
InstructionDiscovery.node,
Bus.node,
Config.node,
FSUtil.node,
Global.node,
Location.node,
Watcher.node,
]),
[
Config.node.replace(config),
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
Global.node.replace(
input.config || input.home
@@ -48,20 +64,34 @@ const instructionLayer = (input: {
],
),
watcher,
config,
Layer.succeed(HttpClient.HttpClient, input.http ?? respond(() => new Response(null, { status: 404 }))),
)
}
const start = Effect.fnUntraced(function* () {
yield* ConfigInstructionPlugin.Plugin.effect(host())
const start = Effect.fnUntraced(function* (ctx = host()) {
yield* ConfigInstructionPlugin.Plugin.effect(ctx)
return yield* InstructionDiscovery.Service
})
const file = (path: string, content: string) =>
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
const configured = (instructions: string[]) => new Document({ type: "document", info: new Info({ instructions }) })
const configUpdated = () => ({ id: Event.ID.create(), created: Date.now(), type: "config.updated" as const, data: {} })
const respond = (handler: (url: string) => Response) =>
HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request.url))))
const file = (path: string, content: string) => new InstructionDiscovery.File({ path, content })
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* triggerAndWait(watcher.emit(update))
})
}
function triggerAndWait<E, R>(trigger: Effect.Effect<void, E, R>) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
const updated = yield* Deferred.make<void>()
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
@@ -69,7 +99,7 @@ function emitAndWait(update: Watcher.Update) {
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* watcher.emit(update)
yield* trigger
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
yield* Fiber.interrupt(fiber)
})
@@ -250,6 +280,133 @@ describe("ConfigInstructionPlugin.Plugin", () => {
),
)
it.live("loads configured files, globs, home paths, and URLs alongside AGENTS.md", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const project = path.join(tmp.path, "repo")
const directory = path.join(project, "packages", "core")
const agents = path.join(project, "AGENTS.md")
const first = path.join(project, "rules", "a.md")
const second = path.join(project, "rules", "b.md")
const absolute = path.join(tmp.path, "absolute.md")
const homeFile = path.join(tmp.path, "home.md")
const remote = "https://example.test/instructions.md"
const requests: string[] = []
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(first), { recursive: true })
await fs.mkdir(directory, { recursive: true })
await Promise.all([
fs.writeFile(agents, "agents"),
fs.writeFile(first, "first"),
fs.writeFile(second, "second"),
fs.writeFile(absolute, "absolute"),
fs.writeFile(homeFile, "home"),
])
})
const discovery = yield* start()
const initialized = yield* readInitial(yield* discovery.load())
// The unreachable URL and missing file are skipped without making AGENTS.md unavailable.
expect(initialized.text).toBe(
[
`Instructions from: ${agents}\nagents`,
`Instructions from: ${first}\nfirst`,
`Instructions from: ${second}\nsecond`,
`Instructions from: ${absolute}\nabsolute`,
`Instructions from: ${homeFile}\nhome`,
`Instructions from: ${remote}\nremote`,
].join("\n\n"),
)
yield* Effect.promise(() => fs.writeFile(first, "changed"))
yield* emitAndWait({ type: "update", path: first })
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
`The instructions changed:\nInstructions from: ${first}\nchanged`,
)
// File rescans reuse fetched URLs instead of requesting them again.
expect(requests).toHaveLength(2)
}).pipe(
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
home: tmp.path,
instructions: [
"rules/*.md",
absolute,
"~/home.md",
remote,
"missing.md",
"https://example.test/missing.md",
],
http: respond((url) => {
requests.push(url)
return url === remote ? new Response("remote") : new Response(null, { status: 404 })
}),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
),
),
}),
),
)
}),
),
)
it.live("reloads configured sources on config updates and keeps the last fetched URL", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const first = path.join(tmp.path, "first.md")
const second = path.join(tmp.path, "second.md")
const remote = "https://example.test/instructions.md"
const responses = [new Response("remote"), new Response(null, { status: 500 })]
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(first, "first")
await fs.writeFile(second, "second")
})
const events = yield* PubSub.unbounded<ReturnType<typeof configUpdated>>()
const config = yield* Config.Test
const discovery = yield* start(host({ event: { subscribe: () => Stream.fromPubSub(events) } }))
const initialized = yield* readInitial(yield* discovery.load())
expect(initialized.text).toBe(
[`Instructions from: ${first}\nfirst`, `Instructions from: ${remote}\nremote`].join("\n\n"),
)
yield* config.setEntries([configured([second, remote])])
yield* triggerAndWait(PubSub.publish(events, configUpdated()).pipe(Effect.asVoid))
expect((yield* readInitial(yield* discovery.load())).text).toBe(
[`Instructions from: ${second}\nsecond`, `Instructions from: ${remote}\nremote`].join("\n\n"),
)
}).pipe(
Effect.provide(
instructionLayer({
config: path.join(tmp.path, "global"),
instructions: [first, remote],
http: respond(() => responses.shift() ?? new Response(null, { status: 500 })),
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
),
)
}),
),
)
it.live("keeps an empty AGENTS.md as available context", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+2 -2
View File
@@ -453,8 +453,8 @@ See the [commands guide](/commands) for arguments, models, agents, and file-base
### Instructions
Declare additional instruction files, globs, or URLs. OpenCode accepts this
field but does not load its entries; use `AGENTS.md` for instructions.
Declare additional instruction files, globs, or HTTP(S) URLs. OpenCode loads
them alongside discovered `AGENTS.md` files.
```jsonc
{
+22 -4
View File
@@ -68,7 +68,7 @@ The selected agent or provider system prompt is sent first. OpenCode then assemb
1. Agent or provider system prompt
2. Built-in environment and date context
3. Code Mode tool guidance, when enabled
4. Global and project AGENTS.md files
4. Global and project AGENTS.md files, then configured instructions
5. Available skill, reference, and MCP guidance
6. Session-specific instruction entries supplied through the API
```
@@ -100,13 +100,31 @@ Instruction values remain privileged. Clients can see which sources changed, but
## Configuration
The V2 config schema accepts an `instructions` array, but V2 does not currently resolve its files, glob patterns, or URLs. This configuration does not add instructions to the model yet:
Use the `instructions` array to add files, glob patterns, or HTTP(S) URLs alongside discovered `AGENTS.md` files:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md", "https://example.com/instructions.md"],
"instructions": [
"CONTRIBUTING.md",
"docs/guidelines/*.md",
"~/rules/opencode.md",
"https://example.com/instructions.md",
],
}
```
Use `AGENTS.md` for active V2 instructions. See [Config](/config) for configuration locations and precedence.
Configured instructions load after `AGENTS.md` files, in the order they are listed. Entries from every config document are combined.
| Entry | Resolution |
| ----------------------- | -------------------------------------------------------------------- |
| Relative path or glob | Searched in each directory from the workspace up to the project root |
| `~/` path | Resolved from your home directory |
| Absolute path | Used as written; the file name may be a glob |
| `http://` or `https://` | Fetched when config loads, with a five-second timeout |
- Local files update like `AGENTS.md` when they change. New files matching a glob load on the next config change.
- Missing files are skipped. A URL that cannot be fetched is skipped, or keeps its last fetched content.
- With `OPENCODE_DISABLE_PROJECT_CONFIG=1`, or when the workspace is outside the project, relative entries resolve against the global config directory.
See [Config](/config) for configuration locations and precedence.