diff --git a/src/agents/builtin-agents.ts b/src/agents/builtin-agents.ts index b20c166ef..dd91e019f 100644 --- a/src/agents/builtin-agents.ts +++ b/src/agents/builtin-agents.ts @@ -171,6 +171,7 @@ export async function createBuiltinAgents( availableAgents, availableSkills, mergedCategories, + directory, userCategories: categories, }) if (atlasConfig) { diff --git a/src/agents/builtin-agents/agent-overrides.ts b/src/agents/builtin-agents/agent-overrides.ts index ad80e8d6f..89873def6 100644 --- a/src/agents/builtin-agents/agent-overrides.ts +++ b/src/agents/builtin-agents/agent-overrides.ts @@ -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) as AgentOverrideConfig const { prompt_append, ...rest } = migratedOverride const merged = deepMerge(base, rest as Partial) 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 + mergedCategories: Record, + directory?: string ): AgentConfig { let result = config const overrideCategory = (override as Record | 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 diff --git a/src/agents/builtin-agents/atlas-agent.ts b/src/agents/builtin-agents/atlas-agent.ts index e9c3d17ef..f1658ebc9 100644 --- a/src/agents/builtin-agents/atlas-agent.ts +++ b/src/agents/builtin-agents/atlas-agent.ts @@ -16,6 +16,7 @@ export function maybeCreateAtlasConfig(input: { availableAgents: AvailableAgent[] availableSkills: AvailableSkill[] mergedCategories: Record + 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 } diff --git a/src/agents/builtin-agents/general-agents.ts b/src/agents/builtin-agents/general-agents.ts index 491e7be11..c84dea2e8 100644 --- a/src/agents/builtin-agents/general-agents.ts +++ b/src/agents/builtin-agents/general-agents.ts @@ -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) diff --git a/src/agents/builtin-agents/hephaestus-agent.ts b/src/agents/builtin-agents/hephaestus-agent.ts index 649ee293d..dc1ac25d3 100644 --- a/src/agents/builtin-agents/hephaestus-agent.ts +++ b/src/agents/builtin-agents/hephaestus-agent.ts @@ -85,7 +85,7 @@ export function maybeCreateHephaestusConfig(input: { } if (hephaestusOverride) { - hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride) + hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride, directory) } return hephaestusConfig } diff --git a/src/agents/builtin-agents/resolve-file-uri.test.ts b/src/agents/builtin-agents/resolve-file-uri.test.ts new file mode 100644 index 000000000..9c045babd --- /dev/null +++ b/src/agents/builtin-agents/resolve-file-uri.test.ts @@ -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") + }) +}) diff --git a/src/agents/builtin-agents/resolve-file-uri.ts b/src/agents/builtin-agents/resolve-file-uri.ts new file mode 100644 index 000000000..56c3ace5f --- /dev/null +++ b/src/agents/builtin-agents/resolve-file-uri.ts @@ -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}]` + } +} diff --git a/src/agents/builtin-agents/sisyphus-agent.ts b/src/agents/builtin-agents/sisyphus-agent.ts index 11e34e22a..11b313e21 100644 --- a/src/agents/builtin-agents/sisyphus-agent.ts +++ b/src/agents/builtin-agents/sisyphus-agent.ts @@ -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 diff --git a/src/agents/sisyphus-junior/default.ts b/src/agents/sisyphus-junior/default.ts index fea9e7150..85d919556 100644 --- a/src/agents/sisyphus-junior/default.ts +++ b/src/agents/sisyphus-junior/default.ts @@ -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: ` if (!promptAppend) return prompt - return prompt + "\n\n" + promptAppend + return prompt + "\n\n" + resolvePromptAppend(promptAppend) } function buildConstraintsSection(useTaskSystem: boolean): string { diff --git a/src/agents/sisyphus-junior/gpt.ts b/src/agents/sisyphus-junior/gpt.ts index e4a849651..1db0e5666 100644 --- a/src/agents/sisyphus-junior/gpt.ts +++ b/src/agents/sisyphus-junior/gpt.ts @@ -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: ` if (!promptAppend) return prompt - return prompt + "\n\n" + promptAppend + return prompt + "\n\n" + resolvePromptAppend(promptAppend) } function buildGptBlockedActionsSection(useTaskSystem: boolean): string { diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index 8fd48e330..876560ec6 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -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(), diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts index 6e3129add..fa6c12a48 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -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; }