mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
10ffb7d9f9
commit
c82dcf9cd8
11 changed files with 960 additions and 959 deletions
6
.changeset/glob-ripgrep.md
Normal file
6
.changeset/glob-ripgrep.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
257
packages/agent-core/src/tools/support/run-rg.ts
Normal file
257
packages/agent-core/src/tools/support/run-rg.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue