Merge pull request #8349 from mmospanenko/feature/7507-context

feat(config): add {file:...} syntax support for agent markdown prompts
This commit is contained in:
Catriel Müller
2026-05-13 00:46:45 -03:00
committed by GitHub
10 changed files with 215 additions and 14 deletions
+13 -1
View File
@@ -1,5 +1,6 @@
export * as ConfigAgent from "./agent"
import path from "path" // kilocode_change
import { Exit, Schema, SchemaGetter } from "effect"
import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
@@ -13,6 +14,7 @@ import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
import { ConfigParse } from "./parse"
import { ConfigPermission } from "./permission"
import { ConfigVariable } from "./variable" // kilocode_change
// kilocode_change start
import { KilocodeConfig } from "@/kilocode/config/config"
import type { Warning } from "./config"
@@ -162,11 +164,21 @@ export async function load(dir: string, warnings?: Warning[]) {
// kilocode_change end
const name = configEntryNameFromPath(item, patterns)
// kilocode_change start - substitute agent prompt variables relative to the agent file
const prompt = await ConfigVariable.substitute({
text: md.content.trim(),
type: "virtual",
dir: path.dirname(item),
source: item,
missing: "empty",
escapeJson: false,
})
const config = {
name,
...md.data,
prompt: md.content.trim(),
prompt,
}
// kilocode_change end
// kilocode_change start - use Effect schema (propertyOrder: original) + non-fatal handleInvalid
try {
result[config.name] = ConfigParse.effectSchema(Info, config, item) as Info
+23 -5
View File
@@ -2,6 +2,7 @@ import { NamedError } from "@opencode-ai/core/util/error"
import matter from "gray-matter"
import { z } from "zod"
import { Filesystem } from "@/util/filesystem"
import { KilocodeMarkdown } from "../kilocode/config/markdown" // kilocode_change
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export const SHELL_REGEX = /!`([^`]+)`/g
@@ -53,10 +54,10 @@ export function fallbackSanitization(content: string): string {
continue
}
// if value contains a colon, convert to block scalar
if (value.includes(":")) {
result.push(`${key}: |-`)
result.push(` ${value}`)
// kilocode_change start - preserve unquoted colon values as exact strings
result.push(`${key}: "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
// kilocode_change end
continue
}
@@ -70,12 +71,16 @@ export function fallbackSanitization(content: string): string {
export async function parse(filePath: string) {
const template = await Filesystem.readText(filePath)
// kilocode_change start - substitute content and retry invalid frontmatter with permissive sanitization
try {
const md = matter(template)
md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change
return md
} catch {
try {
return matter(fallbackSanitization(template))
const md = matter(fallbackSanitization(template))
md.content = await KilocodeMarkdown.substitute(md.content, filePath) // kilocode_change
return md
} catch (err) {
throw new FrontmatterError(
{
@@ -86,8 +91,10 @@ export async function parse(filePath: string) {
)
}
}
// kilocode_change end
}
// kilocode_change start - export structured frontmatter parse errors
export const FrontmatterError = NamedError.create(
"ConfigFrontmatterError",
z.object({
@@ -95,5 +102,16 @@ export const FrontmatterError = NamedError.create(
message: z.string(),
}),
)
// kilocode_change end
export * as ConfigMarkdown from "./markdown"
// kilocode_change start - export helpers as namespace object
export const ConfigMarkdown = {
FILE_REGEX,
SHELL_REGEX,
files,
shell,
fallbackSanitization,
parse,
FrontmatterError,
}
// kilocode_change end
+3 -1
View File
@@ -19,6 +19,7 @@ type ParseSource =
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
escapeJson?: boolean // kilocode_change
}
function source(input: ParseSource) {
@@ -32,6 +33,7 @@ function dir(input: ParseSource) {
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(input: SubstituteInput) {
const missing = input.missing ?? "error"
const escape = input.escapeJson ?? true // kilocode_change
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
@@ -81,7 +83,7 @@ export async function substitute(input: SubstituteInput) {
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
out += escape ? JSON.stringify(fileContent).slice(1, -1) : fileContent // kilocode_change
cursor = index + token.length
}
@@ -0,0 +1,38 @@
import os from "os"
import path from "path"
import { Filesystem } from "@/util/filesystem"
export namespace KilocodeMarkdown {
function ref(token: string) {
const file = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (file.startsWith("~/")) return path.join(os.homedir(), file.slice(2))
return file
}
export async function substitute(text: string, item: string) {
const body = text.replace(/\{env:([^}]+)\}/g, (_, name) => process.env[name] || "")
const matches = Array.from(body.matchAll(/\{file:[^}]+\}/g))
if (!matches.length) return body
const dir = path.dirname(item)
const chunks = await Promise.all(
matches.map(async (match, i) => {
const token = match[0]
const index = match.index ?? 0
const prev = matches[i - 1]
const cursor = prev ? (prev.index ?? 0) + prev[0].length : 0
const head = body.slice(cursor, index)
const start = body.lastIndexOf("\n", index - 1) + 1
const prefix = body.slice(start, index).trimStart()
if (prefix.startsWith("//")) return head + token
const file = ref(token)
const target = path.isAbsolute(file) ? file : path.resolve(dir, file)
const content = await Filesystem.readText(target).catch(() => "")
return head + content.trim()
}),
)
const last = matches.at(-1)
return chunks.join("") + (last ? body.slice((last.index ?? 0) + last[0].length) : "")
}
}
@@ -0,0 +1,7 @@
import { KilocodeMarkdown } from "../config/markdown"
export namespace KilocodeInstruction {
export function content(text: string, item: string) {
return KilocodeMarkdown.substitute(text, item)
}
}
@@ -4,6 +4,7 @@ import os from "os"
import type { Config } from "../config/config"
import type { ConfigCommand } from "../config/command"
import { Filesystem } from "../util/filesystem"
import { KilocodeMarkdown } from "./config/markdown"
import { KilocodePaths } from "./paths"
export namespace WorkflowsMigrator {
@@ -59,7 +60,7 @@ export namespace WorkflowsMigrator {
const files = await findWorkflowFiles(dir)
const workflows: KilocodeWorkflow[] = []
for (const file of files) {
const content = await fs.readFile(file, "utf-8")
const content = await KilocodeMarkdown.substitute(await fs.readFile(file, "utf-8"), file)
workflows.push({
name: extractNameFromFilename(file),
path: file,
+3 -1
View File
@@ -7,6 +7,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "@opencode-ai/core/global"
import { KilocodeInstruction } from "@/kilocode/session/instruction" // kilocode_change
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
@@ -88,7 +89,8 @@ export const layer: Layer.Layer<
})
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
const content = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) // kilocode_change
return yield* Effect.promise(() => KilocodeInstruction.content(content, filepath)) // kilocode_change
})
const fetch = Effect.fnUntraced(function* (url: string) {
@@ -11,6 +11,7 @@ import { Npm } from "@opencode-ai/core/npm"
import { Account } from "../../../src/account/account"
import { Auth } from "../../../src/auth"
import { Config } from "../../../src/config/config"
import { ConfigMarkdown } from "../../../src/config/markdown"
import { Env } from "../../../src/env"
import { KiloIndexing } from "../../../src/kilocode/indexing"
import { Instance } from "../../../src/project/instance"
@@ -64,12 +65,33 @@ const cfg: Partial<Config.Info> = {
},
}
describe("kilocode indexing config", () => {
afterEach(async () => {
await disposeAllInstances()
await clear(true)
})
afterEach(async () => {
delete process.env.KILO_MD_TEST
await disposeAllInstances()
await clear(true)
})
describe("markdown substitutions", () => {
test("applies file and env substitutions to parsed markdown body", async () => {
process.env.KILO_MD_TEST = "env content"
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "body.md"), "file content")
await Filesystem.write(
path.join(dir, "SKILL.md"),
["---", "name: test", "description: Test", "---", "{file:body.md}", "{env:KILO_MD_TEST}"].join("\n"),
)
},
})
const md = await ConfigMarkdown.parse(path.join(tmp.path, "SKILL.md"))
expect(md.content).toContain("file content")
expect(md.content).toContain("env content")
})
})
describe("kilocode indexing config", () => {
test("keeps global indexing enabled in global config", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir()
@@ -0,0 +1,76 @@
import { describe, expect } from "bun:test"
import path from "node:path"
import { Effect, FileSystem, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { NodeFileSystem } from "@effect/platform-node"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Config } from "../../../src/config/config"
import { emptyConsoleState } from "../../../src/config/console-state"
import { Instruction } from "../../../src/session/instruction"
import { MessageID } from "../../../src/session/schema"
import { Global } from "@opencode-ai/core/global"
import { provideTmpdirInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
const configLayer = Layer.succeed(
Config.Service,
Config.Service.of({
get: () => Effect.succeed({}),
getGlobal: () => Effect.succeed({}),
getConsoleState: () => Effect.succeed(emptyConsoleState),
update: () => Effect.void,
updateGlobal: (config) => Effect.succeed(config),
invalidate: () => Effect.void,
directories: () => Effect.succeed([]),
waitForDependencies: () => Effect.void,
warnings: () => Effect.succeed([]),
}),
)
const layer = (dir: string) =>
Instruction.layer.pipe(
Layer.provide(configLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Global.layerWith({ home: dir, config: dir })),
)
const write = (filepath: string, content: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(filepath), { recursive: true })
yield* fs.writeFileString(filepath, content)
})
describe("instruction markdown substitutions", () => {
it.live("applies file and env substitutions to nearby AGENTS.md", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
process.env.KILO_INSTRUCTION_TEST = "env content"
yield* write(path.join(dir, "subdir", "guide.md"), "file content")
yield* write(
path.join(dir, "subdir", "AGENTS.md"),
["# Instructions", "", "{file:guide.md}", "{env:KILO_INSTRUCTION_TEST}"].join("\n"),
)
yield* write(path.join(dir, "subdir", "nested", "file.ts"), "const value = 1")
const svc = yield* Instruction.Service
const results = yield* svc.resolve(
[],
path.join(dir, "subdir", "nested", "file.ts"),
MessageID.ascending(),
)
expect(results).toHaveLength(1)
expect(results[0].content).toContain("file content")
expect(results[0].content).toContain("env content")
expect(results[0].content).not.toContain("{file:")
expect(results[0].content).not.toContain("{env:")
delete process.env.KILO_INSTRUCTION_TEST
}).pipe(Effect.provide(layer(dir))),
),
)
})
@@ -136,6 +136,29 @@ Actual description here.`
workflows.some((w) => w.source === "global" && w.path.includes(path.join(".kilo", "workflows", "global.md"))),
).toBe(true)
})
test("applies markdown substitutions to workflow content", async () => {
process.env.KILO_WORKFLOW_TEST = "env content"
await using tmp = await tmpdir({
init: async (dir) => {
const workflowsDir = path.join(dir, ".kilo", "workflows")
await Bun.write(path.join(dir, "guide.md"), "file content")
await Bun.write(
path.join(workflowsDir, "workflow.md"),
["# Workflow", "", "{file:../../guide.md}", "{env:KILO_WORKFLOW_TEST}"].join("\n"),
)
},
})
try {
const workflows = await WorkflowsMigrator.discoverWorkflows(tmp.path, true)
expect(workflows[0].content).toContain("file content")
expect(workflows[0].content).toContain("env content")
} finally {
delete process.env.KILO_WORKFLOW_TEST
}
})
})
describe("convertToCommand", () => {