diff --git a/.changeset/protect-swe-pruner-instructions.md b/.changeset/protect-swe-pruner-instructions.md
new file mode 100644
index 00000000000..8bb765be72d
--- /dev/null
+++ b/.changeset/protect-swe-pruner-instructions.md
@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Exclude directory-scoped AGENTS.md instructions from SWE-Pruner context.
diff --git a/packages/opencode/src/kilocode/swe-pruner.ts b/packages/opencode/src/kilocode/swe-pruner.ts
index 6a7d50bbba5..68ec046b302 100644
--- a/packages/opencode/src/kilocode/swe-pruner.ts
+++ b/packages/opencode/src/kilocode/swe-pruner.ts
@@ -32,6 +32,9 @@ const KEEP_TAIL = 5
const MERGE_GAP = 2
const MAX_KEEP_RATIO = 0.9
const TIMEOUT_MS = 15_000
+const CLOSE = "\n"
+const FILE = "\nfile\n\n"
+const REMINDER = `${CLOSE}\n\n\n`
const DESCRIPTION = [
"Optional focus question used to prune this tool's output to only the relevant lines.",
@@ -126,10 +129,27 @@ export function kept(ranges: Range[]) {
return ranges.reduce((sum, [start, end]) => sum + (end - start + 1), 0)
}
+function partition(tool: string, result: Tool.ExecuteResult) {
+ if (tool !== "read") return { body: result.output, tail: "", extra: 0 }
+ const loaded = result.metadata["loaded"]
+ if (!Array.isArray(loaded) || loaded.some((item) => typeof item !== "string")) return undefined
+ const start = result.output.indexOf(FILE)
+ const index = start < 0 ? -1 : result.output.indexOf(REMINDER, start + FILE.length)
+ if (loaded.length === 0) return index < 0 ? { body: result.output, tail: "", extra: 0 } : undefined
+ if (index < 0) return undefined
+ const split = index + CLOSE.length
+ const tail = result.output.slice(split)
+ return {
+ body: result.output.slice(0, split),
+ tail,
+ extra: tail.split("\n").length - 1,
+ }
+}
+
/** Reassemble the output from keep-ranges, marking omitted sections inline. */
-export function assemble(lines: string[], ranges: Range[], total: number) {
+export function assemble(lines: string[], ranges: Range[], total: number, extra = 0) {
const parts: string[] = [
- `[SWE-Pruner: kept ${kept(ranges)} of ${total} output lines relevant to the focus question. Omitted sections are marked below; call the tool again without ${PARAMETER} for the full output.]`,
+ `[SWE-Pruner: kept ${kept(ranges) + extra} of ${total + extra} output lines relevant to the focus question. Omitted sections are marked below; call the tool again without ${PARAMETER} for the full output.]`,
]
let cursor = 1
for (const [start, end] of ranges) {
@@ -158,7 +178,12 @@ const resolve = Effect.fn("SwePruner.resolve")(function* () {
return (yield* provider.getSmallModel(ref.providerID)) ?? (yield* provider.getModel(ref.providerID, ref.modelID))
})
-const skim = Effect.fn("SwePruner.skim")(function* (input: { question: string; output: string; abort?: AbortSignal }) {
+const skim = Effect.fn("SwePruner.skim")(function* (input: {
+ question: string
+ output: string
+ extra: number
+ abort?: AbortSignal
+}) {
const provider = yield* Provider.Service
const model = yield* resolve()
const language = yield* provider.getLanguage(model)
@@ -190,7 +215,11 @@ const skim = Effect.fn("SwePruner.skim")(function* (input: { question: string; o
if (!ranges) return undefined
const keep = kept(ranges)
if (keep / lines.length > MAX_KEEP_RATIO) return undefined
- return { output: assemble(lines, ranges, lines.length), kept: keep, total: lines.length }
+ return {
+ output: assemble(lines, ranges, lines.length, input.extra),
+ kept: keep + input.extra,
+ total: lines.length + input.extra,
+ }
})
/** Prune a tool result when a focus question was provided. Fails open to the original result. */
@@ -203,10 +232,13 @@ export const sweep = Effect.fn("SwePruner.sweep")(function* (input: {
const focus = question(input.args)
if (!focus) return input.result
if (input.result.metadata["truncated"] === true) return input.result
- const size = input.result.output.length
+ // Nearby instructions are appended to read output and must reach the main model unchanged.
+ const part = partition(input.tool, input.result)
+ if (!part) return input.result
+ const size = part.body.length
if (size < MIN_CHARS || size > MAX_CHARS) return input.result
- if (input.result.output.split("\n").length < MIN_LINES) return input.result
- const pruned = yield* skim({ question: focus, output: input.result.output, abort: input.abort }).pipe(
+ if (part.body.split("\n").length < MIN_LINES) return input.result
+ const pruned = yield* skim({ question: focus, output: part.body, extra: part.extra, abort: input.abort }).pipe(
Effect.catchCause((cause) => {
log.error("skim failed, returning full output", { tool: input.tool, cause })
return Effect.succeed(undefined)
@@ -216,7 +248,7 @@ export const sweep = Effect.fn("SwePruner.sweep")(function* (input: {
log.info("pruned", { tool: input.tool, kept: pruned.kept, total: pruned.total })
return {
...input.result,
- output: pruned.output,
+ output: pruned.output + part.tail,
metadata: {
...input.result.metadata,
swePruner: { question: focus, kept: pruned.kept, total: pruned.total },
diff --git a/packages/opencode/test/kilocode/swe-pruner.test.ts b/packages/opencode/test/kilocode/swe-pruner.test.ts
index 396f26d4310..61bf028e16f 100644
--- a/packages/opencode/test/kilocode/swe-pruner.test.ts
+++ b/packages/opencode/test/kilocode/swe-pruner.test.ts
@@ -1,5 +1,62 @@
import { describe, expect, test } from "bun:test"
+import type { LanguageModelV3, LanguageModelV3CallOptions } from "@ai-sdk/provider"
+import { Effect } from "effect"
+import { Config } from "../../src/config/config"
import { SwePruner } from "../../src/kilocode/swe-pruner"
+import { Provider } from "../../src/provider/provider"
+import { ModelID, ProviderID } from "../../src/provider/schema"
+
+const pid = ProviderID.make("test")
+const mid = ModelID.make("swe-pruner-test")
+
+function model(): Provider.Model {
+ return {
+ id: mid,
+ providerID: pid,
+ api: { id: mid, npm: "test-provider", url: "" },
+ limit: { context: 100_000, output: 4_000 },
+ capabilities: {
+ toolcall: true,
+ attachment: false,
+ reasoning: false,
+ temperature: true,
+ input: { text: true, image: false, audio: false, video: false },
+ output: { text: true, image: false, audio: false, video: false },
+ },
+ } as unknown as Provider.Model
+}
+
+function provider(seen: string[]): Provider.Interface {
+ const mdl = model()
+ const lang = {
+ specificationVersion: "v3",
+ provider: "test",
+ modelId: mid,
+ supportedUrls: {},
+ doGenerate: async (input: LanguageModelV3CallOptions) => {
+ seen.push(JSON.stringify(input))
+ return {
+ content: [{ type: "text", text: "1-10" }],
+ finishReason: { unified: "stop" },
+ usage: {
+ inputTokens: { total: 12 },
+ outputTokens: { total: 8 },
+ raw: {},
+ },
+ warnings: [],
+ providerMetadata: {},
+ request: {},
+ response: {},
+ }
+ },
+ } as unknown as LanguageModelV3
+ return {
+ defaultModel: () => Effect.succeed({ providerID: pid, modelID: mid }),
+ getSmallModel: () => Effect.succeed(mdl),
+ getModel: () => Effect.succeed(mdl),
+ getLanguage: () => Effect.succeed(lang),
+ } as unknown as Provider.Interface
+}
describe("SwePruner.question", () => {
test("extracts a non-empty focus question from raw args", () => {
@@ -137,3 +194,33 @@ describe("SwePruner.kept", () => {
).toBe(6)
})
})
+
+describe("SwePruner.sweep", () => {
+ test("preserves dynamically loaded instructions outside the pruned output", async () => {
+ const lines = Array.from({ length: 60 }, (_, index) => `${index + 1}: ${"source content ".repeat(4)}`)
+ const body = `/repo/pkg/source.ts\nfile\n\n${lines.join("\n")}\n`
+ const rules = Array.from({ length: 10 }, (_, index) => `Keep instruction ${index + 1} intact.`)
+ const tail = `\n\n\nInstructions from: /repo/pkg/AGENTS.md\n${rules.join("\r\n")}\n`
+ const seen: string[] = []
+ const result = await SwePruner.sweep({
+ tool: "read",
+ args: { context_focus_question: "Where is the relevant source content?" },
+ result: {
+ title: "source.ts",
+ output: body + tail,
+ metadata: { truncated: false, loaded: ["/repo/pkg/AGENTS.md"] },
+ },
+ }).pipe(
+ Effect.provideService(Provider.Service, provider(seen)),
+ Effect.provideService(Config.Service, { get: () => Effect.succeed({}) } as Config.Interface),
+ Effect.runPromise,
+ )
+
+ expect(seen).toHaveLength(1)
+ expect(seen[0]).toContain("source content")
+ expect(seen[0]).not.toContain(rules[0])
+ expect(result.output).toEndWith(tail)
+ expect(result.metadata["loaded"]).toEqual(["/repo/pkg/AGENTS.md"])
+ expect(result.metadata["swePruner"]).toMatchObject({ kept: 29, total: 78 })
+ })
+})