convert glob tool to Tool.defineEffect (#21897)
This commit is contained in:
@@ -3,10 +3,17 @@ import path from "path"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import * as Stream from "effect/Stream"
|
||||||
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { lazy } from "../util/lazy"
|
import { lazy } from "../util/lazy"
|
||||||
|
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { AppFileSystem } from "../filesystem"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import { which } from "../util/which"
|
import { which } from "../util/which"
|
||||||
import { text } from "node:stream/consumers"
|
import { text } from "node:stream/consumers"
|
||||||
@@ -274,6 +281,69 @@ export namespace Ripgrep {
|
|||||||
input.signal?.throwIfAborted()
|
input.signal?.throwIfAborted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly files: (input: {
|
||||||
|
cwd: string
|
||||||
|
glob?: string[]
|
||||||
|
hidden?: boolean
|
||||||
|
follow?: boolean
|
||||||
|
maxDepth?: number
|
||||||
|
}) => Stream.Stream<string, PlatformError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const spawner = yield* ChildProcessSpawner
|
||||||
|
const afs = yield* AppFileSystem.Service
|
||||||
|
|
||||||
|
const files = Effect.fn("Ripgrep.files")(function* (input: {
|
||||||
|
cwd: string
|
||||||
|
glob?: string[]
|
||||||
|
hidden?: boolean
|
||||||
|
follow?: boolean
|
||||||
|
maxDepth?: number
|
||||||
|
}) {
|
||||||
|
const rgPath = yield* Effect.promise(() => filepath())
|
||||||
|
const isDir = yield* afs.isDir(input.cwd)
|
||||||
|
if (!isDir) {
|
||||||
|
return yield* Effect.die(
|
||||||
|
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
||||||
|
code: "ENOENT" as const,
|
||||||
|
errno: -2,
|
||||||
|
path: input.cwd,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [rgPath, "--files", "--glob=!.git/*"]
|
||||||
|
if (input.follow) args.push("--follow")
|
||||||
|
if (input.hidden !== false) args.push("--hidden")
|
||||||
|
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
||||||
|
if (input.glob) {
|
||||||
|
for (const g of input.glob) {
|
||||||
|
args.push(`--glob=${g}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spawner.streamLines(
|
||||||
|
ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }),
|
||||||
|
).pipe(Stream.filter((line: string) => line.length > 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
files: (input) => Stream.unwrap(files(input)),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
|
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
|
||||||
log.info("tree", input)
|
log.info("tree", input)
|
||||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
|
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
|
||||||
|
|||||||
@@ -1,78 +1,92 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Effect, Option } from "effect"
|
||||||
|
import * as Stream from "effect/Stream"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import DESCRIPTION from "./glob.txt"
|
import DESCRIPTION from "./glob.txt"
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Instance } from "../project/instance"
|
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", {
|
export const GlobTool = Tool.defineEffect(
|
||||||
description: DESCRIPTION,
|
"glob",
|
||||||
parameters: z.object({
|
Effect.gen(function* () {
|
||||||
pattern: z.string().describe("The glob pattern to match files against"),
|
const rg = yield* Ripgrep.Service
|
||||||
path: z
|
const fs = yield* AppFileSystem.Service
|
||||||
.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.)`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: path.relative(Instance.worktree, search),
|
description: DESCRIPTION,
|
||||||
metadata: {
|
parameters: z.object({
|
||||||
count: files.length,
|
pattern: z.string().describe("The glob pattern to match files against"),
|
||||||
truncated,
|
path: z
|
||||||
},
|
.string()
|
||||||
output: output.join("\n"),
|
.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),
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect"
|
|||||||
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
||||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
@@ -89,6 +90,7 @@ export namespace ToolRegistry {
|
|||||||
| AppFileSystem.Service
|
| AppFileSystem.Service
|
||||||
| HttpClient.HttpClient
|
| HttpClient.HttpClient
|
||||||
| ChildProcessSpawner
|
| ChildProcessSpawner
|
||||||
|
| Ripgrep.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -107,6 +109,7 @@ export namespace ToolRegistry {
|
|||||||
const websearch = yield* WebSearchTool
|
const websearch = yield* WebSearchTool
|
||||||
const bash = yield* BashTool
|
const bash = yield* BashTool
|
||||||
const codesearch = yield* CodeSearchTool
|
const codesearch = yield* CodeSearchTool
|
||||||
|
const globtool = yield* GlobTool
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||||
@@ -167,7 +170,7 @@ export namespace ToolRegistry {
|
|||||||
invalid: Tool.init(InvalidTool),
|
invalid: Tool.init(InvalidTool),
|
||||||
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(EditTool),
|
edit: Tool.init(EditTool),
|
||||||
write: Tool.init(WriteTool),
|
write: Tool.init(WriteTool),
|
||||||
@@ -320,6 +323,7 @@ export namespace ToolRegistry {
|
|||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import * as Stream from "effect/Stream"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
@@ -52,3 +54,46 @@ describe("file.ripgrep", () => {
|
|||||||
expect(hits).toEqual([])
|
expect(hits).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Ripgrep.Service", () => {
|
||||||
|
test("files returns stream of filenames", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "a.txt"), "hello")
|
||||||
|
await Bun.write(path.join(dir, "b.txt"), "world")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = await Effect.gen(function* () {
|
||||||
|
const rg = yield* Ripgrep.Service
|
||||||
|
return yield* rg.files({ cwd: tmp.path }).pipe(Stream.runCollect, Effect.map((chunk) => [...chunk].sort()))
|
||||||
|
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(files).toEqual(["a.txt", "b.txt"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("files respects glob filter", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(path.join(dir, "keep.ts"), "yes")
|
||||||
|
await Bun.write(path.join(dir, "skip.txt"), "no")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = await Effect.gen(function* () {
|
||||||
|
const rg = yield* Ripgrep.Service
|
||||||
|
return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(Stream.runCollect, Effect.map((chunk) => [...chunk]))
|
||||||
|
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
|
||||||
|
|
||||||
|
expect(files).toEqual(["keep.ts"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("files dies on nonexistent directory", async () => {
|
||||||
|
const exit = await Effect.gen(function* () {
|
||||||
|
const rg = yield* Ripgrep.Service
|
||||||
|
return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect)
|
||||||
|
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
|
||||||
|
|
||||||
|
expect(exit._tag).toBe("Failure")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { ToolRegistry } from "../../src/tool/registry"
|
|||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||||
@@ -172,6 +173,7 @@ function makeHttp() {
|
|||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
Layer.provideMerge(question),
|
Layer.provideMerge(question),
|
||||||
Layer.provideMerge(deps),
|
Layer.provideMerge(deps),
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry"
|
|||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { AppFileSystem } from "../../src/filesystem"
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
|
|
||||||
Log.init({ print: false })
|
Log.init({ print: false })
|
||||||
|
|
||||||
@@ -136,6 +137,7 @@ function makeHttp() {
|
|||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||||
|
Layer.provide(Ripgrep.defaultLayer),
|
||||||
Layer.provideMerge(todo),
|
Layer.provideMerge(todo),
|
||||||
Layer.provideMerge(question),
|
Layer.provideMerge(question),
|
||||||
Layer.provideMerge(deps),
|
Layer.provideMerge(deps),
|
||||||
|
|||||||
Reference in New Issue
Block a user