convert glob tool to Tool.defineEffect (#21897)

This commit is contained in:
Kit Langton
2026-04-10 13:56:42 -04:00
committed by GitHub
parent 59d08683ea
commit 847f1d99c9
6 changed files with 206 additions and 69 deletions

View File

@@ -1,78 +1,92 @@
import z from "zod"
import path from "path"
import { Effect, Option } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import DESCRIPTION from "./glob.txt"
import { Ripgrep } from "../file/ripgrep"
import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "../filesystem"
export const GlobTool = Tool.define("glob", {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
path: z
.string()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
})
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
await assertExternalDirectory(ctx, search, { kind: "directory" })
const limit = 100
const files = []
let truncated = false
for await (const file of Ripgrep.files({
cwd: search,
glob: [params.pattern],
signal: ctx.abort,
})) {
if (files.length >= limit) {
truncated = true
break
}
const full = path.resolve(search, file)
const stats = Filesystem.stat(full)?.mtime.getTime() ?? 0
files.push({
path: full,
mtime: stats,
})
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
)
}
}
export const GlobTool = Tool.defineEffect(
"glob",
Effect.gen(function* () {
const rg = yield* Ripgrep.Service
const fs = yield* AppFileSystem.Service
return {
title: path.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,
},
output: output.join("\n"),
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The glob pattern to match files against"),
path: z
.string()
.optional()
.describe(
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
),
}),
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
ctx.ask({
permission: "glob",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
},
}),
)
let search = params.path ?? Instance.directory
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
const limit = 100
let truncated = false
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
Stream.mapEffect((file) =>
Effect.gen(function* () {
const full = path.resolve(search, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const mtime = info?.mtime.pipe(Option.map((d) => d.getTime()), Option.getOrElse(() => 0)) ?? 0
return { path: full, mtime }
}),
),
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
if (files.length > limit) {
truncated = true
files.length = limit
}
files.sort((a, b) => b.mtime - a.mtime)
const output = []
if (files.length === 0) output.push("No files found")
if (files.length > 0) {
output.push(...files.map((f) => f.path))
if (truncated) {
output.push("")
output.push(
`(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
)
}
}
return {
title: path.relative(Instance.worktree, search),
metadata: {
count: files.length,
truncated,
},
output: output.join("\n"),
}
}).pipe(Effect.orDie, Effect.runPromise),
}
},
})
}),
)

View File

@@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
@@ -89,6 +90,7 @@ export namespace ToolRegistry {
| AppFileSystem.Service
| HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -107,6 +109,7 @@ export namespace ToolRegistry {
const websearch = yield* WebSearchTool
const bash = yield* BashTool
const codesearch = yield* CodeSearchTool
const globtool = yield* GlobTool
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -167,7 +170,7 @@ export namespace ToolRegistry {
invalid: Tool.init(InvalidTool),
bash: Tool.init(bash),
read: Tool.init(read),
glob: Tool.init(GlobTool),
glob: Tool.init(globtool),
grep: Tool.init(GrepTool),
edit: Tool.init(EditTool),
write: Tool.init(WriteTool),
@@ -320,6 +323,7 @@ export namespace ToolRegistry {
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
),
)