refactor(agent-core): use ripgrep for Glob tool (#1068)

* refactor(agent-core): use ripgrep for Glob tool

Glob now shares Grep's ripgrep subprocess plumbing: it respects .gitignore by default, supports brace patterns natively, adds an include_ignored option, and returns only files.

* fix(glob): address review findings on ripgrep migration

- Run rg with cwd pinned to the search root so glob patterns containing
  a slash (e.g. src/**/*.ts) match under an absolute search root.
- Keep include_dirs as a deprecated, ignored parameter so older calls
  are not rejected by parameter validation.
- Surface stdout truncation and drop half-written trailing paths when
  the rg output buffer is capped.
- Document that a bare pattern (e.g. *.ts) matches recursively, and sync
  user docs, the explore profile prompt, and the TUI summary to the new
  files-only / gitignore behavior.
- Add real-ripgrep integration tests covering sort order, recursion,
  brace patterns, and the absolute-search-root case.

* fix(glob): keep partial results on traversal errors

---------

Co-authored-by: hynor <hynor@users.noreply.github.com>
Co-authored-by: Kai <me@kaiyi.cool>
This commit is contained in:
7Sageer 2026-06-29 17:40:58 +08:00 committed by GitHub
parent 10ffb7d9f9
commit c82dcf9cd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 960 additions and 959 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---
Glob now uses ripgrep, so it respects .gitignore by default, supports brace patterns, returns only files, and keeps partial results with a warning when some directories are unreadable.

View file

@ -414,7 +414,7 @@ function extractKeyArgument(
};
// Glob: concatenate multiple args into a single summary so the header
// shows pattern, optional explicit path, and include_dirs override.
// shows pattern, optional explicit path, and ignored-file inclusion.
if (toolName === 'Glob') {
const pattern = args['pattern'];
if (typeof pattern !== 'string' || pattern.length === 0) return null;
@ -423,8 +423,8 @@ function extractKeyArgument(
if (typeof path === 'string' && path.length > 0) {
summary += ` · ${makeWorkspaceRelativePath(path, workspaceDir)}`;
}
if (args['include_dirs'] === false) {
summary += ' · no dirs';
if (args['include_ignored'] === true) {
summary += ' · include ignored';
}
return truncateArgValue('pattern', summary);
}

View file

@ -25,7 +25,7 @@ File tools handle reading, writing, and searching the local filesystem — the f
**`Grep`** invokes ripgrep to search file contents, supporting regular expressions (`pattern`), a search path (`path`), file type filtering (`type`, e.g., `ts`, `py`), glob filtering (`glob`), and output mode (`output_mode`: `files_with_matches` / `content` / `count_matches`; defaults to `files_with_matches`). `content` mode supports context lines (`-A`, `-B`, `-C`), case-insensitive matching (`-i`), line numbers (`-n`, default true), and multiline matching (`multiline`). All modes support `offset` + `head_limit` pagination; `head_limit` defaults to 250 and `0` means unlimited. Sensitive files such as `.env` files and private keys are automatically filtered out; set `include_ignored=true` to search files ignored by `.gitignore`, though sensitive files remain filtered.
**`Glob`** matches files in a specified directory (`path`; defaults to the working directory) by glob pattern (`pattern`). Results are sorted by modification time in descending order, with a maximum of 1000 entries. Pure wildcard patterns (e.g., `**`) and patterns containing brace expansion (`{a,b,c}`) are rejected.
**`Glob`** matches files in a specified directory (`path`; defaults to the working directory) by glob pattern (`pattern`). Results are sorted by modification time in descending order, with a maximum of 100 entries. It respects `.gitignore`, `.ignore`, and `.rgignore` by default; set `include_ignored=true` to include ignored files such as build outputs, while sensitive files remain filtered. Brace patterns such as `*.{ts,tsx}` are supported, and broad wildcard patterns are allowed but usually truncate at the match cap.
**`ReadMediaFile`** sends an image or video to the model as multimodal content. Accepts only `path`; the file size limit is 100 MB. Availability depends on the current model's vision capabilities (`image_in` / `video_in`).

View file

@ -25,7 +25,7 @@
**`Grep`** 调用 ripgrep 搜索文件内容,支持正则表达式(`pattern`)、搜索路径(`path`)、文件类型过滤(`type`,如 `ts``py`、glob 过滤(`glob`)和输出模式(`output_mode``files_with_matches` / `content` / `count_matches`,默认 `files_with_matches`)。`content` 模式支持上下文行(`-A``-B``-C`)、忽略大小写(`-i`)、行号(`-n`,默认 true、跨行匹配`multiline`)。所有模式支持 `offset` + `head_limit` 分页,`head_limit` 默认 250、传 0 表示不限。`.env`、私钥等敏感文件会被自动过滤;`include_ignored=true` 可搜索被 `.gitignore` 忽略的文件,但敏感文件仍保持过滤。
**`Glob`** 按 glob 模式(`pattern`)在指定目录(`path`,默认工作目录)中匹配文件,结果按修改时间倒序排列,最多返回 1000 条。纯通配符模式(如 `**`)和含花括号扩展(`{a,b,c}`)的模式会被拒绝
**`Glob`** 按 glob 模式(`pattern`)在指定目录(`path`,默认工作目录)中匹配文件,结果按修改时间倒序排列,最多返回 100 条。默认尊重 `.gitignore``.ignore``.rgignore`;设置 `include_ignored=true` 可包含构建产物等被忽略的文件,但敏感文件仍会被过滤。支持 `*.{ts,tsx}` 这类花括号模式,也允许宽泛通配符模式,但通常会在匹配上限处截断
**`ReadMediaFile`** 将图片或视频以多模态内容发送给模型,仅接受 `path`,文件大小上限 100 MB。是否可用取决于当前模型的视觉能力`image_in` / `video_in`)。

View file

@ -13,7 +13,7 @@ promptVars:
- Running read-only shell commands (git log, git diff, ls, find, etc.)
Guidelines:
- Use Glob for broad file pattern matching. Prefer a pattern with a literal anchor (extension or subdirectory): results are capped at a fixed number of matches, so a broad pattern like `**/*` can truncate before reaching what you need.
- Use Glob for broad file pattern matching. Prefer patterns with a literal anchor (extension or subdirectory); pure wildcards like `*` or `**/*` are allowed but usually truncate at the match cap.
- Use Grep for searching file contents with regex
- Use Read when you know the specific file path
- Use Bash ONLY for read-only operations (ls, git status, git log, git diff, find)

View file

@ -1,15 +1,16 @@
Find files (and optionally directories) by glob pattern, sorted by modification time (most recent first).
Find files by glob pattern, sorted by modification time (most recent first).
Powered by ripgrep. Respects `.gitignore`, `.ignore`, and `.rgignore` by default — set `include_ignored` to also match ignored files (e.g. build outputs, `node_modules`). Sensitive files (such as `.env`) are always filtered out.
Good patterns:
- `*.ts` — files in the current directory matching an extension
- `*.ts` — all files matching an extension, at any depth below the search root (a bare pattern without `/` matches recursively)
- `src/*.ts` — files directly inside `src/` (one level, not recursive)
- `src/**/*.ts` — recursive walk with a subdirectory anchor and extension
- `**/*.py` — recursive walk from the search root for an extension
- `*.{ts,tsx}` — brace expansion is supported; expanded into `*.ts` and `*.tsx` before walking
- `*.{ts,tsx}` — brace expansion is supported
- `{src,test}/**/*.ts` — cartesian brace expansion is supported too
Prefer a pattern with a literal anchor (a file extension or subdirectory) up front; a bare `**/*` walks until it truncates at the match cap. Results are capped at the first 100 matching paths (walk order, not global modification-time order). If a search would return more, a truncation marker is appended with the count of matches seen so far. Refine the pattern (extension, subdirectory) when 100 is not enough, or call again with a narrower anchor.
Results are capped at the first 100 matching paths. If a search would return more, a truncation marker is appended. Refine the pattern (extension, subdirectory) when 100 is not enough, or call again with a narrower anchor.
Large-directory caveat — avoid recursing into dependency / build output even with an anchor:
- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` all match technically but
typically produce thousands of results that truncate at the match cap and waste the caller context.
Prefer specific subpaths like `node_modules/react/src/**/*.js`.
Large-directory caveat — avoid recursing into dependency / build output even with an anchor, especially when `include_ignored` is set:
- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` can produce thousands of results that truncate at the match cap and waste context. Prefer specific subpaths like `node_modules/react/src/**/*.js`.

View file

@ -1,83 +1,80 @@
/**
* GlobTool file pattern matching.
* GlobTool file pattern matching via ripgrep.
*
* Finds files matching a glob pattern, returned sorted by modification
* time (most recent first). Uses `kaos.glob`.
* time (most recent first). Implemented by shelling out to `rg --files`
* through Kaos sharing the ripgrep binary, subprocess plumbing, and
* gitignore / sensitive-file handling with GrepTool.
*
* Output convention: `content` shown to the LLM is relativized to the
* search base only when the base is inside the primary workspace. External
* roots stay absolute so downstream Read/Edit target the same file.
*
* Behaviour:
* - Brace expansion (`*.{ts,tsx}`, `{src,test}/**`) is expanded at
* this layer into a list of sub-patterns before handing each to
* `kaos.glob`. The kaos walker treats `{` / `}` as literals, so the
* fan-out has to happen here for any results to come back. Cartesian
* and one level of nesting are supported; unbalanced or comma-less
* braces fall through as literals.
* - `.gitignore` / `.ignore` / `.rgignore` are respected by default
* (ripgrep native). Pass `include_ignored` to also surface ignored
* files (e.g. build outputs, `node_modules`). Sensitive files such
* as `.env` are always filtered out.
* - Brace expansion (`*.{ts,tsx}`, `{src,test}/**`) is handled by
* ripgrep's glob engine.
* - `path` is validated by `resolvePathAccess` in `absolute-outside-allowed`
* mode. Explicit absolute paths outside the workspace are allowed; relative
* paths that escape the workspace stay rejected.
* - Match count is capped at `MAX_MATCHES` (unique paths). A separate
* `YIELD_SAFETY_CAP` on the raw yield stream is a secondary belt that
* still terminates the stream if the kaos layer's own symlink-cycle
* detection were ever absent or bypassed. Primary cycle defense lives
* in `packages/kaos/src/local.ts:_globWalk` via a path-local visited
* inode set. With brace expansion the legitimate yield volume scales
* with the number of sub-patterns, so the safety cap scales too.
* - Pre-rejection of pure-wildcard / `**`-leading patterns has been
* removed; the 100-match cap is the only safety against runaway
* enumeration. Callers are expected to add an anchor (extension,
* subdirectory) when 100 results would not be enough.
* - Match count is capped at `MAX_MATCHES`. Callers are expected to add an
* anchor (extension, subdirectory) when that would not be enough.
*/
import type { Kaos } from '@moonshot-ai/kaos';
import { normalize } from 'pathe';
import { normalize, resolve } from 'pathe';
import { z } from 'zod';
import type { BuiltinTool } from '../../../agent/tool';
import { isAbortError } from '../../../loop/errors';
import { ToolAccesses } from '../../../loop/tool-access';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { isWithinDirectory, resolvePathAccessPath } from '../../policies/path-access';
import type { PathClass } from '../../policies/path-access';
import { isSensitiveFile } from '../../policies/sensitive';
import { toInputJsonSchema } from '../../support/input-schema';
import { ensureRgPath, rgUnavailableMessage } from '../../support/rg-locator';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import {
DEFAULT_TIMEOUT_MS,
MAX_OUTPUT_BYTES,
SENSITIVE_GLOBS_TO_EXCLUDE,
VCS_DIRECTORIES_TO_EXCLUDE,
runRipgrepOnce,
shouldRetryRipgrepEagain,
} from '../../support/run-rg';
import type { WorkspaceConfig } from '../../support/workspace';
import GLOB_DESCRIPTION from './glob.md?raw';
export const GlobInputSchema = z.object({
pattern: z.string().describe('Glob pattern to match files/directories.'),
pattern: z.string().describe('Glob pattern to match files.'),
path: z
.string()
.optional()
.describe(
'Absolute path to the directory to search in. Defaults to the current working directory.',
),
include_dirs: z
include_ignored: z
.boolean()
.default(true)
.optional()
.describe(
'Whether to include directories in results. Defaults to true. Set false to return only files.',
'Also match files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. Defaults to false.',
),
include_dirs: z
.boolean()
.optional()
.describe(
'Deprecated and ignored. Results are always files-only — directories are never listed. Accepted only so older calls that still pass this flag are not rejected by parameter validation.',
),
});
export type GlobInput = z.Infer<typeof GlobInputSchema>;
export type GlobInput = z.infer<typeof GlobInputSchema>;
export const MAX_MATCHES = 100;
/**
* Hard upper bound on the number of sub-patterns a single brace expansion
* is allowed to produce. Generous enough for the common LLM patterns
* (`*.{ts,tsx,js,jsx,mjs,cjs}` etc.) while still keeping pathological
* cartesian inputs like `{a,b}{c,d}{e,f}{g,h}{i,j}{k,l}` (= 64) from
* fanning out unboundedly. Beyond this we fall through with the original
* pattern unexpanded kaos would then treat the braces as literals and
* match zero, which is the right "obvious failure" signal for a pattern
* the model probably did not mean.
*/
const MAX_BRACE_EXPANSIONS = 64;
/**
* Path-shape hint appended to the tool description only on a Windows
* (`win32` path class) backend. The `path` argument accepts both native
@ -92,7 +89,7 @@ export const WINDOWS_PATH_HINT =
'returned in Windows backslash form; convert them to forward slashes before ' +
'using them in a Bash command.';
// POSIX mode bits — same constants used by KaosPath.isDir (packages/kaos/src/path.ts:199).
// POSIX mode bits for the search-root directory check.
const S_IFMT = 0o170000;
const S_IFDIR = 0o040000;
@ -129,13 +126,12 @@ export class GlobTool implements BuiltinTool<GlobInput> {
}
const searchRoots = [path ?? this.workspace.workspaceDir];
const detailParts: string[] = [];
detailParts.push(`pattern: ${args.pattern}`);
const detailParts: string[] = [`pattern: ${args.pattern}`];
if (args.path !== undefined) {
detailParts.push(`path: ${args.path}`);
}
if (args.include_dirs === false) {
detailParts.push('include_dirs: false');
if (args.include_ignored === true) {
detailParts.push('include_ignored: true');
}
return {
@ -149,149 +145,218 @@ export class GlobTool implements BuiltinTool<GlobInput> {
},
approvalRule: literalRulePattern(this.name, args.pattern),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern),
execute: () => this.execution(args, searchRoots),
execute: ({ signal }) => this.execution(args, signal, searchRoots),
};
}
private async execution(args: GlobInput, searchRoots: string[]): Promise<ExecutableToolResult> {
const subPatterns = expandBraces(args.pattern).map((p) =>
hasGlobEscape(p) ? p : normalize(p),
);
// Default true. When false, directories yielded by kaos are
// filtered out using the same stat that fuels the mtime sort
// (no second stat per path).
const includeDirs = args.include_dirs ?? true;
// kaos.glob silently returns empty for missing or non-directory roots
// (its _globWalk catches the readdir failure and exits without yielding).
// Without this pre-check, a Glob against a missing path would report
// "No matches found" instead of "does not exist", and the model would
// not realize the search root itself was wrong. iterdir is the right
// signal: pulling one entry triggers the same readdir that kaos.glob
// would do, so ENOENT/ENOTDIR surface here for the realistic backends
// before the walker is invoked. Any other failure (e.g. an unmocked
// test backend that throws "not implemented") falls through silently
// so the existing kaos.glob path still runs.
for (const root of searchRoots) {
try {
const iter = this.kaos.iterdir(root);
await iter.next();
if (typeof iter.return === 'function') {
await iter.return(undefined);
}
} catch (error) {
if (error !== null && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: string }).code;
if (code === 'ENOENT') {
return { isError: true, output: `${root} does not exist` };
}
if (code === 'ENOTDIR') {
return { isError: true, output: `${root} is not a directory` };
}
}
// Unknown failure (including unmocked test backends): fall
// through and let kaos.glob run; it will either yield results
// or its own catch path will surface the error.
}
}
private async execution(
args: GlobInput,
signal: AbortSignal,
searchRoots: string[],
): Promise<ExecutableToolResult> {
const searchRoot = searchRoots[0] ?? this.workspace.workspaceDir;
// Validate the search root is a directory. `rg --files <file>` exits 0
// and lists the file itself, so without this check a file root would be
// returned as its own match instead of rejected. A missing root surfaces
// here as "does not exist".
try {
// Two counters, two jobs:
// - `entries.length` caps the *unique* paths we return, so a
// truncation warning only fires after MAX_MATCHES real hits.
// - `yielded` counts every path the kaos stream emits, including
// duplicates. Secondary safety belt: the kaos `_globWalk`
// itself detects symlink cycles, so a well-formed kaos layer
// never re-yields the same real
// file. `yielded` still terminates the stream if that primary
// defense were ever absent or bypassed (e.g. a future kaos
// backend without inode tracking), so the tool layer doesn't
// depend on the kaos implementation for cycle safety. With
// brace expansion the legitimate yield volume scales with the
// number of sub-patterns (each is its own walk), so the cap
// scales too.
const seen = new Set<string>();
const entries: Array<{ path: string; mtime: number }> = [];
const YIELD_SAFETY_CAP = MAX_MATCHES * 2 * subPatterns.length;
let yielded = 0;
let truncated = false;
outer: for (const root of searchRoots) {
for (const subPattern of subPatterns) {
for await (const filePath of this.kaos.glob(root, subPattern)) {
yielded++;
if (yielded >= YIELD_SAFETY_CAP) {
truncated = true;
break outer;
}
if (seen.has(filePath)) continue;
if (entries.length >= MAX_MATCHES) {
truncated = true;
break outer;
}
seen.add(filePath);
let mtime = 0;
let isDir = false;
try {
const st = await this.kaos.stat(filePath);
mtime = st.stMtime ?? 0;
isDir = (st.stMode & S_IFMT) === S_IFDIR;
} catch {
// stat failure — use 0 mtime / assume file so it still surfaces
}
// Apply include_dirs *after* marking seen so a filtered dir
// doesn't re-enter via a later duplicate yield, and *before*
// pushing to entries so MAX_MATCHES continues to cap output
// (not pre-filter) size.
if (!includeDirs && isDir) continue;
entries.push({ path: filePath, mtime });
}
}
const st = await this.kaos.stat(searchRoot);
if ((st.stMode & S_IFMT) !== S_IFDIR) {
return { isError: true, output: `${searchRoot} is not a directory` };
}
entries.sort((a, b) => b.mtime - a.mtime);
const paths = entries.map((e) => e.path);
// Content shown to the LLM uses paths relative to the search base
// to save tokens, but only for the primary workspace. Relative paths
// are later resolved against workspaceDir, so additionalDir matches
// must stay absolute to keep follow-up Read/Edit calls on the same file.
const pathClass = this.kaos.pathClass();
const relBase = searchRoots[0] ?? this.workspace.workspaceDir;
const shouldRelativize = isWithinDirectory(relBase, this.workspace.workspaceDir, pathClass);
const displayLines = paths.map((p) =>
shouldRelativize ? relativizeIfUnder(p, relBase, pathClass) : p,
);
if (entries.length === 0 && !truncated) {
return { output: 'No matches found' };
}
const lines: string[] = [];
if (truncated) {
lines.push(`[Truncated at ${String(MAX_MATCHES)} matches — ${String(seen.size)} matched so far, use a more specific pattern]`);
lines.push(`Only the first ${String(MAX_MATCHES)} matches are returned.`);
}
lines.push(...displayLines);
if (!truncated && entries.length === MAX_MATCHES) {
lines.push(`Found ${String(entries.length)} matches`);
}
return { output: lines.join('\n') };
} catch (error) {
if (error !== null && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: string }).code;
const path = searchRoots[0] ?? this.workspace.workspaceDir;
if (code === 'ENOENT') {
return { isError: true, output: `${path} does not exist` };
}
if (code === 'ENOTDIR') {
return { isError: true, output: `${path} is not a directory` };
}
if (errorCode(error) === 'ENOENT') {
return { isError: true, output: `${searchRoot} does not exist` };
}
return { isError: true, output: error instanceof Error ? error.message : String(error) };
}
}
let rgPath: string;
try {
const resolution = await ensureRgPath({ signal });
rgPath = resolution.path;
} catch (error) {
if (isAbortError(error)) {
return { isError: true, output: 'Glob aborted' };
}
return { isError: true, output: rgUnavailableMessage(error) };
}
// Run rg with its cwd pinned to the search root and `.` as the search
// path. ripgrep matches `--glob` patterns against the path *as passed to
// rg*, so with an absolute search path a pattern containing a `/` (e.g.
// `src/**/*.ts`) is matched against the absolute path and never matches.
// Running from the search root makes glob matching relative to it.
const execKaos = this.kaos.withCwd(searchRoot);
let runResult = await runRipgrepOnce(
execKaos,
buildRgArgs(rgPath, args),
signal,
{ abortedMessage: 'Glob aborted' },
);
if (runResult.kind === 'tool-error') return runResult.result;
if (shouldRetryRipgrepEagain(runResult)) {
runResult = await runRipgrepOnce(
execKaos,
buildRgArgs(rgPath, args, true),
signal,
{ abortedMessage: 'Glob aborted' },
);
if (runResult.kind === 'tool-error') return runResult.result;
}
const { exitCode, stdoutText, stderrText, bufferTruncated, timedOut } = runResult;
// rg exit codes: 0 = matches, 1 = no matches, 2+ = error. Timeout
// kills usually surface as a signal exit code; keep any partial paths.
// If rg returned complete paths before failing on a traversal error such
// as an unreadable subdirectory, keep those paths and surface a warning
// instead of failing the whole search. If no complete path was produced,
// treat stderr as authoritative (invalid glob, spawn failure, etc.).
let traversalWarning: string | undefined;
if (exitCode !== 0 && exitCode !== 1 && !timedOut) {
const rawPathsBeforeError = splitCompletePaths(stdoutText, true);
if (rawPathsBeforeError.length === 0) {
return { isError: true, output: formatGlobError(searchRoot, stderrText) };
}
traversalWarning = formatGlobWarning(stderrText);
}
if (signal.aborted) {
return { isError: true, output: 'Glob aborted' };
}
// One path per line from `rg --files`. When stdout is capped or the run
// timed out, the final chunk can cut a path in half; drop any trailing
// line that lacks its terminating newline so a half-written path is never
// surfaced as a match. Mirrors GrepTool's omitIncompleteTrailingRecord.
// rg reports paths relative to its cwd (the search root), e.g.
// `./src/a.ts`; resolve them back to absolute paths so the sensitive-file
// check, workspace relativization, and display all keep working on
// absolute paths as before.
const rawPaths = splitCompletePaths(stdoutText, bufferTruncated || timedOut).map((p) =>
resolve(searchRoot, p),
);
// Authoritative sensitive-file check (the rg prefilter is conservative).
const kept: string[] = [];
let filteredSensitive = 0;
for (const p of rawPaths) {
if (isSensitiveFile(p)) {
filteredSensitive++;
} else {
kept.push(p);
}
}
const truncated = kept.length > MAX_MATCHES;
const limited = truncated ? kept.slice(0, MAX_MATCHES) : kept;
if (limited.length === 0 && !timedOut) {
if (filteredSensitive > 0) {
return {
output: `No non-sensitive matches found (${String(filteredSensitive)} sensitive file(s) filtered).`,
};
}
return { output: 'No matches found' };
}
// Content shown to the LLM uses paths relative to the search base to
// save tokens, but only for the primary workspace. Relative paths are
// later resolved against workspaceDir, so additionalDir matches stay
// absolute to keep follow-up Read/Edit calls on the same file.
const pathClass = this.kaos.pathClass();
const shouldRelativize = isWithinDirectory(searchRoot, this.workspace.workspaceDir, pathClass);
const displayLines = limited.map((p) =>
shouldRelativize ? relativizeIfUnder(p, searchRoot, pathClass) : p,
);
const lines: string[] = [];
if (timedOut) {
lines.push(
`Glob timed out after ${String(DEFAULT_TIMEOUT_MS / 1000)}s; partial results returned.`,
);
}
if (bufferTruncated) {
lines.push(
`[stdout truncated at ${String(MAX_OUTPUT_BYTES)} bytes; results may be incomplete — use a more specific pattern]`,
);
}
if (traversalWarning !== undefined) {
lines.push(traversalWarning);
}
if (truncated) {
lines.push(`[Truncated at ${String(MAX_MATCHES)} matches — use a more specific pattern]`);
lines.push(`Only the first ${String(MAX_MATCHES)} matches are returned.`);
}
lines.push(...displayLines);
if (filteredSensitive > 0) {
lines.push(`Filtered ${String(filteredSensitive)} sensitive file(s).`);
}
if (!truncated && limited.length === MAX_MATCHES) {
lines.push(`Found ${String(limited.length)} matches`);
}
return { output: lines.join('\n') };
}
}
function buildRgArgs(rgPath: string, args: GlobInput, singleThreaded = false): string[] {
const cmd: string[] = [rgPath];
if (singleThreaded) cmd.push('-j', '1');
cmd.push('--files', '--hidden', '--sortr=modified');
for (const dir of VCS_DIRECTORIES_TO_EXCLUDE) {
cmd.push('--glob', `!${dir}`);
}
// Positive pattern first, then sensitive-file exclusions so a broad
// pattern cannot re-include a sensitive path.
cmd.push('--glob', args.pattern);
for (const glob of SENSITIVE_GLOBS_TO_EXCLUDE) {
cmd.push('--glob', `!${glob}`);
}
if (args.include_ignored) cmd.push('--no-ignore');
// Search path is `.` because the process cwd is pinned to the search root
// (see execution()); this keeps `--glob` matching relative to that root.
cmd.push('.');
return cmd;
}
function formatGlobError(searchRoot: string, stderr: string): string {
const trimmed = stderr.trim();
if (/no such file or directory/i.test(trimmed)) {
return `${searchRoot} does not exist`;
}
return trimmed.length > 0 ? `Glob failed: ${trimmed}` : 'Glob failed';
}
function formatGlobWarning(stderr: string): string {
const trimmed = stderr.trim();
return trimmed.length > 0
? `Glob completed with warnings; some directories could not be read: ${trimmed}`
: 'Glob completed with warnings; some directories could not be read.';
}
function errorCode(error: unknown): string | undefined {
if (error !== null && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: unknown }).code;
return typeof code === 'string' ? code : undefined;
}
return undefined;
}
/**
* Split `rg --files` stdout into complete paths. When the run was capped or
* timed out (`truncatedOutput`), a path cut mid-write lacks its terminating
* newline; drop that trailing fragment so it is never surfaced as a match.
* Complete output always ends in `\n`, so the split is lossless in that case.
*/
export function splitCompletePaths(stdoutText: string, truncatedOutput: boolean): string[] {
let text = stdoutText;
if (truncatedOutput && !text.endsWith('\n')) {
const lastNewline = text.lastIndexOf('\n');
text = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : '';
}
return text.split('\n').filter((p) => p.length > 0);
}
/**
@ -311,116 +376,3 @@ function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass
}
return normCandidate;
}
/**
* Expand brace alternations (`{a,b,c}`, `{src,test}/**`) into a flat list
* of sub-patterns. Recursive handles cartesian products (`{a,b}/{c,d}.ts`
* 4 patterns) and one or more levels of nesting (`{a,{b,c}}.ts`).
*
* Falls through with the original pattern as a single-element list when:
* - the pattern contains no `{...}` group at all;
* - the pattern contains `{...}` groups but none have a top-level comma
* (e.g. `{abc}` bash treats those as literal);
* - braces are unbalanced (a stray `{` with no matching `}`, etc.);
* - expansion would produce more than `MAX_BRACE_EXPANSIONS` patterns
* pathological cartesian inputs (`{a,b}{c,d}{e,f}{g,h}{i,j}{k,l,m}`
* 192) bail out rather than fan out unboundedly.
*
* Backslash-escaped braces (`\{`, `\}`) are treated as literals and skip
* the structural recognition so a user can opt out of expansion.
*/
export function expandBraces(pattern: string): string[] {
const out: string[] = [];
if (!expandInto(pattern, out, MAX_BRACE_EXPANSIONS)) {
// Cap exceeded somewhere down the recursion — discard partial
// fan-out and report the original. Letting half the alternatives
// through would be a silent footgun.
return [pattern];
}
return out;
}
function hasGlobEscape(pattern: string): boolean {
return /\\[{}[\]*?,]/.test(pattern);
}
function expandInto(pattern: string, out: string[], cap: number): boolean {
// Find the first balanced `{...}` group containing a top-level comma.
let depth = 0;
let start = -1;
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === '\\' && i + 1 < pattern.length) {
i++;
continue;
}
if (ch === '{') {
if (depth === 0) start = i;
depth++;
continue;
}
if (ch === '}') {
if (depth === 0) {
// Stray `}` — treat the whole pattern as literal.
return pushLiteral(pattern, out, cap);
}
depth--;
if (depth === 0 && start !== -1) {
const inner = pattern.slice(start + 1, i);
const parts = splitTopLevelCommas(inner);
if (parts.length < 2) {
// No commas at the top level → literal group; skip past it
// and keep scanning for a real alternation further right.
start = -1;
continue;
}
const prefix = pattern.slice(0, start);
const suffix = pattern.slice(i + 1);
for (const part of parts) {
if (out.length >= cap) return false;
if (!expandInto(prefix + part + suffix, out, cap)) return false;
}
return true;
}
}
}
if (depth !== 0) {
// Unbalanced `{` — treat the whole pattern as literal.
return pushLiteral(pattern, out, cap);
}
return pushLiteral(pattern, out, cap);
}
function pushLiteral(pattern: string, out: string[], cap: number): boolean {
if (out.length >= cap) return false;
out.push(pattern);
return true;
}
/**
* Split on commas that sit at brace depth zero. Used by `expandBraces`
* to slice a `{a,{b,c},d}` group into `["a", "{b,c}", "d"]` rather than
* `["a", "{b", "c}", "d"]`.
*/
function splitTopLevelCommas(s: string): string[] {
const parts: string[] = [];
let depth = 0;
let last = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '\\' && i + 1 < s.length) {
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === ',' && depth === 0) {
parts.push(s.slice(last, i));
last = i + 1;
}
}
parts.push(s.slice(last));
return parts;
}

View file

@ -17,9 +17,7 @@
* backend path class.
*/
import type { Readable } from 'node:stream';
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
import type { Kaos } from '@moonshot-ai/kaos';
import { normalize } from 'pathe';
import { z } from 'zod';
@ -29,12 +27,19 @@ import { ToolAccesses } from '../../../loop/tool-access';
import type { ExecutableToolResult, ToolExecution } from '../../../loop/types';
import { resolvePathAccessPath } from '../../policies/path-access';
import type { PathClass } from '../../policies/path-access';
import { isSensitiveFile, SENSITIVE_DOT_VARIANT_SUFFIXES } from '../../policies/sensitive';
import { isSensitiveFile } from '../../policies/sensitive';
import { toInputJsonSchema } from '../../support/input-schema';
import { ensureRgPath, rgUnavailableMessage } from '../../support/rg-locator';
import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match';
import { ToolResultBuilder } from '../../support/result-builder';
import { isPrematureCloseError } from '../../support/stream';
import {
DEFAULT_TIMEOUT_MS,
MAX_OUTPUT_BYTES,
SENSITIVE_GLOBS_TO_EXCLUDE,
VCS_DIRECTORIES_TO_EXCLUDE,
runRipgrepOnce,
shouldRetryRipgrepEagain,
} from '../../support/run-rg';
import type { WorkspaceConfig } from '../../support/workspace';
import GREP_DESCRIPTION from './grep.md?raw';
@ -133,39 +138,11 @@ export const GrepOutputSchema = z.object({
export type GrepInput = z.Infer<typeof GrepInputSchema>;
export type GrepOutput = z.Infer<typeof GrepOutputSchema>;
const DEFAULT_TIMEOUT_MS = 20_000;
const SIGTERM_GRACE_MS = 5_000;
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
async function disposeProcess(proc: KaosProcess): Promise<void> {
try {
await proc.dispose();
} catch {
/* best-effort cleanup */
}
}
// Column cap applied to non-content output modes only; `content` mode returns
// matching lines in full so the cap is intentionally skipped there.
const RG_MAX_COLUMNS = 500;
const DEFAULT_HEAD_LIMIT = 250;
const MTIME_STAT_CONCURRENCY = 32;
const VCS_DIRECTORIES_TO_EXCLUDE = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'] as const;
// This is a conservative prefilter. The authoritative sensitive-file check
// still happens on parsed rg records after execution.
const SENSITIVE_KEY_BASENAMES = ['id_rsa', 'id_ed25519', 'id_ecdsa'] as const;
const SENSITIVE_KEY_GLOBS_TO_EXCLUDE = SENSITIVE_KEY_BASENAMES.flatMap((name) => [
`**/${name}`,
`**/${name}[-_]*`,
...SENSITIVE_DOT_VARIANT_SUFFIXES.map((suffix) => `**/${name}${suffix}`),
]);
const SENSITIVE_GLOBS_TO_EXCLUDE = [
'**/.env',
...SENSITIVE_KEY_GLOBS_TO_EXCLUDE,
'**/.aws/credentials',
'**/.aws/credentials/**',
'**/.gcp/credentials',
'**/.gcp/credentials/**',
] as const;
// Line formats produced by ripgrep:
// content match with --null: "file.py<NUL>10:matched text"
@ -229,13 +206,19 @@ export class GrepTool implements BuiltinTool<GrepInput> {
return { isError: true, output: rgUnavailableMessage(error) };
}
let runResult = await runRipgrepOnce(this.kaos, buildRgArgs(rgPath, args, searchPaths), signal);
let runResult = await runRipgrepOnce(
this.kaos,
buildRgArgs(rgPath, args, searchPaths),
signal,
{ abortedMessage: 'Grep aborted' },
);
if (runResult.kind === 'tool-error') return runResult.result;
if (shouldRetryRipgrepEagain(runResult)) {
runResult = await runRipgrepOnce(
this.kaos,
buildRgArgs(rgPath, args, searchPaths, true),
signal,
{ abortedMessage: 'Grep aborted' },
);
if (runResult.kind === 'tool-error') return runResult.result;
}
@ -360,20 +343,6 @@ export class GrepTool implements BuiltinTool<GrepInput> {
}
interface RipgrepRunResult {
readonly kind: 'result';
readonly exitCode: number;
readonly stdoutText: string;
readonly stderrText: string;
readonly bufferTruncated: boolean;
readonly stderrTruncated: boolean;
readonly timedOut: boolean;
}
type RipgrepRunOutcome =
| RipgrepRunResult
| { readonly kind: 'tool-error'; readonly result: ExecutableToolResult };
type GrepMode = 'content' | 'files_with_matches' | 'count_matches';
type ParsedGrepLine =
@ -397,159 +366,6 @@ class GrepAbortedError extends Error {
}
}
async function runRipgrepOnce(
kaos: Kaos,
rgArgs: readonly string[],
signal: AbortSignal,
): Promise<RipgrepRunOutcome> {
if (signal.aborted) {
return { kind: 'tool-error', result: { isError: true, output: 'Grep aborted' } };
}
let proc: KaosProcess;
try {
proc = await kaos.exec(...rgArgs);
} catch (error) {
// Spawn can still fail after path resolution, e.g. permissions or a
// corrupt binary. ENOENT gets the same actionable hint as locator failures.
const isEnoent =
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT';
return {
kind: 'tool-error',
result: {
isError: true,
output: isEnoent
? rgUnavailableMessage(error)
: error instanceof Error
? error.message
: String(error),
},
};
}
try {
proc.stdin.end();
} catch {
/* already gone */
}
let timedOut = false;
let aborted = false;
let killed = false;
const killProc = async (): Promise<void> => {
if (killed) return;
killed = true;
try {
await proc.kill('SIGTERM');
} catch {
/* process already gone */
}
const exited = proc
.wait()
.then(() => true)
.catch(() => true);
const raced = await Promise.race([
exited,
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, SIGTERM_GRACE_MS);
}),
]);
if (!raced && proc.exitCode === null) {
try {
await proc.kill('SIGKILL');
} catch {
/* ignore */
}
}
await disposeProcess(proc);
};
const onAbort = (): void => {
aborted = true;
void killProc();
};
signal.addEventListener('abort', onAbort);
// AbortSignal does not replay past abort events; check once after registering
// the listener so already-aborted calls still run the cleanup path.
if (signal.aborted) onAbort();
const timeoutHandle = setTimeout(() => {
timedOut = true;
void killProc();
}, DEFAULT_TIMEOUT_MS);
let exitCode = 0;
let stdoutText = '';
let stderrText = '';
let bufferTruncated = false;
let stderrTruncated = false;
try {
const isTerminating = (): boolean => timedOut || aborted || killed;
const [stdoutResult, stderrResult, code] = await Promise.all([
readStreamWithCap(proc.stdout, MAX_OUTPUT_BYTES, isTerminating),
readStreamWithCap(proc.stderr, MAX_OUTPUT_BYTES, isTerminating),
proc.wait(),
]);
stdoutText = stdoutResult.text;
stderrText = stderrResult.text;
bufferTruncated = stdoutResult.truncated;
stderrTruncated = stderrResult.truncated;
exitCode = code;
} catch (error) {
if (isPrematureCloseError(error) && (timedOut || aborted || killed)) {
// The disposer intentionally closes streams after a terminating signal.
} else {
return {
kind: 'tool-error',
result: {
isError: true,
output: error instanceof Error ? error.message : String(error),
},
};
}
} finally {
clearTimeout(timeoutHandle);
signal.removeEventListener('abort', onAbort);
await disposeProcess(proc);
}
if (aborted) {
return {
kind: 'tool-error',
result: { isError: true, output: 'Grep aborted' },
};
}
return {
kind: 'result',
exitCode,
stdoutText,
stderrText,
bufferTruncated,
stderrTruncated,
timedOut,
};
}
function shouldRetryRipgrepEagain(result: RipgrepRunResult): boolean {
return (
result.exitCode !== 0 &&
result.exitCode !== 1 &&
!result.timedOut &&
isEagainRipgrepError(result.stderrText)
);
}
function isEagainRipgrepError(stderr: string): boolean {
return stderr.includes('os error 11') || stderr.includes('Resource temporarily unavailable');
}
async function sortFilesWithMatchesByMtime(
lines: readonly ParsedGrepLine[],
kaos: Kaos,
@ -951,39 +767,3 @@ function countPayloadFromLegacyLine(line: string): string | undefined {
const idx = line.lastIndexOf(':');
return idx > 0 ? line.slice(idx + 1) : undefined;
}
interface CappedStreamResult {
readonly text: string;
readonly truncated: boolean;
}
async function readStreamWithCap(
stream: Readable,
maxBytes: number,
suppressPrematureClose?: () => boolean,
): Promise<CappedStreamResult> {
const chunks: Buffer[] = [];
let total = 0;
let truncated = false;
try {
for await (const chunk of stream) {
const buf: Buffer =
typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer);
if (truncated) continue;
if (total + buf.length > maxBytes) {
const remaining = maxBytes - total;
if (remaining > 0) chunks.push(buf.subarray(0, remaining));
total = maxBytes;
truncated = true;
continue;
}
chunks.push(buf);
total += buf.length;
}
} catch (error) {
if (!isPrematureCloseError(error) || suppressPrematureClose?.() !== true) {
throw error;
}
}
return { text: Buffer.concat(chunks).toString('utf8'), truncated };
}

View file

@ -0,0 +1,257 @@
/**
* run-rg shared ripgrep subprocess plumbing.
*
* Single place that knows how we spawn `rg` through Kaos: timeout / abort
* handling, capped stdout / stderr draining, two-phase kill with process
* disposal, and the standard exclusion globs (VCS metadata + sensitive
* files) shared by GrepTool and GlobTool. Mode-specific argument building
* and output parsing stay in the tools themselves.
*/
import type { Readable } from 'node:stream';
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
import type { ExecutableToolResult } from '../../loop/types';
import { SENSITIVE_DOT_VARIANT_SUFFIXES } from '../policies/sensitive';
import { rgUnavailableMessage } from './rg-locator';
import { isPrematureCloseError } from './stream';
export const DEFAULT_TIMEOUT_MS = 20_000;
export const SIGTERM_GRACE_MS = 5_000;
export const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
export const VCS_DIRECTORIES_TO_EXCLUDE = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'] as const;
// Conservative prefilter. The authoritative sensitive-file check still happens
// on parsed rg records after execution.
export const SENSITIVE_KEY_BASENAMES = ['id_rsa', 'id_ed25519', 'id_ecdsa'] as const;
export const SENSITIVE_KEY_GLOBS_TO_EXCLUDE = SENSITIVE_KEY_BASENAMES.flatMap((name) => [
`**/${name}`,
`**/${name}[-_]*`,
...SENSITIVE_DOT_VARIANT_SUFFIXES.map((suffix) => `**/${name}${suffix}`),
]);
export const SENSITIVE_GLOBS_TO_EXCLUDE = [
'**/.env',
...SENSITIVE_KEY_GLOBS_TO_EXCLUDE,
'**/.aws/credentials',
'**/.aws/credentials/**',
'**/.gcp/credentials',
'**/.gcp/credentials/**',
] as const;
export interface RipgrepRunResult {
readonly kind: 'result';
readonly exitCode: number;
readonly stdoutText: string;
readonly stderrText: string;
readonly bufferTruncated: boolean;
readonly stderrTruncated: boolean;
readonly timedOut: boolean;
}
export type RipgrepRunOutcome =
| RipgrepRunResult
| { readonly kind: 'tool-error'; readonly result: ExecutableToolResult };
export interface RunRipgrepOptions {
/** Message surfaced when the run is aborted via `signal`. Defaults to `"Aborted"`. */
readonly abortedMessage?: string;
}
async function disposeProcess(proc: KaosProcess): Promise<void> {
try {
await proc.dispose();
} catch {
/* best-effort cleanup */
}
}
export async function runRipgrepOnce(
kaos: Kaos,
rgArgs: readonly string[],
signal: AbortSignal,
options: RunRipgrepOptions = {},
): Promise<RipgrepRunOutcome> {
const abortedMessage = options.abortedMessage ?? 'Aborted';
if (signal.aborted) {
return { kind: 'tool-error', result: { isError: true, output: abortedMessage } };
}
let proc: KaosProcess;
try {
proc = await kaos.exec(...rgArgs);
} catch (error) {
// Spawn can still fail after path resolution, e.g. permissions or a
// corrupt binary. ENOENT gets the same actionable hint as locator failures.
const isEnoent =
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT';
return {
kind: 'tool-error',
result: {
isError: true,
output: isEnoent
? rgUnavailableMessage(error)
: error instanceof Error
? error.message
: String(error),
},
};
}
try {
proc.stdin.end();
} catch {
/* already gone */
}
let timedOut = false;
let aborted = false;
let killed = false;
const killProc = async (): Promise<void> => {
if (killed) return;
killed = true;
try {
await proc.kill('SIGTERM');
} catch {
/* process already gone */
}
const exited = proc
.wait()
.then(() => true)
.catch(() => true);
const raced = await Promise.race([
exited,
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, SIGTERM_GRACE_MS);
}),
]);
if (!raced && proc.exitCode === null) {
try {
await proc.kill('SIGKILL');
} catch {
/* ignore */
}
}
await disposeProcess(proc);
};
const onAbort = (): void => {
aborted = true;
void killProc();
};
signal.addEventListener('abort', onAbort);
// AbortSignal does not replay past abort events; check once after registering
// the listener so already-aborted calls still run the cleanup path.
if (signal.aborted) onAbort();
const timeoutHandle = setTimeout(() => {
timedOut = true;
void killProc();
}, DEFAULT_TIMEOUT_MS);
let exitCode = 0;
let stdoutText = '';
let stderrText = '';
let bufferTruncated = false;
let stderrTruncated = false;
try {
const isTerminating = (): boolean => timedOut || aborted || killed;
const [stdoutResult, stderrResult, code] = await Promise.all([
readStreamWithCap(proc.stdout, MAX_OUTPUT_BYTES, isTerminating),
readStreamWithCap(proc.stderr, MAX_OUTPUT_BYTES, isTerminating),
proc.wait(),
]);
stdoutText = stdoutResult.text;
stderrText = stderrResult.text;
bufferTruncated = stdoutResult.truncated;
stderrTruncated = stderrResult.truncated;
exitCode = code;
} catch (error) {
if (isPrematureCloseError(error) && (timedOut || aborted || killed)) {
// The disposer intentionally closes streams after a terminating signal.
} else {
return {
kind: 'tool-error',
result: {
isError: true,
output: error instanceof Error ? error.message : String(error),
},
};
}
} finally {
clearTimeout(timeoutHandle);
signal.removeEventListener('abort', onAbort);
await disposeProcess(proc);
}
if (aborted) {
return { kind: 'tool-error', result: { isError: true, output: abortedMessage } };
}
return {
kind: 'result',
exitCode,
stdoutText,
stderrText,
bufferTruncated,
stderrTruncated,
timedOut,
};
}
export function shouldRetryRipgrepEagain(result: RipgrepRunResult): boolean {
return (
result.exitCode !== 0 &&
result.exitCode !== 1 &&
!result.timedOut &&
isEagainRipgrepError(result.stderrText)
);
}
function isEagainRipgrepError(stderr: string): boolean {
return stderr.includes('os error 11') || stderr.includes('Resource temporarily unavailable');
}
interface CappedStreamResult {
readonly text: string;
readonly truncated: boolean;
}
async function readStreamWithCap(
stream: Readable,
maxBytes: number,
suppressPrematureClose?: () => boolean,
): Promise<CappedStreamResult> {
const chunks: Buffer[] = [];
let total = 0;
let truncated = false;
try {
for await (const chunk of stream) {
const buf: Buffer =
typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer);
if (truncated) continue;
if (total + buf.length > maxBytes) {
const remaining = maxBytes - total;
if (remaining > 0) chunks.push(buf.subarray(0, remaining));
total = maxBytes;
truncated = true;
continue;
}
chunks.push(buf);
total += buf.length;
}
} catch (error) {
if (!isPrematureCloseError(error) || suppressPrematureClose?.() !== true) {
throw error;
}
}
return { text: Buffer.concat(chunks).toString('utf8'), truncated };
}

View file

@ -44,6 +44,12 @@ import {
AgentSwarmToolInputSchema,
} from '../../src/tools/builtin/collaboration/agent-swarm';
vi.mock('../../src/tools/support/rg-locator', () => ({
ensureRgPath: vi.fn(async () => ({ path: '/mock/rg', source: 'system-path' })),
rgUnavailableMessage: (cause: unknown) =>
`rg unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
}));
const signal = new AbortController().signal;
const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: [] };
const regularFileStat = {
@ -187,18 +193,9 @@ describe('current builtin file and shell tools', () => {
it('Glob exposes parameters and walks pure-wildcard patterns capped at MAX_MATCHES', async () => {
// Pure wildcards used to be rejected up-front; now they walk like
// any other pattern and the 100-match cap is the only safety.
const glob = vi.fn().mockReturnValue(
(async function* () {
yield '/workspace/a.ts';
})(),
);
const tool = new GlobTool(
createFakeKaos({
glob,
stat: vi.fn().mockResolvedValue({ stMtime: 1, stMode: 0o100000 }),
}),
workspace,
);
const exec = vi.fn().mockResolvedValue(processWithOutput('/workspace/a.ts\n'));
const stat = vi.fn().mockResolvedValue({ ...regularFileStat, stMode: 0o040000 });
const tool = new GlobTool(createFakeKaos({ exec, stat }), workspace);
expect(GlobInputSchema.safeParse({ pattern: '*.ts' }).success).toBe(true);
expect(tool.parameters).toMatchObject({
@ -208,7 +205,8 @@ describe('current builtin file and shell tools', () => {
const result = await executeTool(tool, context({ pattern: '**' }));
expect(result.isError).toBeFalsy();
expect(glob).toHaveBeenCalledWith('/workspace', '**');
expect(exec).toHaveBeenCalled();
expect((exec.mock.calls[0] as string[]).at(-1)).toBe('.');
expect(result.output).toContain('a.ts');
});

File diff suppressed because it is too large Load diff