Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/protect-swe-pruner-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Exclude directory-scoped AGENTS.md instructions from SWE-Pruner context.
48 changes: 40 additions & 8 deletions packages/opencode/src/kilocode/swe-pruner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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</content>"
const FILE = "\n<type>file</type>\n<content>\n"
const REMINDER = `${CLOSE}\n\n<system-reminder>\n`

const DESCRIPTION = [
"Optional focus question used to prune this tool's output to only the relevant lines.",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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. */
Expand All @@ -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)
Expand All @@ -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 },
Expand Down
87 changes: 87 additions & 0 deletions packages/opencode/test/kilocode/swe-pruner.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 = `<path>/repo/pkg/source.ts</path>\n<type>file</type>\n<content>\n${lines.join("\n")}\n</content>`
const rules = Array.from({ length: 10 }, (_, index) => `Keep instruction ${index + 1} intact.`)
const tail = `\n\n<system-reminder>\nInstructions from: /repo/pkg/AGENTS.md\n${rules.join("\r\n")}\n</system-reminder>`
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 })
})
})
Loading