Просмотр исходного кода

convert glob tool to Tool.defineEffect (#21897)

Kit Langton 1 неделя назад
Родитель
Сommit
847f1d99c9

+ 70 - 0
packages/opencode/src/file/ripgrep.ts

@@ -3,10 +3,17 @@ import path from "path"
 import { Global } from "../global"
 import fs from "fs/promises"
 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 { lazy } from "../util/lazy"
 
 import { Filesystem } from "../util/filesystem"
+import { AppFileSystem } from "../filesystem"
 import { Process } from "../util/process"
 import { which } from "../util/which"
 import { text } from "node:stream/consumers"
@@ -274,6 +281,69 @@ export namespace Ripgrep {
     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 }) {
     log.info("tree", input)
     const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))

+ 80 - 66
packages/opencode/src/tool/glob.ts

@@ -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,
-      },
-    })
+export const GlobTool = Tool.defineEffect(
+  "glob",
+  Effect.gen(function* () {
+    const rg = yield* Ripgrep.Service
+    const fs = yield* AppFileSystem.Service
 
-    let search = params.path ?? Instance.directory
-    search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
-    await assertExternalDirectory(ctx, search, { kind: "directory" })
+    return {
+      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,
+              },
+            }),
+          )
 
-    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)
+          let search = params.path ?? Instance.directory
+          search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
+          yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
 
-    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.)`,
-        )
-      }
-    }
+          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]),
+          )
 
-    return {
-      title: path.relative(Instance.worktree, search),
-      metadata: {
-        count: files.length,
-        truncated,
-      },
-      output: output.join("\n"),
+          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),
     }
-  },
-})
+  }),
+)

+ 5 - 1
packages/opencode/src/tool/registry.ts

@@ -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),
     ),
   )
 

+ 45 - 0
packages/opencode/test/file/ripgrep.test.ts

@@ -1,4 +1,6 @@
 import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import * as Stream from "effect/Stream"
 import fs from "fs/promises"
 import path from "path"
 import { tmpdir } from "../fixture/fixture"
@@ -52,3 +54,46 @@ describe("file.ripgrep", () => {
     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")
+  })
+})

+ 2 - 0
packages/opencode/test/session/prompt-effect.test.ts

@@ -37,6 +37,7 @@ import { ToolRegistry } from "../../src/tool/registry"
 import { Truncate } from "../../src/tool/truncate"
 import { Log } from "../../src/util/log"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { Ripgrep } from "../../src/file/ripgrep"
 import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 import { reply, TestLLMServer } from "../lib/llm-server"
@@ -172,6 +173,7 @@ function makeHttp() {
     Layer.provide(Skill.defaultLayer),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(CrossSpawnSpawner.defaultLayer),
+    Layer.provide(Ripgrep.defaultLayer),
     Layer.provideMerge(todo),
     Layer.provideMerge(question),
     Layer.provideMerge(deps),

+ 2 - 0
packages/opencode/test/session/snapshot-tool-race.test.ts

@@ -53,6 +53,7 @@ import { ToolRegistry } from "../../src/tool/registry"
 import { Truncate } from "../../src/tool/truncate"
 import { AppFileSystem } from "../../src/filesystem"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { Ripgrep } from "../../src/file/ripgrep"
 
 Log.init({ print: false })
 
@@ -136,6 +137,7 @@ function makeHttp() {
     Layer.provide(Skill.defaultLayer),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(CrossSpawnSpawner.defaultLayer),
+    Layer.provide(Ripgrep.defaultLayer),
     Layer.provideMerge(todo),
     Layer.provideMerge(question),
     Layer.provideMerge(deps),