diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 8470134ff7..f1dafd5c55 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -31,6 +31,8 @@ export type ListInput = typeof ListInput.Type export { FindInput } +export const DEFAULT_SEARCH_LIMIT = 100 + export class GlobInput extends Schema.Class("FileSystem.GlobInput")({ pattern: Schema.String, path: RelativePath.pipe(Schema.optional), diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 6130fddf10..a2b21d32cd 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -56,7 +56,7 @@ export const ripgrepLayer = Layer.effect( .glob({ cwd, pattern: input.pattern, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) .pipe( Effect.map((result) => @@ -81,7 +81,7 @@ export const ripgrepLayer = Layer.effect( pattern: input.pattern, file: info.type === "File" ? path.basename(target) : undefined, include: input.include, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) .pipe( Effect.map((result) => @@ -150,7 +150,7 @@ export const fffLayer = Layer.effect( const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { pageIndex: 0, - pageSize: input.limit, + pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) if (!found.ok) throw found.error return found.value.items.map((item) => @@ -167,7 +167,7 @@ export const fffLayer = Layer.effect( [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] .filter((value) => value !== undefined) .join(" "), - { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, + { mode: "regex", pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, timeBudgetMs: 1_500 }, ) if (!found.ok) throw found.error return found.value.items.map((match) => { diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index f71102c362..6af5560048 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -20,7 +20,7 @@ export const Input = Schema.Struct({ description: "Relative directory to search. Defaults to the active Location.", }), limit: FileSystem.GlobInput.fields.limit.annotate({ - description: "Maximum results to return", + description: `Maximum results to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`, }), }) @@ -86,7 +86,7 @@ export const Plugin = { .glob({ cwd, pattern: input.pattern, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) .pipe( Effect.map((result) => diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 99e64af50a..6e784dc876 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -25,7 +25,7 @@ export const Input = Schema.Struct({ description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")', }), limit: FileSystem.GrepInput.fields.limit.annotate({ - description: "Maximum matches to return", + description: `Maximum matches to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`, }), }) @@ -106,7 +106,7 @@ export const Plugin = { pattern: input.pattern, file: info?.type === "File" ? path.basename(target) : undefined, include: input.include, - limit: input.limit ?? Number.MAX_SAFE_INTEGER, + limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, }) .pipe( Effect.map((result) => diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index b1bad34102..45a3e42296 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -1,8 +1,11 @@ import { describe, expect } from "bun:test" +import fs from "fs/promises" +import path from "path" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -16,7 +19,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" +import { executeTool, registerToolPlugin, settleTool, toolIdentity } from "./lib/tool" const globToolNode = makeLocationNode({ name: "test/glob-tool-plugin", @@ -66,6 +69,32 @@ const call = (name: "glob" | "grep", input: unknown) => ({ const it = testEffect(Layer.empty) describe("search tools", () => { + it.live("bounds omitted glob and grep limits", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all( + Array.from({ length: FileSystem.DEFAULT_SEARCH_LIMIT + 1 }, (_, index) => + fs.writeFile(path.join(tmp.path, `${index}.txt`), "needle\n"), + ), + ), + ) + yield* withTools(tmp.path, (registry) => + Effect.gen(function* () { + const glob = yield* settleTool(registry, call("glob", { pattern: "*" })) + const grep = yield* settleTool(registry, call("grep", { pattern: "needle" })) + + expect(glob.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + expect(grep.output?.structured).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT) + }), + ) + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + for (const name of ["glob", "grep"] as const) { it.live(`${name} reports a missing search path`, () => Effect.acquireUseRelease(