diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 94c8d8fe008..538d83bd288 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -12,6 +12,7 @@ import * as ConfigMarkdown from "./markdown" import { ConfigModelID } from "./model-id" import { ConfigParse } from "./parse" import { ConfigPermission } from "./permission" +import { ConfigVariable } from "./variable" const log = Log.create({ service: "config" }) @@ -129,10 +130,19 @@ export async function load(dir: string) { const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] const name = configEntryNameFromPath(item, patterns) + const body = md.content.trim() + if (body && md.data.prompt) { + log.warn("agent has both body and frontmatter prompt, body takes precedence", { agent: item }) + } + let prompt: string = body || md.data.prompt || "" + if (prompt) { + prompt = await ConfigVariable.substitute({ type: "path", path: item, text: prompt, missing: "empty" }) + } + const config = { name, ...md.data, - prompt: md.content.trim(), + prompt, } result[config.name] = ConfigParse.effectSchema(Info, config, item) } @@ -158,10 +168,19 @@ export async function loadMode(dir: string) { }) if (!md) continue + const body = md.content.trim() + if (body && md.data.prompt) { + log.warn("mode has both body and frontmatter prompt, body takes precedence", { mode: item }) + } + let prompt: string = body || md.data.prompt || "" + if (prompt) { + prompt = await ConfigVariable.substitute({ type: "path", path: item, text: prompt, missing: "empty" }) + } + const config = { name: configEntryNameFromPath(item, []), ...md.data, - prompt: md.content.trim(), + prompt, } const parsed = Schema.decodeUnknownExit(Info)(config, { errors: "all", propertyOrder: "original" }) if (Exit.isSuccess(parsed)) { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c7990d1b353..9781bab520d 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -101,6 +101,9 @@ const live: Layer.Layer< const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" const system: string[] = [] + if (!input.agent.native && input.agent.prompt !== undefined && !input.agent.prompt) { + l.warn("agent prompt is empty, falling back to provider default", { agent: input.agent.name }) + } system.push( [ // use agent prompt otherwise provider prompt diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index bbe585237b0..d46847c9574 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -843,6 +843,119 @@ Nested agent prompt`, }) }) +test("agent .md with frontmatter prompt and empty body uses frontmatter prompt", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const agentDir = path.join(dir, ".opencode", "agent") + await fs.mkdir(agentDir, { recursive: true }) + + await Filesystem.write( + path.join(agentDir, "frontmatter-only.md"), + `--- +model: test/model +prompt: "You are a custom assistant." +--- +`, + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.agent?.["frontmatter-only"]).toEqual( + expect.objectContaining({ + name: "frontmatter-only", + prompt: "You are a custom assistant.", + }), + ) + }, + }) +}) + +test("agent .md with both body and frontmatter prompt uses body", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const agentDir = path.join(dir, ".opencode", "agent") + await fs.mkdir(agentDir, { recursive: true }) + + await Filesystem.write( + path.join(agentDir, "both.md"), + `--- +model: test/model +prompt: "Frontmatter prompt" +--- +Body prompt wins`, + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.agent?.["both"]).toEqual( + expect.objectContaining({ + name: "both", + prompt: "Body prompt wins", + }), + ) + }, + }) +}) + +test("agent .md prompt resolves {env:VAR} substitution", async () => { + process.env.__TEST_AGENT_VAR = "resolved-value" + await using tmp = await tmpdir({ + init: async (dir) => { + const agentDir = path.join(dir, ".opencode", "agent") + await fs.mkdir(agentDir, { recursive: true }) + + await Filesystem.write( + path.join(agentDir, "env-sub.md"), + `--- +model: test/model +--- +Hello {env:__TEST_AGENT_VAR}`, + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.agent?.["env-sub"]?.prompt).toBe("Hello resolved-value") + }, + }) + delete process.env.__TEST_AGENT_VAR +}) + +test("agent .md frontmatter prompt resolves {file:path} substitution", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const agentDir = path.join(dir, ".opencode", "agent") + await fs.mkdir(agentDir, { recursive: true }) + + await Filesystem.write(path.join(dir, "system-prompt.txt"), "File content here") + + await Filesystem.write( + path.join(agentDir, "file-sub.md"), + `--- +model: test/model +prompt: "{file:../../system-prompt.txt}" +--- +`, + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(config.agent?.["file-sub"]?.prompt).toBe("File content here") + }, + }) +}) + test("loads commands from .opencode/command (singular)", async () => { await using tmp = await tmpdir({ init: async (dir) => {