From aa3cb48e406b1f28eee69fec272f869368240eb5 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:28:08 +0200 Subject: [PATCH 1/2] feat(opencode): filter instruction files by reader audience Adds an opencode.audience frontmatter directive so an instruction file can declare who it is for: a role (main / subagent / all) and an agent glob, matched as OR across entries and AND within one. Reader role is derived structurally from whether the session has a parent, so a primary-mode agent dispatched as a child is correctly a subagent. Absent metadata means included everywhere, byte-identical; an unknown key under opencode is fatal and names the file, because a typo like `audiance` parses as valid YAML and failing open would silently reinstate the delivery the directive exists to prevent. Filtering covers system-prompt assembly, nearby-file resolution through the read tool, and the prompt attachment path. The V2 SessionCore assembly path is a separate implementation and is not covered. --- .../src/cli/cmd/debug/agent.handler.ts | 1 + .../src/session/instruction-audience.ts | 106 ++++++++++ packages/opencode/src/session/instruction.ts | 87 +++++++-- packages/opencode/src/session/prompt.ts | 8 +- packages/opencode/src/session/tools.ts | 1 + packages/opencode/src/tool/read.ts | 3 +- packages/opencode/src/tool/tool.ts | 1 + .../test/session/instruction-audience.test.ts | 140 +++++++++++++ .../opencode/test/session/instruction.test.ts | 184 ++++++++++++++++-- packages/opencode/test/session/prompt.test.ts | 95 ++++++++- packages/opencode/test/tool/read.test.ts | 29 +++ packages/web/src/content/docs/rules.mdx | 48 +++++ 12 files changed, 668 insertions(+), 35 deletions(-) create mode 100644 packages/opencode/src/session/instruction-audience.ts create mode 100644 packages/opencode/test/session/instruction-audience.test.ts diff --git a/packages/opencode/src/cli/cmd/debug/agent.handler.ts b/packages/opencode/src/cli/cmd/debug/agent.handler.ts index b9d9ff49c8e4..0576e5b68350 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.handler.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.handler.ts @@ -176,6 +176,7 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio messageID, callID: PartID.ascending(), agent: agent.name, + reader: { role: "main" as const, agent: agent.name }, abort: new AbortController().signal, messages: [], metadata: () => Effect.void, diff --git a/packages/opencode/src/session/instruction-audience.ts b/packages/opencode/src/session/instruction-audience.ts new file mode 100644 index 000000000000..edb33b71f69e --- /dev/null +++ b/packages/opencode/src/session/instruction-audience.ts @@ -0,0 +1,106 @@ +import { Schema } from "effect" +import matter from "gray-matter" +import { isRecord } from "@/util/record" +import { Wildcard } from "@/util/wildcard" + +export type Reader = { role: "main" | "subagent"; agent: string } + +export type AudienceEntry = { + role?: "main" | "subagent" | "all" + agent?: string +} + +export class AudienceError extends Schema.TaggedErrorClass()("InstructionAudienceError", { + path: Schema.String, + detail: Schema.String, +}) { + override get message() { + return `Instruction audience error in ${this.path}: ${this.detail}` + } +} + +export type Parsed = + | { kind: "absent" } + | { kind: "present"; body: string; entries: AudienceEntry[] } + +const roles = ["main", "subagent", "all"] as const + +export function parse(filepath: string, content: string): Parsed { + const parsed = parseMatter(filepath, content) + if (!isRecord(parsed.data)) return { kind: "absent" } + if (!("opencode" in parsed.data)) return { kind: "absent" } + + const opencode = parsed.data.opencode + if (!isRecord(opencode)) fail(filepath, "`opencode` must be a mapping") + + const keys = Object.keys(opencode) + if (keys.length === 0) return { kind: "absent" } + + const unknown = keys.filter((key) => key !== "audience") + if (unknown.length > 0) fail(filepath, `unknown keys inside opencode: ${unknown.join(", ")}`) + return { kind: "present", body: parsed.content, entries: parseEntries(filepath, opencode.audience) } +} + +function parseMatter(filepath: string, content: string) { + try { + return matter(content) + } catch (error) { + fail(filepath, `unparseable YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`) + } +} + +function parseEntries(filepath: string, raw: unknown) { + const entries = Array.isArray(raw) ? raw : [raw] + if (entries.length === 0) fail(filepath, "opencode.audience is empty") + return entries.map((entry, index) => parseEntry(filepath, entry, index)) +} + +function parseEntry(filepath: string, raw: unknown, index: number): AudienceEntry { + if (typeof raw === "string") return { role: parseRole(filepath, raw, index) } + if (!isRecord(raw)) fail(filepath, `opencode.audience[${index}] must be a mapping or string`) + + const keys = Object.keys(raw) + if (keys.length === 0) fail(filepath, `opencode.audience[${index}] is empty`) + + const unknown = keys.filter((key) => key !== "role" && key !== "agent") + if (unknown.length > 0) fail(filepath, `opencode.audience[${index}] has unknown keys: ${unknown.join(", ")}`) + const agent = raw.agent + if (agent !== undefined && typeof agent !== "string") { + fail(filepath, `opencode.audience[${index}].agent must be a string`) + } + + return { + ...(raw.role === undefined ? {} : { role: parseRole(filepath, raw.role, index) }), + ...(agent === undefined ? {} : { agent }), + } +} + +function parseRole(filepath: string, raw: unknown, index: number): AudienceEntry["role"] { + if (raw === "main" || raw === "subagent" || raw === "all") return raw + fail(filepath, `opencode.audience[${index}].role must be one of ${roles.join(" | ")}`) +} + +function fail(filepath: string, detail: string): never { + throw new AudienceError({ path: filepath, detail }) +} + +export function matches(reader: Reader, entries: AudienceEntry[]) { + return entries.some((entry) => { + const role = entry.role === undefined || entry.role === "all" || entry.role === reader.role + const agent = entry.agent === undefined || Wildcard.match(reader.agent, entry.agent) + return role && agent + }) +} + +export function filter( + filepath: string, + content: string, + reader: Reader, +): { include: true; body: string } | { include: false } { + const parsed = parse(filepath, content) + if (parsed.kind === "absent") return { include: true, body: content } + if (!matches(reader, parsed.entries)) return { include: false } + return { include: true, body: parsed.body } +} + +export * as InstructionAudience from "./instruction-audience" diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 7f593550d468..9a1771405ab2 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -13,6 +13,7 @@ import { withTransientReadRetry } from "@/util/effect-http-client" import { Global } from "@opencode-ai/core/global" import type { MessageV2 } from "./message-v2" import type { MessageID } from "./schema" +import { InstructionAudience } from "./instruction-audience" function extract(messages: SessionV1.WithParts[]) { const paths = new Set() @@ -34,13 +35,14 @@ function extract(messages: SessionV1.WithParts[]) { export interface Interface { readonly clear: (messageID: MessageID) => Effect.Effect readonly systemPaths: () => Effect.Effect, FSUtil.Error> - readonly system: () => Effect.Effect + readonly system: (reader: InstructionAudience.Reader) => Effect.Effect readonly find: (dir: string) => Effect.Effect readonly resolve: ( messages: SessionV1.WithParts[], filepath: string, messageID: MessageID, - ) => Effect.Effect<{ filepath: string; content: string }[], FSUtil.Error> + reader: InstructionAudience.Reader, + ) => Effect.Effect<{ filepath: string; content: string }[], FSUtil.Error | InstructionAudience.AudienceError> } export class Service extends Context.Service()("@opencode/Instruction") {} @@ -107,14 +109,22 @@ const layer: Layer.Layer< s.claims.delete(messageID) }) + type Origin = "global" | "project" | "config" + type TaggedEntry = { path: string; origin: Origin; isUrl: boolean } + const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { + const tagged = yield* systemTagged() + return new Set(tagged.map((e) => e.path)) + }) + + const systemTagged = Effect.fn("Instruction.systemTagged")(function* () { const config = yield* cfg.get() const ctx = yield* InstanceState.context - const paths = new Set() + const tagged: TaggedEntry[] = [] for (const file of globalFiles) { if (yield* fs.existsSafe(file)) { - paths.add(path.resolve(file)) + tagged.push({ path: path.resolve(file), origin: "global", isUrl: false }) break } } @@ -126,15 +136,21 @@ const layer: Layer.Layer< .findUp(file, ctx.directory, ctx.worktree) .pipe(Effect.catch(() => Effect.succeed([]))) if (matches.length > 0) { - matches.forEach((item) => paths.add(path.resolve(item))) + for (const item of matches) { + tagged.push({ path: path.resolve(item), origin: "project", isUrl: false }) + } break } } } + // Config instructions: file paths AND URLs are both classified as "config" origin. if (config.instructions) { for (const raw of config.instructions) { - if (raw.startsWith("https://") || raw.startsWith("http://")) continue + if (raw.startsWith("https://") || raw.startsWith("http://")) { + tagged.push({ path: raw, origin: "config", isUrl: true }) + continue + } const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw const matches = yield* ( path.isAbsolute(instruction) @@ -145,29 +161,58 @@ const layer: Layer.Layer< }) : relative(instruction) ).pipe(Effect.catch(() => Effect.succeed([] as string[]))) - matches.forEach((item) => paths.add(path.resolve(item))) + for (const item of matches) { + tagged.push({ path: path.resolve(item), origin: "config", isUrl: false }) + } } } - return paths + return tagged }) - const system = Effect.fn("Instruction.system")(function* () { - const config = yield* cfg.get() - const paths = yield* systemPaths() - const urls = (config.instructions ?? []).filter( - (item) => item.startsWith("https://") || item.startsWith("http://"), - ) + const filterAudience = Effect.fnUntraced(function* ( + filepath: string, + content: string, + reader: InstructionAudience.Reader, + ) { + return yield* Effect.try({ + try: () => InstructionAudience.filter(filepath, content, reader), + catch: (error) => { + if (error instanceof InstructionAudience.AudienceError) return error + throw error + }, + }) + }) - const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) - const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) + const buildSystem = Effect.fn("Instruction.buildSystem")(function* ( + reader: InstructionAudience.Reader, + tagged: TaggedEntry[], + ) { + const fileEntries = tagged.filter((e) => !e.isUrl) + const urlEntries = tagged.filter((e) => e.isUrl) + const files = yield* Effect.forEach(fileEntries.map((e) => e.path), read, { concurrency: 8 }) + const remote = yield* Effect.forEach(urlEntries.map((e) => e.path), fetch, { concurrency: 4 }) return [ - ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), - ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), + ...(yield* Effect.forEach(fileEntries, (entry, index) => { + if (!files[index]) return Effect.succeed([]) + return filterAudience(entry.path, files[index], reader).pipe( + Effect.map((filtered) => filtered.include ? [`Instructions from: ${entry.path}\n${filtered.body}`] : []), + ) + })).flat(), + ...(yield* Effect.forEach(urlEntries, (entry, index) => { + if (!remote[index]) return Effect.succeed([]) + return filterAudience(entry.path, remote[index], reader).pipe( + Effect.map((filtered) => filtered.include ? [`Instructions from: ${entry.path}\n${filtered.body}`] : []), + ) + })).flat(), ] }) + const system = Effect.fn("Instruction.system")(function* (reader: InstructionAudience.Reader) { + return yield* buildSystem(reader, yield* systemTagged()) + }) + const find = Effect.fn("Instruction.find")(function* (dir: string) { for (const file of instructionFiles) { const filepath = path.resolve(path.join(dir, file)) @@ -180,6 +225,7 @@ const layer: Layer.Layer< messages: SessionV1.WithParts[], filepath: string, messageID: MessageID, + reader: InstructionAudience.Reader, ) { const sys = yield* systemPaths() const already = extract(messages) @@ -211,7 +257,10 @@ const layer: Layer.Layer< set.add(found) const content = yield* read(found) if (content) { - results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) + const filtered = yield* filterAudience(found, content, reader) + if (filtered.include) { + results.push({ filepath: found, content: `Instructions from: ${found}\n${filtered.body}` }) + } } current = path.dirname(current) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..e08d41873700 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -15,6 +15,7 @@ import type { JSONSchema7 } from "@ai-sdk/provider" import { SessionCompaction } from "./compaction" import { SystemPrompt } from "./system" import { Instruction } from "./instruction" +import { InstructionAudience } from "./instruction-audience" import { Plugin } from "../plugin" import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps" import { ToolRegistry } from "@/tool/registry" @@ -818,6 +819,7 @@ const layer = Layer.effect( sessionID: input.sessionID, abort: controller.signal, agent: input.agent!, + reader: { role: current.parentID == null ? "main" : "subagent", agent: input.agent! }, messageID: info.id, extra: { bypassCwdCheck: true, ...extra }, messages: [], @@ -1254,10 +1256,14 @@ const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) + const reader: InstructionAudience.Reader = { + role: session.parentID == null ? "main" : "subagent", + agent: agent.name, + } const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([ sys.skills(agent), sys.environment(model), - instruction.system().pipe(Effect.orDie), + instruction.system(reader).pipe(Effect.orDie), sys.mcp(agent, session.permission), MessageV2.toModelMessagesEffect(msgs, model), ]) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0f401c7562fa..9fa793b1e89f 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -63,6 +63,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { callID: options.toolCallId, extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps }, agent: input.agent.name, + reader: { role: input.session.parentID == null ? "main" : "subagent", agent: input.agent.name }, messages: input.messages, metadata: (val) => input.processor.updateToolCall(options.toolCallId, (match) => { diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 678ed4451048..c72eb2da5b24 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -297,7 +297,8 @@ export const ReadTool = Tool.define< } } - const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID) + if (!ctx.reader) return yield* Effect.fail(new Error("ReadTool instruction resolution requires reader identity")) + const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID, ctx.reader) const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) const mime = sniffAttachmentMime(sample, FSUtil.mimeType(filepath)) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index e5e7802858ca..6dfe0df1ae30 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -37,6 +37,7 @@ export type Context = { sessionID: SessionID messageID: MessageID agent: string + reader?: { role: "main" | "subagent"; agent: string } abort: AbortSignal callID?: string extra?: { [key: string]: unknown } diff --git a/packages/opencode/test/session/instruction-audience.test.ts b/packages/opencode/test/session/instruction-audience.test.ts new file mode 100644 index 000000000000..b237988116b7 --- /dev/null +++ b/packages/opencode/test/session/instruction-audience.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test" +import { InstructionAudience } from "../../src/session/instruction-audience" + +const mainBuild = { role: "main" as const, agent: "build" } +const subReviewer = { role: "subagent" as const, agent: "reviewer" } + +function capture(fn: () => unknown) { + try { + fn() + } catch (error) { + return error + } + throw new Error("expected function to throw") +} + +describe("InstructionAudience.parse", () => { + test("treats files without opencode metadata as absent", () => { + expect(InstructionAudience.parse("/x/AGENTS.md", "# Heading\nBody\n")).toEqual({ kind: "absent" }) + }) + + test("treats an empty opencode mapping as absent", () => { + expect(InstructionAudience.parse("/x/AGENTS.md", "---\nopencode: {}\n---\nBody\n")).toEqual({ kind: "absent" }) + }) + + test("expands the bare role shorthand", () => { + const result = InstructionAudience.parse("/x/AGENTS.md", "---\nopencode:\n audience: main\n---\nBody\n") + expect(result).toEqual({ kind: "present", body: "Body\n", entries: [{ role: "main" }] }) + }) + + test("accepts role-only, agent-only, and combined entries", () => { + const result = InstructionAudience.parse( + "/x/AGENTS.md", + "---\nopencode:\n audience:\n - role: main\n - agent: reviewer*\n - { role: subagent, agent: general* }\n---\nBody\n", + ) + expect(result).toEqual({ + kind: "present", + body: "Body\n", + entries: [{ role: "main" }, { agent: "reviewer*" }, { role: "subagent", agent: "general*" }], + }) + }) + + test("rejects the audiance typo and names the path", () => { + const error = capture(() => + InstructionAudience.parse("/path/AGENTS.md", "---\nopencode:\n audiance: main\n---\nBody\n"), + ) + expect(error).toBeInstanceOf(InstructionAudience.AudienceError) + if (!(error instanceof InstructionAudience.AudienceError)) return + expect(error.path).toBe("/path/AGENTS.md") + expect(error.message).toMatch(/audiance/) + }) + + test.each([ + ["unknown opencode key", "---\nopencode:\n audience: main\n future: 1\n---\nBody\n", /future/], + ["unknown entry key", "---\nopencode:\n audience:\n - role: main\n mode: primary\n---\nBody\n", /mode/], + ["empty audience array", "---\nopencode:\n audience: []\n---\nBody\n", /empty/], + ["empty entry", "---\nopencode:\n audience:\n - {}\n---\nBody\n", /empty/], + ["unknown role", "---\nopencode:\n audience:\n - role: primary\n---\nBody\n", /role/], + ["non-string agent", "---\nopencode:\n audience:\n - agent: 42\n---\nBody\n", /agent/], + ["wrong opencode type", "---\nopencode: main\n---\nBody\n", /mapping/], + ["wrong audience type", "---\nopencode:\n audience: 42\n---\nBody\n", /mapping|string/], + ["invalid bare role", "---\nopencode:\n audience: reviewer*\n---\nBody\n", /role/], + ])("rejects %s", (_name, content, message) => { + const error = capture(() => InstructionAudience.parse("/x/AGENTS.md", content)) + expect(error).toBeInstanceOf(InstructionAudience.AudienceError) + if (!(error instanceof InstructionAudience.AudienceError)) return + expect(error.path).toBe("/x/AGENTS.md") + expect(error.message).toContain("/x/AGENTS.md") + expect(error.message).toMatch(message) + }) + + test("rejects malformed YAML and names the path", () => { + const error = capture(() => + InstructionAudience.parse("/path/AGENTS.md", "---\nopencode:\n audience: [unclosed\n---\nBody\n"), + ) + expect(error).toBeInstanceOf(InstructionAudience.AudienceError) + if (!(error instanceof InstructionAudience.AudienceError)) return + expect(error.path).toBe("/path/AGENTS.md") + expect(error.message).toMatch(/frontmatter/i) + }) +}) + +describe("InstructionAudience.matches", () => { + test("uses Wildcard.match boundaries and case sensitivity", () => { + for (const agent of ["reviewer", "reviewer-perf", "reviewer-security"]) { + expect(InstructionAudience.matches({ role: "subagent", agent }, [{ agent: "reviewer*" }])).toBe(true) + } + expect(InstructionAudience.matches({ role: "subagent", agent: "general" }, [{ agent: "reviewer*" }])).toBe(false) + expect(InstructionAudience.matches({ role: "subagent", agent: "Reviewer" }, [{ agent: "reviewer*" }])).toBe(false) + expect(InstructionAudience.matches({ role: "subagent", agent: "general1" }, [{ agent: "general?" }])).toBe(true) + expect(InstructionAudience.matches({ role: "subagent", agent: "general" }, [{ agent: "general?" }])).toBe(false) + expect(InstructionAudience.matches({ role: "subagent", agent: "general-fast" }, [{ agent: "general?" }])).toBe(false) + }) + + test("matches role-only and role all entries", () => { + expect(InstructionAudience.matches(mainBuild, [{ role: "main" }])).toBe(true) + expect(InstructionAudience.matches(subReviewer, [{ role: "main" }])).toBe(false) + expect(InstructionAudience.matches(mainBuild, [{ role: "all" }])).toBe(true) + expect(InstructionAudience.matches(subReviewer, [{ role: "all" }])).toBe(true) + }) + + test("matches agent-only entries regardless of role", () => { + expect(InstructionAudience.matches(mainBuild, [{ agent: "build" }])).toBe(true) + expect(InstructionAudience.matches({ role: "subagent", agent: "build" }, [{ agent: "build" }])).toBe(true) + }) + + test("requires all keys within a combined entry", () => { + expect(InstructionAudience.matches(subReviewer, [{ role: "subagent", agent: "reviewer*" }])).toBe(true) + expect(InstructionAudience.matches({ role: "main", agent: "reviewer" }, [{ role: "subagent", agent: "reviewer*" }])).toBe(false) + }) + + test("matches any entry across the array and excludes when none match", () => { + const entries: InstructionAudience.AudienceEntry[] = [{ role: "main" }, { agent: "reviewer*" }] + expect(InstructionAudience.matches(mainBuild, entries)).toBe(true) + expect(InstructionAudience.matches(subReviewer, entries)).toBe(true) + expect(InstructionAudience.matches({ role: "subagent", agent: "general" }, entries)).toBe(false) + }) +}) + +describe("InstructionAudience.filter", () => { + test("preserves a horizontal-rule file byte-for-byte", () => { + const original = "---\nA heading\n---\nBody text here\n" + expect(InstructionAudience.filter("/x/AGENTS.md", original, mainBuild)).toEqual({ include: true, body: original }) + }) + + test("preserves unrelated and empty frontmatter byte-for-byte", () => { + for (const original of ["---\ntitle: Doc\n---\nBody\n", "---\n---\nBody\n"]) { + expect(InstructionAudience.filter("/x/AGENTS.md", original, mainBuild)).toEqual({ include: true, body: original }) + } + }) + + test("includes a matching directive with frontmatter stripped", () => { + const original = "---\nopencode:\n audience: main\n---\nBody text\n" + expect(InstructionAudience.filter("/x/AGENTS.md", original, mainBuild)).toEqual({ include: true, body: "Body text\n" }) + }) + + test("excludes a valid directive when no entry matches", () => { + const original = "---\nopencode:\n audience: main\n---\nBody text\n" + expect(InstructionAudience.filter("/x/AGENTS.md", original, subReviewer)).toEqual({ include: false }) + }) +}) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index cdbc6e66d70d..2b3ac00c9d3d 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import path from "path" -import { Effect, FileSystem, Layer } from "effect" +import { Cause, Effect, Exit, FileSystem, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instruction } from "../../src/session/instruction" +import { InstructionAudience } from "../../src/session/instruction-audience" import type { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" @@ -31,10 +32,15 @@ const it = testEffect( ) const configLayer = Layer.succeed(Config.Service, TestConfig.make()) +const mainBuild: InstructionAudience.Reader = { role: "main", agent: "build" } -const instructionLayer = (global: Partial, flags: Partial = {}) => +const instructionLayer = ( + global: Partial, + flags: Partial = {}, + config: Config.Interface = TestConfig.make(), +) => AppNodeBuilder.build(Instruction.node, [ - [Config.node, configLayer], + [Config.node, Layer.succeed(Config.Service, config)], [Global.node, Global.layerWith(global)], [RuntimeFlags.node, RuntimeFlags.layer(flags)], ]) @@ -119,7 +125,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true) - const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1")) + const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1"), mainBuild) expect(results).toEqual([]) }), ), @@ -136,6 +142,7 @@ describe("Instruction.resolve", () => { [], path.join(dir, "subdir", "nested", "file.ts"), MessageID.make("msg_message-test-2"), + mainBuild, ) expect(results.length).toBe(1) expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) @@ -151,7 +158,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(filepath)).toBe(false) - const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3")) + const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3"), mainBuild) expect(results).toEqual([]) }), ), @@ -164,8 +171,8 @@ describe("Instruction.resolve", () => { const filepath = path.join(dir, "subdir", "nested", "file.ts") const id = MessageID.make("msg_message-claim-1") - const first = yield* svc.resolve([], filepath, id) - const second = yield* svc.resolve([], filepath, id) + const first = yield* svc.resolve([], filepath, id, mainBuild) + const second = yield* svc.resolve([], filepath, id, mainBuild) expect(first).toHaveLength(1) expect(first[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) @@ -181,9 +188,9 @@ describe("Instruction.resolve", () => { const filepath = path.join(dir, "subdir", "nested", "file.ts") const id = MessageID.make("msg_message-claim-2") - const first = yield* svc.resolve([], filepath, id) + const first = yield* svc.resolve([], filepath, id, mainBuild) yield* svc.clear(id) - const second = yield* svc.resolve([], filepath, id) + const second = yield* svc.resolve([], filepath, id, mainBuild) expect(first).toHaveLength(1) expect(second).toHaveLength(1) @@ -200,12 +207,103 @@ describe("Instruction.resolve", () => { const filepath = path.join(dir, "subdir", "nested", "file.ts") const id = MessageID.make("msg_message-claim-3") - const results = yield* svc.resolve(loaded(agents), filepath, id) + const results = yield* svc.resolve(loaded(agents), filepath, id, mainBuild) expect(results).toEqual([]) }), ), ) + it.live("a child reader cannot resolve a nested AGENTS.md marked audience: main", () => + withFiles( + { + "subdir/AGENTS.md": "---\nopencode:\n audience: main\n---\nMAIN-ONLY NESTED DOCTRINE\n", + "subdir/nested/file.ts": "const x = 1", + }, + (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const results = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("msg_message-claim-audience"), + { role: "subagent", agent: "build" }, + ) + expect(results).toEqual([]) + }), + ), + ) + + it.live("a matching nested instruction is included with frontmatter stripped", () => + withFiles( + { + "subdir/AGENTS.md": "---\nopencode:\n audience: main\n---\nMAIN-ONLY NESTED DOCTRINE\n", + "subdir/nested/file.ts": "const x = 1", + }, + (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const results = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("msg_message-audience-main"), + mainBuild, + ) + expect(results).toHaveLength(1) + expect(results[0].content).toContain("MAIN-ONLY NESTED DOCTRINE") + expect(results[0].content).not.toContain("audience: main") + }), + ), + ) + + it.live("delivers malformed audience as a typed failure rather than a defect", () => + withFiles( + { + "subdir/AGENTS.md": "---\nopencode:\n audiance: main\n---\nBody\n", + "subdir/nested/file.ts": "const x = 1", + }, + (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const exit = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("msg_message-audience-typed-failure"), + mainBuild, + ).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(Cause.hasDies(exit.cause)).toBe(false) + expect(Cause.hasFails(exit.cause)).toBe(true) + expect(Cause.squash(exit.cause)).toBeInstanceOf(InstructionAudience.AudienceError) + }), + ), + ) + + it.live("a malformed nested audience directive fails with its path", () => + withFiles( + { + "subdir/AGENTS.md": "---\nopencode:\n audiance: main\n---\nBody\n", + "subdir/nested/file.ts": "const x = 1", + }, + (dir) => + Effect.gen(function* () { + const svc = yield* Instruction.Service + const filepath = path.join(dir, "subdir", "AGENTS.md") + const exit = yield* svc.resolve( + [], + path.join(dir, "subdir", "nested", "file.ts"), + MessageID.make("msg_message-audience-malformed"), + mainBuild, + ).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(InstructionAudience.AudienceError) + expect(String(error)).toContain(filepath) + }), + ), + ) + test.todo("fetches remote instructions from config URLs via HttpClient", () => {}) }) @@ -221,7 +319,7 @@ describe("Instruction.system", () => { expect(paths.has(path.join(projectTmp, "AGENTS.md"))).toBe(true) expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) - const rules = yield* svc.system() + const rules = yield* svc.system(mainBuild) expect(rules).toHaveLength(2) expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`) expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`) @@ -229,6 +327,68 @@ describe("Instruction.system", () => { }), ) + it.live("origin and audience filters intersect independently", () => + Effect.gen(function* () { + const globalTmp = yield* tmpWithFiles({ + "CONFIG_MATCH.md": "---\nopencode:\n audience:\n - agent: build*\n---\nCONFIG MATCH\n", + }) + const projectTmp = yield* tmpWithFiles({ + "AGENTS.md": "---\nopencode:\n audience:\n - agent: build*\n---\nPROJECT MATCH\n", + "PROJECT_WRONG.md": "---\nopencode:\n audience:\n - agent: other*\n---\nPROJECT WRONG AUDIENCE\n", + }) + const config = TestConfig.make({ + get: () => Effect.succeed({ + instructions: [path.join(projectTmp, "PROJECT_WRONG.md"), path.join(globalTmp, "CONFIG_MATCH.md")], + }), + }) + + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const all = yield* svc.system(mainBuild) + expect(all.join("\n")).toContain("PROJECT MATCH") + expect(all.join("\n")).toContain("CONFIG MATCH") + expect(all.join("\n")).not.toContain("PROJECT WRONG AUDIENCE") + }).pipe( + provideInstance(projectTmp), + Effect.provide(instructionLayer({ home: globalTmp, config: globalTmp }, {}, config)), + ) + }), + ) + + it.live("assembly preserves unrelated frontmatter and horizontal-rule bytes", () => + Effect.gen(function* () { + const projectTmp = yield* tmpWithFiles({ + "AGENTS.md": "---\ntitle: A Document\n---\nBody\n", + "RULE.md": "---\nA heading\n---\nBody text here\n", + }) + const config = TestConfig.make({ + get: () => Effect.succeed({ instructions: [path.join(projectTmp, "RULE.md")] }), + }) + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const rules = yield* svc.system(mainBuild) + expect(rules).toContain(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n---\ntitle: A Document\n---\nBody\n`) + expect(rules).toContain(`Instructions from: ${path.join(projectTmp, "RULE.md")}\n---\nA heading\n---\nBody text here\n`) + }).pipe( + provideInstance(projectTmp), + Effect.provide(instructionLayer({ home: projectTmp, config: projectTmp }, {}, config)), + ) + }), + ) + + it.live("assembly strips a validated directive", () => + Effect.gen(function* () { + const projectTmp = yield* tmpWithFiles({ + "AGENTS.md": "---\nopencode:\n audience: main\n---\nBODY\n", + }) + yield* Effect.gen(function* () { + const svc = yield* Instruction.Service + const rules = yield* svc.system(mainBuild) + expect(rules).toContain(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\nBODY\n`) + }).pipe(provideInstance(projectTmp), provideInstruction({ home: projectTmp, config: projectTmp })) + }), + ) + it.live("skips project and global CLAUDE.md when Claude Code prompt is disabled", () => Effect.gen(function* () { const globalTmp = yield* tmpWithFiles({ ".claude/CLAUDE.md": "# Global Claude" }) @@ -239,7 +399,7 @@ describe("Instruction.system", () => { const paths = yield* svc.systemPaths() expect(paths.has(path.join(globalTmp, ".claude", "CLAUDE.md"))).toBe(false) expect(paths.has(path.join(projectTmp, "CLAUDE.md"))).toBe(false) - expect(yield* svc.system()).toEqual([]) + expect(yield* svc.system(mainBuild)).toEqual([]) }).pipe( provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp }, { disableClaudeCodePrompt: true }), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index da6e0f8d036f..a887b97f6008 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -368,13 +368,13 @@ const succeedVoid = (deferred: Deferred.Deferred) => { Effect.runSync(Deferred.succeed(deferred, void 0).pipe(Effect.ignore)) } -const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) { +const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string, agent = "build") { const session = yield* Session.Service const msg = yield* session.updateMessage({ id: MessageID.ascending(), role: "user", sessionID, - agent: "build", + agent, model: ref, time: { created: Date.now() }, }) @@ -580,6 +580,66 @@ withMcpInstructions.instance( 15_000, ) +withMcpInstructions.instance( + "audience: main file is included for root session, absent for dispatched child", + () => + Effect.gen(function* () { + const { llm, dir } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const write = yield* FSUtil.Service + yield* write.writeWithDirs(path.join(dir, "AGENTS.md"), "---\nopencode:\n audience: main\n---\nMAIN-ONLY DOCTRINE\n") + const base = { permission: [{ permission: "*", pattern: "*", action: "allow" }] } as const + const root = yield* sessions.create({ title: "Root", ...base }) + yield* llm.hang + yield* user(root.id, "hello") + const rootFiber = yield* prompt.loop({ sessionID: root.id }).pipe(Effect.forkChild) + yield* awaitWithTimeout(llm.wait(1), "timed out waiting for root LLM request", "10 seconds") + expect(JSON.stringify((yield* llm.hits)[0]?.body)).toContain("MAIN-ONLY DOCTRINE") + yield* Fiber.interrupt(rootFiber) + yield* llm.reset + const child = yield* sessions.create({ parentID: root.id, title: "Child", ...base }) + yield* llm.hang + yield* user(child.id, "hello") + const childFiber = yield* prompt.loop({ sessionID: child.id }).pipe(Effect.forkChild) + yield* awaitWithTimeout(llm.wait(1), "timed out waiting for child LLM request", "10 seconds") + expect(JSON.stringify((yield* llm.hits)[0]?.body)).not.toContain("MAIN-ONLY DOCTRINE") + yield* Fiber.interrupt(childFiber) + }), + 30_000, +) + +withMcpInstructions.instance( + "agent wildcard includes a matching build session and excludes a non-matching directive", + () => + Effect.gen(function* () { + const { llm, dir } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const write = yield* FSUtil.Service + yield* write.writeWithDirs(path.join(dir, "AGENTS.md"), "---\nopencode:\n audience:\n - agent: bui*\n---\nBUILD-ONLY DOCTRINE\n") + const base = { permission: [{ permission: "*", pattern: "*", action: "allow" }] } as const + const root = yield* sessions.create({ title: "Root", ...base }) + const matching = yield* sessions.create({ parentID: root.id, title: "Matching", ...base }) + yield* llm.hang + yield* user(matching.id, "hello") + const matchingFiber = yield* prompt.loop({ sessionID: matching.id }).pipe(Effect.forkChild) + yield* awaitWithTimeout(llm.wait(1), "timed out waiting for matching LLM request", "10 seconds") + expect(JSON.stringify((yield* llm.hits)[0]?.body)).toContain("BUILD-ONLY DOCTRINE") + yield* Fiber.interrupt(matchingFiber) + yield* llm.reset + yield* write.writeWithDirs(path.join(dir, "AGENTS.md"), "---\nopencode:\n audience:\n - agent: reviewer*\n---\nREVIEWER-ONLY DOCTRINE\n") + const nonMatching = yield* sessions.create({ parentID: root.id, title: "NonMatching", ...base }) + yield* llm.hang + yield* user(nonMatching.id, "hello") + const nonMatchingFiber = yield* prompt.loop({ sessionID: nonMatching.id }).pipe(Effect.forkChild) + yield* awaitWithTimeout(llm.wait(1), "timed out waiting for non-matching LLM request", "10 seconds") + expect(JSON.stringify((yield* llm.hits)[0]?.body)).not.toContain("REVIEWER-ONLY DOCTRINE") + yield* Fiber.interrupt(nonMatchingFiber) + }), + 30_000, +) + it.instance("legacy prompt emits message events without session.next events", () => Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -2128,6 +2188,37 @@ noLLMServer.instance( 30_000, ) +noLLMServer.instance( + "child file attachment excludes nearby audience: main instructions", + () => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const write = yield* FSUtil.Service + yield* write.writeWithDirs( + path.join(dir, "subdir", "AGENTS.md"), + "---\nopencode:\n audience: main\n---\nMAIN-ONLY ATTACHMENT DOCTRINE\n", + ) + const filepath = path.join(dir, "subdir", "nested", "file.ts") + yield* write.writeWithDirs(filepath, "const x = 1\n") + const root = yield* sessions.create({ title: "Root" }) + const child = yield* sessions.create({ parentID: root.id, title: "Child", agent: "build" }) + const msg = yield* prompt.prompt({ + sessionID: child.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "review attached file" }, + { type: "file", url: `file://${filepath}`, filename: "file.ts", mime: "text/plain" }, + ], + }) + const text = msg.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n") + expect(text).not.toContain("MAIN-ONLY ATTACHMENT DOCTRINE") + }), + { config: cfg }, +) + // Missing file handling noLLMServer.instance( diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index c1ef61b227dd..158eb8a67d0a 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -38,6 +38,7 @@ const ctx = { messageID: MessageID.make("msg_test"), callID: "", agent: "build", + reader: { role: "main" as const, agent: "build" }, abort: AbortSignal.any([]), messages: [], metadata: () => Effect.void, @@ -148,6 +149,34 @@ const asks = () => { } } +describe("tool.read reader identity", () => { + it.live("fails closed when instruction resolution has no reader", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, "file.ts"), "const x = 1\n") + const { reader: _reader, ...next } = ctx + const error = yield* fail(dir, { filePath: path.join(dir, "file.ts") }, next) + expect(error.message).toContain("requires reader identity") + }), + ) +}) + +describe("tool.read instruction audience", () => { + it.live("child reader does not load a nested AGENTS.md marked audience: main", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + yield* put(path.join(dir, "subdir", "AGENTS.md"), "---\nopencode:\n audience: main\n---\nMAIN-ONLY NESTED DOCTRINE\n") + yield* put(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1\n") + const result = yield* exec(dir, { filePath: path.join(dir, "subdir", "nested", "file.ts") }, { + ...ctx, + reader: { role: "subagent", agent: "build" }, + }) + expect(result.metadata.loaded).toEqual([]) + expect(result.output).not.toContain("MAIN-ONLY NESTED DOCTRINE") + }), + ) +}) + describe("tool.read external_directory permission", () => { it.live("allows reading absolute path inside project directory", () => Effect.gen(function* () { diff --git a/packages/web/src/content/docs/rules.mdx b/packages/web/src/content/docs/rules.mdx index 6db5d45b1d3a..8ec98e279798 100644 --- a/packages/web/src/content/docs/rules.mdx +++ b/packages/web/src/content/docs/rules.mdx @@ -132,6 +132,54 @@ All instruction files are combined with your `AGENTS.md` files. --- +## Audience + +Instruction files are included for every session and agent by default. Add `opencode.audience` frontmatter to restrict a file: + +```markdown title="AGENTS.md" +--- +opencode: + audience: + - role: main + - agent: reviewer* + - { role: subagent, agent: general* } +--- +``` + +The array is OR: matching any entry includes the file. Fields in one entry are AND, so `{ role: subagent, agent: general* }` requires both conditions. A single role has a shorter form: + +```markdown title="AGENTS.md" +--- +opencode: + audience: main +--- +``` + +| Field | Values | Meaning | +|---|---|---| +| `role` | `main`, `subagent`, `all` | `main` is a session with no parent. `subagent` is any child session. This is structural: an agent configured with `mode: "primary"` is still a `subagent` when dispatched as a child. An agent run directly in a root session is `main`. | +| `agent` | Wildcard pattern | Matches the running agent name with `*` and `?`. Matching is case-sensitive on Linux. | + +Unknown keys inside `opencode` are fatal. This catches routing mistakes such as `audiance`, which valid YAML would otherwise treat as unrelated metadata and silently include everywhere. An empty `opencode: {}` and files with no `opencode` key have no audience directive, so they remain included everywhere and their contents are unchanged. + +Audience filtering applies when opencode assembles instruction files into the system prompt and when the read tool finds nearby instruction files such as a nested `AGENTS.md`. It does not filter arbitrary file contents read by tools. + +:::caution +The V2 session core assembles its system context through a separate path that does not yet apply audience filtering. Sessions served by it include every instruction file regardless of `opencode.audience`, with frontmatter left in place, and a malformed directive is not fatal there. +::: + +:::tip +Claude Code and Codex do not interpret `opencode.audience` when they read the same `AGENTS.md`. Keep shared doctrine in a file those tools still load, or configure an equivalent include for each tool. +::: + +Audience and sparse context are separate filters. Sparse context first keeps project-origin instructions; audience filtering then keeps files matching the current reader. An audience label cannot make a global or configured instruction survive sparse origin filtering. + +Moving doctrine from a global `AGENTS.md` to `config.instructions` also changes assembly order from early to late. Compare the final system prompt before and after moving it when precedence matters. + +Audience-specific root and child prompts create distinct cache prefixes for the same agent. That is intentional, and it is not free: a reader that excludes files no longer shares a prefix with one that includes them. Whether the smaller prompt is worth the extra prefix depends on which instructions are excluded and has not been measured. + +--- + ## Referencing External Files While opencode doesn't automatically parse file references in `AGENTS.md`, you can achieve similar functionality in two ways: From c6c6859ec2f2af5b4a33f48a7cfbfbac0f76ed0f Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:17:28 +0200 Subject: [PATCH 2/2] docs: note that audience role reflects session shape, not work kind --- packages/web/src/content/docs/rules.mdx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/web/src/content/docs/rules.mdx b/packages/web/src/content/docs/rules.mdx index 8ec98e279798..20d4c475d457 100644 --- a/packages/web/src/content/docs/rules.mdx +++ b/packages/web/src/content/docs/rules.mdx @@ -168,6 +168,12 @@ Audience filtering applies when opencode assembles instruction files into the sy The V2 session core assembles its system context through a separate path that does not yet apply audience filtering. Sessions served by it include every instruction file regardless of `opencode.audience`, with frontmatter left in place, and a malformed directive is not fatal there. ::: +:::caution +`role` is derived from whether the session has a parent, so it describes how a session was created rather than what it is being used for. A client that creates parentless sessions to run subagent-class work, such as an external supervisor driving workers over the HTTP API, produces sessions that are `main` by construction and those sessions receive `role: main` files. Scope by `agent` instead when the distinction you need is the kind of work rather than the shape of the session tree. + +This is a per-session property, not a per-installation one: a single opencode process commonly serves both shapes at once, since task-tool subagents carry a parent while externally created sessions may not. A setting that disabled the `role` axis for an installation would break the sessions where it currently works. +::: + :::tip Claude Code and Codex do not interpret `opencode.audience` when they read the same `AGENTS.md`. Keep shared doctrine in a file those tools still load, or configure an equivalent include for each tool. :::