diff --git a/README.md b/README.md index a23e3fdc0e..023cc55235 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 336ed628a3..c2d3bb9bc4 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -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 @@ -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 = { diff --git a/src/agents/utils.ts b/src/agents/utils.ts index d831caa871..2a8cf3247d 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -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" @@ -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) 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 @@ -157,7 +182,7 @@ export function createBuiltinAgents( } if (override) { - config = mergeAgentConfig(config, override) + config = mergeAgentConfig(config, override, directory) } result[name] = config @@ -184,7 +209,7 @@ export function createBuiltinAgents( } if (sisyphusOverride) { - sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride) + sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride, directory) } result["Sisyphus"] = sisyphusConfig @@ -199,7 +224,7 @@ export function createBuiltinAgents( }) if (orchestratorOverride) { - orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride) + orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride, directory) } result["orchestrator-sisyphus"] = orchestratorConfig diff --git a/src/config/schema.ts b/src/config/schema.ts index d5fad7e014..685bc703b7 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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(),