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
23 changes: 21 additions & 2 deletions packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)) {
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading