refactor(tool): convert grep tool to Tool.defineEffect (#21937)

This commit is contained in:
Kit Langton 2026-04-10 19:20:00 -04:00 committed by GitHub
commit d72ddd71fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 176 additions and 146 deletions

View file

@ -1,30 +1,39 @@
import z from "zod" import z from "zod"
import { text } from "node:stream/consumers" import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { Process } from "../util/process" import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import DESCRIPTION from "./grep.txt" import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import path from "path" import path from "path"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_LINE_LENGTH = 2000 const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define("grep", { export const GrepTool = Tool.defineEffect(
"grep",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
return {
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
pattern: z.string().describe("The regex pattern to search for in file contents"), pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."), path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'), include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}), }),
async execute(params, ctx) { execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.pattern) { if (!params.pattern) {
throw new Error("pattern is required") throw new Error("pattern is required")
} }
await ctx.ask({ yield* Effect.promise(() =>
ctx.ask({
permission: "grep", permission: "grep",
patterns: [params.pattern], patterns: [params.pattern],
always: ["*"], always: ["*"],
@ -33,32 +42,40 @@ export const GrepTool = Tool.define("grep", {
path: params.path, path: params.path,
include: params.include, include: params.include,
}, },
}) }),
)
let searchPath = params.path ?? Instance.directory let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath() const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern] const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) { if (params.include) {
args.push("--glob", params.include) args.push("--glob", params.include)
} }
args.push(searchPath) args.push(searchPath)
const proc = Process.spawn([rgPath, ...args], { const result = yield* Effect.scoped(
stdout: "pipe", Effect.gen(function* () {
stderr: "pipe", const handle = yield* spawner.spawn(
abort: ctx.abort, ChildProcess.make(rgPath, args, {
}) stdin: "ignore",
}),
)
if (!proc.stdout || !proc.stderr) { const [output, errorOutput] = yield* Effect.all(
throw new Error("Process output not available") [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
} { concurrency: 2 },
)
const output = await text(proc.stdout) const exitCode = yield* handle.exitCode
const errorOutput = await text(proc.stderr)
const exitCode = await proc.exited return { output, errorOutput, exitCode }
}),
)
const { output, errorOutput, exitCode } = result
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches) // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc. // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
@ -128,7 +145,9 @@ export const GrepTool = Tool.define("grep", {
outputLines.push(`${match.path}:`) outputLines.push(`${match.path}:`)
} }
const truncatedLineText = const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText match.lineText.length > MAX_LINE_LENGTH
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
: match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`) outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
} }
@ -152,5 +171,7 @@ export const GrepTool = Tool.define("grep", {
}, },
output: outputLines.join("\n"), output: outputLines.join("\n"),
} }
}, }).pipe(Effect.orDie, Effect.runPromise),
}) }
}),
)

View file

@ -112,6 +112,7 @@ export namespace ToolRegistry {
const globtool = yield* GlobTool const globtool = yield* GlobTool
const writetool = yield* WriteTool const writetool = yield* WriteTool
const edit = yield* EditTool const edit = yield* EditTool
const greptool = yield* GrepTool
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@ -173,7 +174,7 @@ export namespace ToolRegistry {
bash: Tool.init(bash), bash: Tool.init(bash),
read: Tool.init(read), read: Tool.init(read),
glob: Tool.init(globtool), glob: Tool.init(globtool),
grep: Tool.init(GrepTool), grep: Tool.init(greptool),
edit: Tool.init(edit), edit: Tool.init(edit),
write: Tool.init(writetool), write: Tool.init(writetool),
task: Tool.init(task), task: Tool.init(task),

View file

@ -1,9 +1,17 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect"
import { GrepTool } from "../../src/tool/grep" import { GrepTool } from "../../src/tool/grep"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
}
const ctx = { const ctx = {
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
@ -23,7 +31,7 @@ describe("tool.grep", () => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const grep = await GrepTool.init() const grep = await initGrep()
const result = await grep.execute( const result = await grep.execute(
{ {
pattern: "export", pattern: "export",
@ -47,7 +55,7 @@ describe("tool.grep", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const grep = await GrepTool.init() const grep = await initGrep()
const result = await grep.execute( const result = await grep.execute(
{ {
pattern: "xyznonexistentpatternxyz123", pattern: "xyznonexistentpatternxyz123",
@ -72,7 +80,7 @@ describe("tool.grep", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const grep = await GrepTool.init() const grep = await initGrep()
const result = await grep.execute( const result = await grep.execute(
{ {
pattern: "line", pattern: "line",