Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
iceteaSA committed Aug 22, 2026
commit aa3cb48e406b1f28eee69fec272f869368240eb5
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/debug/agent.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions packages/opencode/src/session/instruction-audience.ts
Original file line number Diff line number Diff line change
@@ -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<AudienceError>()("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"
87 changes: 68 additions & 19 deletions packages/opencode/src/session/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
Expand All @@ -34,13 +35,14 @@ function extract(messages: SessionV1.WithParts[]) {
export interface Interface {
readonly clear: (messageID: MessageID) => Effect.Effect<void>
readonly systemPaths: () => Effect.Effect<Set<string>, FSUtil.Error>
readonly system: () => Effect.Effect<string[], FSUtil.Error>
readonly system: (reader: InstructionAudience.Reader) => Effect.Effect<string[], FSUtil.Error | InstructionAudience.AudienceError>
readonly find: (dir: string) => Effect.Effect<string | undefined, FSUtil.Error>
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<Service, Interface>()("@opencode/Instruction") {}
Expand Down Expand Up @@ -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<string>()
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
}
}
Expand All @@ -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)
Expand All @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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),
])
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
messageID: MessageID
agent: string
reader?: { role: "main" | "subagent"; agent: string }
abort: AbortSignal
callID?: string
extra?: { [key: string]: unknown }
Expand Down
Loading