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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,26 @@ Use `prompt_append` to add extra instructions without replacing the default syst
}
```

You can also reference external files using `file://` URIs:

```json
{
"agents": {
"oracle": {
"prompt_append": "file://~/.config/opencode/my-oracle-instructions.md"
},
"Sisyphus": {
"prompt_append": "file://./project-context.md"
}
}
}
```

Supported URI formats:
- `file:///absolute/path` - Absolute path
- `file://relative/path` - Relative to project root
- `file://~/path` - Relative to home directory

You can also override settings for `Sisyphus` (the main orchestrator) and `build` (the default agent) using the same options.

#### Permission Options
Expand Down
86 changes: 85 additions & 1 deletion src/agents/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import { describe, test, expect } from "bun:test"
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
import { mkdirSync, writeFileSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import { createBuiltinAgents } from "./utils"
import type { AgentConfig } from "@opencode-ai/sdk"

const TEST_DIR = join(tmpdir(), "utils-test-prompts")
const TEST_PROMPT_FILE = join(TEST_DIR, "custom-prompt.md")
const TEST_PROMPT_CONTENT = "Custom prompt from file\nWith multiple lines"
const TEST_ENCODED_FILE = join(TEST_DIR, "my prompt file.md")
const TEST_ENCODED_CONTENT = "Prompt from percent-encoded path"

beforeAll(() => {
mkdirSync(TEST_DIR, { recursive: true })
writeFileSync(TEST_PROMPT_FILE, TEST_PROMPT_CONTENT)
writeFileSync(TEST_ENCODED_FILE, TEST_ENCODED_CONTENT)
})

afterAll(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
})

describe("createBuiltinAgents with model overrides", () => {
test("Sisyphus with default model has thinking config", () => {
// #given - no overrides
Expand Down Expand Up @@ -72,6 +91,71 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.oracle.textVerbosity).toBeUndefined()
})

test("resolves file:// URI in prompt_append with absolute path", () => {
// #given
const overrides = {
oracle: { prompt_append: `file://${TEST_PROMPT_FILE}` },
}

// #when
const agents = createBuiltinAgents([], overrides)

// #then
expect(agents.oracle.prompt).toContain(TEST_PROMPT_CONTENT)
})

test("resolves file:// URI in prompt_append with relative path", () => {
// #given
const overrides = {
oracle: { prompt_append: "file://custom-prompt.md" },
}

// #when
const agents = createBuiltinAgents([], overrides, TEST_DIR)

// #then
expect(agents.oracle.prompt).toContain(TEST_PROMPT_CONTENT)
})

test("handles non-existent file:// URI in prompt_append with warning", () => {
// #given
const overrides = {
oracle: { prompt_append: "file://non-existent-prompt.md" },
}

// #when
const agents = createBuiltinAgents([], overrides, TEST_DIR)

// #then
expect(agents.oracle.prompt).toContain("[WARNING: Could not resolve file URI: file://non-existent-prompt.md]")
})

test("resolves file:// URI with percent-encoded path", () => {
// #given - filename has space: "my prompt file.md" → "my%20prompt%20file.md"
const overrides = {
oracle: { prompt_append: "file://my%20prompt%20file.md" },
}

// #when
const agents = createBuiltinAgents([], overrides, TEST_DIR)

// #then
expect(agents.oracle.prompt).toContain(TEST_ENCODED_CONTENT)
})

test("handles malformed percent-encoding in file URI with warning", () => {
// #given - malformed percent-encoding (incomplete escape sequence)
const overrides = {
oracle: { prompt_append: "file://my%prompt.md" },
}

// #when
const agents = createBuiltinAgents([], overrides, TEST_DIR)

// #then
expect(agents.oracle.prompt).toContain("[WARNING: Malformed file URI (invalid percent-encoding): file://my%prompt.md]")
})

test("non-model overrides are still applied after factory rebuild", () => {
// #given
const overrides = {
Expand Down
35 changes: 30 additions & 5 deletions src/agents/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { createOrchestratorSisyphusAgent, orchestratorSisyphusAgent } from "./or
import { createMomusAgent } from "./momus"
import type { AvailableAgent } from "./sisyphus-prompt-builder"
import { deepMerge } from "../shared"
import { existsSync, readFileSync } from "fs"
import { resolve, isAbsolute } from "path"
import { homedir } from "os"
import { DEFAULT_CATEGORIES } from "../tools/sisyphus-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"

Expand Down Expand Up @@ -113,13 +116,35 @@ export function createEnvContext(): string {

function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
override: AgentOverrideConfig,
configDir?: string
): AgentConfig {
const { prompt_append, ...rest } = override
const merged = deepMerge(base, rest as Partial<AgentConfig>)

if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
let resolved = prompt_append
if (prompt_append.startsWith("file://")) {
let path: string
try {
path = decodeURIComponent(prompt_append.slice(7))
} catch {
resolved = `[WARNING: Malformed file URI (invalid percent-encoding): ${prompt_append}]`
path = ""
}

if (path) {
const abs = path.startsWith("~/")
? resolve(homedir(), path.slice(2))
: isAbsolute(path)
? path
: resolve(configDir ?? process.cwd(), path)
resolved = existsSync(abs)
? readFileSync(abs, "utf-8")
: `[WARNING: Could not resolve file URI: ${prompt_append}]`
}
}
merged.prompt = merged.prompt + "\n" + resolved
}

return merged
Expand Down Expand Up @@ -157,7 +182,7 @@ export function createBuiltinAgents(
}

if (override) {
config = mergeAgentConfig(config, override)
config = mergeAgentConfig(config, override, directory)
}

result[name] = config
Expand All @@ -184,7 +209,7 @@ export function createBuiltinAgents(
}

if (sisyphusOverride) {
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride, directory)
}

result["Sisyphus"] = sisyphusConfig
Expand All @@ -199,7 +224,7 @@ export function createBuiltinAgents(
})

if (orchestratorOverride) {
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride, directory)
}

result["orchestrator-sisyphus"] = orchestratorConfig
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const AgentOverrideConfigSchema = z.object({
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
prompt: z.string().optional(),
/** Text to append to agent prompt. Supports file:// URIs (file:///abs, file://rel, file://~/home) */
prompt_append: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),
Expand Down