feat: add signal-to-noise controls to grep tool (#12811)

* feat: add signal-to-noise controls to grep tool

Add configurable options to reduce noise in grep results:
- context: show N lines before/after each match
- limit: bound maximum matches (default 100) with early termination
- literal: treat pattern as plain text instead of regex
- ignoreCase: case-insensitive matching

These controls help models avoid overwhelming context windows with too
many matches and provide clear guidance when results are truncated.

Implementation preserves upstream ripgrep structure with minimal Kilo
hooks for additive behavior only. Shared-file changes reduced from 183
to 60 lines compared to initial implementation.

* test(core): tolerate PTY event publication race

* fix(cli): count grep matches independently of context
This commit is contained in:
Marius
2026-08-03 10:32:40 -04:00
committed by GitHub
parent 3d4294e3bb
commit 989f7f06a0
8 changed files with 383 additions and 20 deletions
@@ -0,0 +1,192 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import path from "path"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { Agent } from "../../../src/agent/agent"
import { Git } from "../../../src/git"
import { GrepTool, Parameters } from "../../../src/tool/grep"
import { Truncate } from "../../../src/tool/truncate"
import { MessageID, SessionID } from "../../../src/session/schema"
import { TestInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const it = testEffect(
Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
FSUtil.defaultLayer,
Ripgrep.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
Git.defaultLayer,
),
)
const ctx = {
sessionID: SessionID.make("ses_grep_signal_controls"),
messageID: MessageID.make("msg_grep_signal_controls"),
callID: "",
agent: "code",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const file = (test: { readonly directory: string }, name: string) => path.join(test.directory, name)
const init = Effect.gen(function* () {
const info = yield* GrepTool
return yield* info.init()
})
describe("Kilo grep signal-to-noise controls", () => {
it.effect("validates signal controls", () =>
Effect.sync(() => {
expect(Schema.decodeUnknownSync(Parameters)({ pattern: "needle", context: 0, limit: 1 })).toMatchObject({
context: 0,
limit: 1,
})
expect(() => Schema.decodeUnknownSync(Parameters)({ pattern: "needle", context: -1 })).toThrow()
expect(() => Schema.decodeUnknownSync(Parameters)({ pattern: "needle", limit: 0 })).toThrow()
expect(() => Schema.decodeUnknownSync(Parameters)({ pattern: "needle", limit: 1.5 })).toThrow()
}),
)
it.instance("preserves the default match output", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(file(test, "default.txt"), "before\nneedle\nafter\n"))
const grep = yield* init
const result = yield* grep.execute({ pattern: "needle", path: test.directory }, ctx)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain("Line 2: needle")
expect(result.output).not.toContain("[match]")
}),
)
it.instance("stops after the custom match limit", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Bun.write(file(test, "many.txt"), `${Array.from({ length: 20 }, () => "needle").join("\n")}\n`),
)
const grep = yield* init
const result = yield* grep.execute({ pattern: "needle", path: test.directory, limit: 2 }, ctx)
expect(result.metadata.matches).toBe(2)
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("2 matches limit reached. Use limit=4 for more, or refine pattern.")
expect(result.output).not.toContain("(Results truncated")
expect(result.output).not.toContain("Line 3: needle")
}),
)
it.instance("supports literal and case-insensitive matching", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Promise.all([
Bun.write(file(test, "literal.txt"), "a.*b\n"),
Bun.write(file(test, "regex.txt"), "azb\n"),
Bun.write(file(test, "case.txt"), "Needle\n"),
]),
)
const grep = yield* init
const literal = yield* grep.execute({ pattern: "a.*b", path: test.directory, literal: true }, ctx)
const insensitive = yield* grep.execute({ pattern: "needle", path: test.directory, ignoreCase: true }, ctx)
expect(literal.metadata.matches).toBe(1)
expect(literal.output).toContain("literal.txt")
expect(literal.output).not.toContain("regex.txt")
expect(insensitive.metadata.matches).toBe(1)
expect(insensitive.output).toContain("case.txt")
}),
)
it.instance("formats only bounded context around returned matches", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Bun.write(
file(test, "context.txt"),
["before", "needle", "after", "far", "later needle", "later after"].join("\n") + "\n",
),
)
const grep = yield* init
const result = yield* grep.execute({ pattern: "needle", path: test.directory, context: 1, limit: 1 }, ctx)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain("[context] Line 1: before")
expect(result.output).toContain("[match] Line 2: needle")
expect(result.output).toContain("[context] Line 3: after")
expect(result.output).not.toContain("Line 4: far")
expect(result.output).not.toContain("later needle")
}),
)
it.instance("does not count context lines toward the match limit", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const content = Array.from(
{ length: 35 },
(_, index) => `before-${index}\nneedle-${index}\nafter-${index}\ngap-${index}`,
).join("\n")
yield* Effect.promise(() => Bun.write(file(test, "context-limit.txt"), `${content}\n`))
const grep = yield* init
const result = yield* grep.execute({ pattern: "needle", path: test.directory, context: 1, limit: 100 }, ctx)
expect(result.metadata.matches).toBe(35)
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("needle-34")
expect(result.output).not.toContain("matches limit reached")
}),
)
it.instance("guides the model to read truncated lines", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(file(test, "long.txt"), `${"x".repeat(2_100)}needle\n`))
const grep = yield* init
const result = yield* grep.execute({ pattern: "needle", path: test.directory }, ctx)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain("Some matching or context lines were truncated. Use read for full lines.")
}),
)
it.instance("retains include filtering", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Promise.all([
Bun.write(file(test, "included.ts"), "needle\n"),
Bun.write(file(test, "excluded.txt"), "needle\n"),
]),
)
const grep = yield* init
const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.ts" }, ctx)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain("included.ts")
expect(result.output).not.toContain("excluded.txt")
}),
)
it.instance("honors cancellation", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(file(test, "cancel.txt"), "needle\n".repeat(10_000)))
const grep = yield* init
const controller = new AbortController()
controller.abort()
const exit = yield* grep
.execute({ pattern: "needle", path: test.directory }, { ...ctx, abort: controller.signal })
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
@@ -114,16 +114,37 @@ exports[`tool parameters JSON Schema (wire shape) grep 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"context": {
"description": "Number of context lines to show before and after each match (default 0)",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer",
},
"ignoreCase": {
"description": "Match without regard to letter case (default false)",
"type": "boolean",
},
"include": {
"description": "File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")",
"type": "string",
},
"limit": {
"description": "Maximum matching lines to return (default 100)",
"exclusiveMinimum": 0,
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer",
},
"literal": {
"description": "Treat pattern as plain text instead of a regex (default false)",
"type": "boolean",
},
"path": {
"description": "The directory to search in. Defaults to the current working directory.",
"type": "string",
},
"pattern": {
"description": "The regex pattern to search for in file contents",
"description": "Pattern to search for in file contents (regex by default)",
"type": "string",
},
},
+1 -1
View File
@@ -149,7 +149,7 @@ describe("tool.grep", () => {
const grep = yield* info.init()
const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx)
expect(result.output).toContain("(Results truncated. Consider using a more specific path or pattern.)")
expect(result.output).toContain("100 matches limit reached. Use limit=200 for more, or refine pattern.") // kilocode_change
expect(result.output).not.toMatch(/showing \d+ of \d+ matches/)
}),
)