Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
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
Loading