Merge pull request #821 from devxoul/prompt-append-file-uri

feat: add file:// URI support in agent prompt_append
This commit is contained in:
YeonGyu-Kim
2026-02-13 11:30:27 +09:00
committed by GitHub
12 changed files with 166 additions and 12 deletions

View File

@@ -171,6 +171,7 @@ export async function createBuiltinAgents(
availableAgents,
availableSkills,
mergedCategories,
directory,
userCategories: categories,
})
if (atlasConfig) {

View File

@@ -2,6 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrideConfig } from "../types"
import type { CategoryConfig } from "../../config/schema"
import { deepMerge, migrateAgentConfig } from "../../shared"
import { resolvePromptAppend } from "./resolve-file-uri"
/**
* Expands a category reference from an agent override into concrete config properties.
@@ -28,19 +29,23 @@ export function applyCategoryOverride(
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
if (categoryConfig.prompt_append && typeof result.prompt === "string") {
result.prompt = result.prompt + "\n" + categoryConfig.prompt_append
result.prompt = result.prompt + "\n" + resolvePromptAppend(categoryConfig.prompt_append)
}
return result as AgentConfig
}
export function mergeAgentConfig(base: AgentConfig, override: AgentOverrideConfig): AgentConfig {
export function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig,
directory?: string
): AgentConfig {
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
const { prompt_append, ...rest } = migratedOverride
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append, directory)
}
return merged
@@ -49,7 +54,8 @@ export function mergeAgentConfig(base: AgentConfig, override: AgentOverrideConfi
export function applyOverrides(
config: AgentConfig,
override: AgentOverrideConfig | undefined,
mergedCategories: Record<string, CategoryConfig>
mergedCategories: Record<string, CategoryConfig>,
directory?: string
): AgentConfig {
let result = config
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
@@ -58,7 +64,7 @@ export function applyOverrides(
}
if (override) {
result = mergeAgentConfig(result, override)
result = mergeAgentConfig(result, override, directory)
}
return result

View File

@@ -16,6 +16,7 @@ export function maybeCreateAtlasConfig(input: {
availableAgents: AvailableAgent[]
availableSkills: AvailableSkill[]
mergedCategories: Record<string, CategoryConfig>
directory?: string
userCategories?: CategoriesConfig
useTaskSystem?: boolean
}): AgentConfig | undefined {
@@ -28,6 +29,7 @@ export function maybeCreateAtlasConfig(input: {
availableAgents,
availableSkills,
mergedCategories,
directory,
userCategories,
} = input
@@ -58,7 +60,7 @@ export function maybeCreateAtlasConfig(input: {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories, directory)
return orchestratorConfig
}

View File

@@ -84,7 +84,7 @@ export function collectPendingBuiltinAgents(input: {
config = applyEnvironmentContext(config, directory)
}
config = applyOverrides(config, override, mergedCategories)
config = applyOverrides(config, override, mergedCategories, directory)
// Store for later - will be added after sisyphus and hephaestus
pendingAgentConfigs.set(name, config)

View File

@@ -85,7 +85,7 @@ export function maybeCreateHephaestusConfig(input: {
}
if (hephaestusOverride) {
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride)
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride, directory)
}
return hephaestusConfig
}

View File

@@ -0,0 +1,109 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { resolvePromptAppend } from "./resolve-file-uri"
describe("resolvePromptAppend", () => {
const fixtureRoot = join(tmpdir(), `resolve-file-uri-${Date.now()}`)
const configDir = join(fixtureRoot, "config")
const homeFixtureDir = join(homedir(), `.resolve-file-uri-home-${Date.now()}`)
const absoluteFilePath = join(fixtureRoot, "absolute.txt")
const relativeFilePath = join(configDir, "relative.txt")
const spacedFilePath = join(fixtureRoot, "with space.txt")
const homeFilePath = join(homeFixtureDir, "home.txt")
beforeAll(() => {
mkdirSync(fixtureRoot, { recursive: true })
mkdirSync(configDir, { recursive: true })
mkdirSync(homeFixtureDir, { recursive: true })
writeFileSync(absoluteFilePath, "absolute-content", "utf8")
writeFileSync(relativeFilePath, "relative-content", "utf8")
writeFileSync(spacedFilePath, "encoded-content", "utf8")
writeFileSync(homeFilePath, "home-content", "utf8")
})
afterAll(() => {
rmSync(fixtureRoot, { recursive: true, force: true })
rmSync(homeFixtureDir, { recursive: true, force: true })
})
test("returns non-file URI strings unchanged", () => {
//#given
const input = "append this text"
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toBe(input)
})
test("resolves absolute file URI to file contents", () => {
//#given
const input = `file://${absoluteFilePath}`
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toBe("absolute-content")
})
test("resolves relative file URI using configDir", () => {
//#given
const input = "file://./relative.txt"
//#when
const resolved = resolvePromptAppend(input, configDir)
//#then
expect(resolved).toBe("relative-content")
})
test("resolves home directory URI path", () => {
//#given
const input = `file://~/${homeFixtureDir.split("/").pop()}/home.txt`
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toBe("home-content")
})
test("resolves percent-encoded URI path", () => {
//#given
const input = `file://${encodeURIComponent(spacedFilePath)}`
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toBe("encoded-content")
})
test("returns warning for malformed percent-encoding", () => {
//#given
const input = "file://%E0%A4%A"
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toContain("[WARNING: Malformed file URI")
})
test("returns warning when file does not exist", () => {
//#given
const input = "file:///path/does/not/exist.txt"
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toContain("[WARNING: Could not resolve file URI")
})
})

View File

@@ -0,0 +1,30 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { isAbsolute, resolve } from "node:path"
export function resolvePromptAppend(promptAppend: string, configDir?: string): string {
if (!promptAppend.startsWith("file://")) return promptAppend
const encoded = promptAppend.slice(7)
let filePath: string
try {
const decoded = decodeURIComponent(encoded)
const expanded = decoded.startsWith("~/") ? decoded.replace(/^~\//, `${homedir()}/`) : decoded
filePath = isAbsolute(expanded)
? expanded
: resolve(configDir ?? process.cwd(), expanded)
} catch {
return `[WARNING: Malformed file URI (invalid percent-encoding): ${promptAppend}]`
}
if (!existsSync(filePath)) {
return `[WARNING: Could not resolve file URI: ${promptAppend}]`
}
try {
return readFileSync(filePath, "utf8")
} catch {
return `[WARNING: Could not read file: ${promptAppend}]`
}
}

View File

@@ -77,7 +77,7 @@ export function maybeCreateSisyphusConfig(input: {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories, directory)
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
return sisyphusConfig

View File

@@ -7,6 +7,8 @@
* - Extended reasoning context for complex tasks
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
export function buildDefaultSisyphusJuniorPrompt(
useTaskSystem: boolean,
promptAppend?: string
@@ -40,7 +42,7 @@ Task NOT complete without:
</Style>`
if (!promptAppend) return prompt
return prompt + "\n\n" + promptAppend
return prompt + "\n\n" + resolvePromptAppend(promptAppend)
}
function buildConstraintsSection(useTaskSystem: boolean): string {

View File

@@ -16,6 +16,8 @@
* - Explicit decision criteria needed (model won't infer)
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
export function buildGptSisyphusJuniorPrompt(
useTaskSystem: boolean,
promptAppend?: string
@@ -85,7 +87,7 @@ Task NOT complete without evidence:
</style_spec>`
if (!promptAppend) return prompt
return prompt + "\n\n" + promptAppend
return prompt + "\n\n" + resolvePromptAppend(promptAppend)
}
function buildGptBlockedActionsSection(useTaskSystem: boolean): string {

View File

@@ -12,6 +12,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(),

View File

@@ -1,5 +1,6 @@
import type { CategoryConfig } from "../config/schema";
import { PROMETHEUS_PERMISSION, PROMETHEUS_SYSTEM_PROMPT } from "../agents/prometheus";
import { resolvePromptAppend } from "../agents/builtin-agents/resolve-file-uri";
import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements";
import {
fetchAvailableModels,
@@ -92,7 +93,7 @@ export async function buildPrometheusAgentConfig(params: {
const { prompt_append, ...restOverride } = override;
const merged = { ...base, ...restOverride };
if (prompt_append && typeof merged.prompt === "string") {
merged.prompt = merged.prompt + "\n" + prompt_append;
merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append);
}
return merged;
}