feat(coding-agent): added unified path targets for grep family
- Consolidated grep, ast-grep, and ast-edit on required `path`, replacing `glob`/`lang`/`sel` with inline file, dir, glob, list, and URL targets. - Changed ast-grep and grep schemas to require a single `pat` string and use `skip` pagination instead of array patterns or offsets. - Updated argument validation to reject empty `path` and invalid `skip`, and routed grep context to session settings only. - Updated tool prompts and tests to reflect new path globbing semantics and `first N` truncation output text.
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed multi-pattern array input from `ast_grep` by changing `pat` to a single pattern string, so call sites using `pat: [...]` must be updated to send one query per invocation
|
||||
- Removed `lang`, `glob`, and `sel` options from `ast_edit` and `ast_grep`, and moved those behaviors into the required `path` argument
|
||||
- Required `path` for `ast_edit` and `ast_grep`, so invocations that relied on implicit repo-root searching are no longer valid
|
||||
- Renamed atom edit operations from `before` and `after` to `pre` and `post`, so existing `atom` payloads using the old operation keys must be updated
|
||||
- Changed the hashline anchor format from `LINE#ID:content` to `LINEID\tcontent` (no `#`/`:` separators, tab between anchor and content, no padding on line numbers); expanded the bigram alphabet from 40 hand-picked English bigrams to the full 647 single-token 2-letter bigrams — invalidates every previously captured `LINE#ID` reference
|
||||
- Renamed the subagent completion contract from `submit_result` to `yield`, so subagent sessions must now finish with the `yield` tool and the `requireYieldTool` option; `submit_result`/`requireSubmitResultTool` and old completion calls are no longer recognized
|
||||
@@ -19,6 +23,9 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `ast_edit` and `ast_grep` to pass file-selection intent through `path` (including inline globs and comma/space-separated path lists) instead of separate `glob` filters
|
||||
- Changed `ast_grep` pagination API from `offset` to `skip`
|
||||
- Changed `grep` truncation output to report `Result limit reached; narrow path.` and label match/result caps as `first N`
|
||||
- Changed JSON tree output to truncate inline argument pairs by available width and add an ellipsis when values no longer fit in the display
|
||||
- Changed JSON tree rendering to hide harness-internal `intent` and `__partialJson` fields from top-level tool output
|
||||
- Simplified the `grep` tool schema by requiring `path`, folding glob and type filtering into path globs, auto-detecting multiline patterns, removing model-controlled context and limit options, and renaming result skipping to `skip`.
|
||||
|
||||
@@ -177,16 +177,11 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
||||
|
||||
async grep(args: Parameters<NonNullable<ICursorExecHandlers["grep"]>>[0]) {
|
||||
const toolCallId = decodeToolCallId(args.toolCallId);
|
||||
const grepPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
|
||||
const toolResultMessage = await executeTool(this.options, "grep", toolCallId, {
|
||||
pattern: args.pattern,
|
||||
path: args.path || undefined,
|
||||
glob: args.glob || undefined,
|
||||
mode: args.outputMode || undefined,
|
||||
context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined,
|
||||
ignore_case: args.caseInsensitive || undefined,
|
||||
type: args.type || undefined,
|
||||
limit: args.headLimit ?? undefined,
|
||||
multiline: args.multiline || undefined,
|
||||
path: grepPath,
|
||||
i: args.caseInsensitive || undefined,
|
||||
});
|
||||
return toolResultMessage;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as nodePath from "node:path";
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { StringEnum } from "@oh-my-pi/pi-coding-agent";
|
||||
import { StringEnum } from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
ChunkAnchorStyle,
|
||||
ChunkEditOp,
|
||||
|
||||
@@ -2,12 +2,12 @@ Performs structural AST-aware rewrites via native ast-grep.
|
||||
|
||||
<instruction>
|
||||
- Use for codemods and structural rewrites where plain text replace is unsafe
|
||||
- `path` accepts a comma-separated list in addition to file/dir/glob
|
||||
- Set `lang` explicitly in mixed-language trees for deterministic rewrites
|
||||
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
|
||||
- Language is inferred from `path`; narrow `path` to one language for deterministic rewrites
|
||||
- Metavariables captured in `pat` (`$A`, `$$$ARGS`) are substituted into that entry's `out` template
|
||||
- **Patterns match AST structure, not text.** `$NAME` = one node (captured); `$_` = one without binding; `$$$NAME` = zero-or-more (lazy — stops at next matchable element); `$$$` = zero-or-more without binding. Use `$$$NAME`, **NOT** `$$NAME` — the two-dollar form is invalid. Metavariable names are UPPERCASE and **MUST** be the whole AST node — partial text like `prefix$VAR` or `"hello $NAME"` does NOT work
|
||||
- When the same metavariable appears twice, both occurrences **MUST** match identical code (`$A == $A` matches `x == x`, not `x == y`)
|
||||
- Rewrite patterns **MUST** parse as a single valid AST node. For method fragments or body snippets that don't parse standalone, wrap in context (e.g. `class $_ { … }`) and set `sel` to target the inner node — match and replacement target the selected node, not the wrapper. If ast-grep reports `Multiple AST nodes are detected`, wrap and use `sel`
|
||||
- Rewrite patterns **MUST** parse as a single valid AST node. For method fragments or body snippets that don't parse standalone, wrap in context (e.g. `class $_ { … }`)
|
||||
- For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }`
|
||||
- Delete matched code with empty `out`: `{"pat":"console.log($$$)","out":""}`
|
||||
- Each rewrite is a 1:1 structural substitution — cannot split one capture across multiple nodes or merge multiple captures into one
|
||||
@@ -19,22 +19,18 @@ Performs structural AST-aware rewrites via native ast-grep.
|
||||
</output>
|
||||
|
||||
<examples>
|
||||
# Rename a call site across a directory
|
||||
`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"lang":"typescript","path":"src/"}`
|
||||
# Delete matching calls (empty `out` removes the node)
|
||||
`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"lang":"typescript","path":"src/"}`
|
||||
# Rename a call site across TypeScript files
|
||||
`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"path":"src/**/*.ts"}`
|
||||
# Delete matching calls
|
||||
`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"path":"src/**/*.ts"}`
|
||||
# Rewrite import source path
|
||||
`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"lang":"typescript","path":"src/"}`
|
||||
`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"path":"src/**/*.ts"}`
|
||||
# Modernize to optional chaining (same metavariable enforces identity)
|
||||
`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"lang":"typescript","path":"src/"}`
|
||||
`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"path":"src/**/*.ts"}`
|
||||
# Swap two arguments using captures
|
||||
`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"lang":"typescript","path":"tests/"}`
|
||||
# Rename a function declaration while tolerating any return type annotation
|
||||
`{"ops":[{"pat":"async function fetchData($$$ARGS): $_ { $$$BODY }","out":"async function loadData($$$ARGS): $_ { $$$BODY }"}],"sel":"function_declaration","lang":"typescript","path":"src/api.ts"}`
|
||||
# Rewrite a method body fragment by wrapping in parseable context (sel targets the inner node)
|
||||
`{"ops":[{"pat":"class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $_.parse($INPUT); $$$AFTER } }","out":"class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $SCHEMA.parse($INPUT); $$$AFTER } }"}],"sel":"method_definition","lang":"typescript","path":"src/tools/todo.ts"}`
|
||||
`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"path":"tests/**/*.ts"}`
|
||||
# Python — convert print calls to logging
|
||||
`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"lang":"python","path":"src/"}`
|
||||
`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"path":"src/**/*.py"}`
|
||||
</examples>
|
||||
|
||||
<critical>
|
||||
|
||||
@@ -2,18 +2,18 @@ Performs structural code search using AST matching via native ast-grep.
|
||||
|
||||
<instruction>
|
||||
- Use when syntax shape matters more than raw text (calls, declarations, specific language constructs)
|
||||
- `path` accepts a comma-separated list in addition to file/dir/glob
|
||||
- Set `lang` explicitly in mixed-language trees to avoid parse noise from non-source files
|
||||
- Multiple patterns in `pat` run in one native pass, merged, then `offset`/`limit` applied
|
||||
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
|
||||
- Language is inferred from `path`; narrow `path` to one language when mixed-language trees could cause parse noise
|
||||
- `pat` is a single AST pattern. Run separate calls for distinct unrelated patterns
|
||||
- **Patterns match AST structure, not text** — whitespace/formatting is ignored
|
||||
- `$NAME` captures one node; `$_` matches one without binding; `$$$NAME` captures zero-or-more (lazy — stops at next matchable element); `$$$` matches zero-or-more without binding. Use `$$$NAME`, **NOT** `$$NAME` — the two-dollar form is invalid and produces a parse error
|
||||
- Metavariable names are UPPERCASE and must be the whole AST node — partial-text like `prefix$VAR`, `"hello $NAME"`, or `a $OP b` does NOT work; match the whole node instead
|
||||
- When the same metavariable appears twice, both occurrences **MUST** match identical code (`$A == $A` matches `x == x`, not `x == y`)
|
||||
- Patterns **MUST** parse as a single valid AST node for the target language. For method fragments or body snippets that don't parse standalone, wrap in valid context (e.g. `class $_ { … }`) and set `sel` to target the inner node — results return for the selected node, not the outer wrapper. If ast-grep reports `Multiple AST nodes are detected`, the pattern isn't a single parseable node — wrap and use `sel`
|
||||
- C++ qualified calls used as expression statements need the statement semicolon in the pattern: use `ns::doThing($ARG);`, `$CALLEE($ARG)`, or wrap a statement snippet and select `call_expression`. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches
|
||||
- Patterns **MUST** parse as a single valid AST node for the inferred target language. For method fragments or body snippets that don't parse standalone, wrap in valid context (e.g. `class $_ { … }`)
|
||||
- C++ qualified calls used as expression statements need the statement semicolon in the pattern: use `ns::doThing($ARG);`, `$CALLEE($ARG);`, or wrap a statement snippet. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches
|
||||
- For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }`
|
||||
- Declaration forms are structurally distinct — top-level `function foo`, class method `foo()`, and `const foo = () => {}` are different AST shapes; search the right form before concluding absence
|
||||
- Loosest existence check: `pat: ["executeBash"]` with `sel: "identifier"`
|
||||
- Loosest existence check: `pat: "executeBash"` with a narrow `path`
|
||||
</instruction>
|
||||
|
||||
<output>
|
||||
@@ -22,26 +22,20 @@ Performs structural code search using AST matching via native ast-grep.
|
||||
</output>
|
||||
|
||||
<examples>
|
||||
# Multi-pattern scoped search
|
||||
`{"pat":["console.log($$$)","console.error($$$)"],"lang":"typescript","path":"src/"}`
|
||||
# Named imports from a specific package (quoted string inside pattern)
|
||||
`{"pat":["import { $$$IMPORTS } from \"react\""],"lang":"typescript","path":"src/"}`
|
||||
# Arrow functions assigned to a const (distinct AST from function declarations)
|
||||
`{"pat":["const $NAME = ($$$ARGS) => $BODY"],"lang":"typescript","path":"src/utils/"}`
|
||||
# Search TypeScript files under src
|
||||
`{"pat":"console.log($$$)","path":"src/**/*.ts"}`
|
||||
# Named imports from a specific package
|
||||
`{"pat":"import { $$$IMPORTS } from \"react\"","path":"src/**/*.ts"}`
|
||||
# Arrow functions assigned to a const
|
||||
`{"pat":"const $NAME = ($$$ARGS) => $BODY","path":"src/utils/**/*.ts"}`
|
||||
# Method call on any object, ignoring method name with `$_`
|
||||
`{"pat":["logger.$_($$$ARGS)"],"lang":"typescript","path":"src/"}`
|
||||
# Contextual pattern with selector — match the identifier `foo`, not the whole call
|
||||
`{"pat":["foo()"],"sel":"identifier","lang":"typescript","path":"src/utils.ts"}`
|
||||
# Match a function declaration while tolerating any return type annotation (sel targets the inner node)
|
||||
`{"pat":["async function processItems($$$ARGS): $_ { $$$BODY }"],"sel":"function_declaration","lang":"typescript","path":"src/worker.ts"}`
|
||||
# Match a method body fragment by wrapping in parseable context and selecting the method
|
||||
`{"pat":["class $_ { async execute($INPUT: $_) { $$$BEFORE; const $PARSED = $_.parse($INPUT); $$$AFTER } }"],"sel":"method_definition","lang":"typescript","path":"src/tools/todo.ts"}`
|
||||
`{"pat":"logger.$_($$$ARGS)","path":"src/**/*.ts"}`
|
||||
# Loosest existence check for a symbol in one file
|
||||
`{"pat":["processItems"],"sel":"identifier","lang":"typescript","path":"src/worker.ts"}`
|
||||
`{"pat":"processItems","path":"src/worker.ts"}`
|
||||
</examples>
|
||||
|
||||
<critical>
|
||||
- Avoid repo-root AST scans when the target is language-specific — narrow `path` first
|
||||
- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `path`/`glob`/`lang` before concluding "no matches"
|
||||
- Avoid repo-root scans — narrow `path` first
|
||||
- Parse issues are query failure, not evidence of absence: repair the pattern or tighten `path` before concluding "no matches"
|
||||
- For broad/open-ended exploration across subsystems, use Task tool with explore subagent first
|
||||
</critical>
|
||||
|
||||
@@ -2,9 +2,8 @@ Searches files using powerful regex matching.
|
||||
|
||||
<instruction>
|
||||
- Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go)
|
||||
- `path` also accepts comma-separated path lists; pair with `glob` when you need a relative file filter in addition to `type`
|
||||
- For cross-line patterns like `struct \\{[\\s\\S]*?field`, set `multiline: true`
|
||||
- If the pattern contains a literal `\n`, `multiline` defaults to true automatically
|
||||
- `path` is required and accepts a file, directory, glob, comma-separated path list, or internal URL
|
||||
- Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
|
||||
</instruction>
|
||||
|
||||
<output>
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { ToolSession } from ".";
|
||||
import { createFileRecorder, formatResultPath } from "./file-recorder";
|
||||
import type { OutputMeta } from "./output-meta";
|
||||
import {
|
||||
combineSearchGlobs,
|
||||
hasGlobPathChars,
|
||||
normalizePathLikeInput,
|
||||
parseSearchPath,
|
||||
@@ -46,21 +45,10 @@ const astEditSchema = Type.Object({
|
||||
minItems: 1,
|
||||
description: "rewrite ops",
|
||||
}),
|
||||
lang: Type.Optional(Type.String({ description: "language override", examples: ["typescript", "python"] })),
|
||||
path: Type.Optional(
|
||||
Type.String({
|
||||
description: "path or glob to rewrite",
|
||||
examples: ["src/", "src/foo.ts"],
|
||||
}),
|
||||
),
|
||||
glob: Type.Optional(Type.String({ description: "glob filter", examples: ["**/*.ts"] })),
|
||||
sel: Type.Optional(
|
||||
Type.String({
|
||||
description: "contextual pattern selector",
|
||||
examples: ["function_declaration", "call_expression", "identifier"],
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(Type.Number({ description: "max replacements" })),
|
||||
path: Type.String({
|
||||
description: "file, directory, glob, or comma-separated paths to rewrite",
|
||||
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
|
||||
}),
|
||||
});
|
||||
|
||||
export interface AstEditToolDetails {
|
||||
@@ -115,10 +103,6 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
seenPatterns.add(pat);
|
||||
}
|
||||
const normalizedRewrites = Object.fromEntries(ops);
|
||||
const maxReplacements = params.limit !== undefined ? Math.floor(params.limit) : undefined;
|
||||
if (maxReplacements !== undefined && (!Number.isFinite(maxReplacements) || maxReplacements < 1)) {
|
||||
throw new ToolError("limit must be a positive number");
|
||||
}
|
||||
const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
|
||||
|
||||
const formatScopePath = (targetPath: string): string => {
|
||||
@@ -127,8 +111,11 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
};
|
||||
let searchPath: string | undefined;
|
||||
let scopePath: string | undefined;
|
||||
let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined;
|
||||
const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined;
|
||||
let globFilter: string | undefined;
|
||||
const rawPath = normalizePathLikeInput(params.path);
|
||||
if (rawPath.length === 0) {
|
||||
throw new ToolError("`path` must be a non-empty path or glob");
|
||||
}
|
||||
if (rawPath) {
|
||||
const internalRouter = this.session.internalRouter;
|
||||
if (internalRouter?.canHandle(rawPath)) {
|
||||
@@ -150,7 +137,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
}
|
||||
}
|
||||
@@ -167,12 +154,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
|
||||
const result = await astEdit({
|
||||
rewrites: normalizedRewrites,
|
||||
lang: params.lang?.trim(),
|
||||
path: resolvedSearchPath,
|
||||
glob: globFilter,
|
||||
selector: params.sel?.trim(),
|
||||
dryRun: true,
|
||||
maxReplacements,
|
||||
maxFiles,
|
||||
failOnParseError: false,
|
||||
signal,
|
||||
@@ -295,7 +279,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
count: fileReplacementCounts.get(filePath) ?? 0,
|
||||
}));
|
||||
if (result.limitReached) {
|
||||
outputLines.push("", "Limit reached; narrow path or increase limit.");
|
||||
outputLines.push("", "Limit reached; narrow path.");
|
||||
}
|
||||
if (dedupedParseErrors.length) {
|
||||
outputLines.push("", ...formatParseErrors(dedupedParseErrors));
|
||||
@@ -311,12 +295,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
apply: async (_reason: string) => {
|
||||
const applyResult = await astEdit({
|
||||
rewrites: normalizedRewrites,
|
||||
lang: params.lang?.trim(),
|
||||
path: resolvedSearchPath,
|
||||
glob: globFilter,
|
||||
selector: params.sel?.trim(),
|
||||
dryRun: false,
|
||||
maxReplacements,
|
||||
maxFiles,
|
||||
failOnParseError: false,
|
||||
});
|
||||
@@ -391,10 +372,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
|
||||
|
||||
interface AstEditRenderArgs {
|
||||
ops?: Array<{ pat?: string; out?: string }>;
|
||||
lang?: string;
|
||||
path?: string;
|
||||
sel?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
|
||||
@@ -403,9 +381,7 @@ export const astEditToolRenderer = {
|
||||
inline: true,
|
||||
renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const meta: string[] = [];
|
||||
if (args.lang) meta.push(`lang:${args.lang}`);
|
||||
if (args.path) meta.push(`in ${args.path}`);
|
||||
if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
|
||||
const rewriteCount = args.ops?.length ?? 0;
|
||||
if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
|
||||
|
||||
@@ -495,7 +471,7 @@ export const astEditToolRenderer = {
|
||||
|
||||
const extraLines: string[] = [];
|
||||
if (limitReached) {
|
||||
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path or increase limit"));
|
||||
extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
|
||||
}
|
||||
if (details?.parseErrors?.length) {
|
||||
const total = details.parseErrors.length;
|
||||
|
||||
@@ -15,7 +15,6 @@ import { createFileRecorder, formatResultPath } from "./file-recorder";
|
||||
import { formatMatchLine } from "./match-line-format";
|
||||
import type { OutputMeta } from "./output-meta";
|
||||
import {
|
||||
combineSearchGlobs,
|
||||
hasGlobPathChars,
|
||||
normalizePathLikeInput,
|
||||
parseSearchPath,
|
||||
@@ -36,28 +35,12 @@ import { ToolError } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
|
||||
const astGrepSchema = Type.Object({
|
||||
pat: Type.Array(Type.String(), {
|
||||
minItems: 1,
|
||||
description: "ast patterns",
|
||||
examples: [["console.log($$$)"]],
|
||||
pat: Type.String({ description: "ast pattern", examples: ["console.log($$$)"] }),
|
||||
path: Type.String({
|
||||
description: "file, directory, glob, or comma-separated paths to search",
|
||||
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
|
||||
}),
|
||||
lang: Type.Optional(Type.String({ description: "language override", examples: ["typescript", "python"] })),
|
||||
path: Type.Optional(
|
||||
Type.String({
|
||||
description: "path or glob to search",
|
||||
examples: ["src/", "src/foo.ts"],
|
||||
}),
|
||||
),
|
||||
glob: Type.Optional(Type.String({ description: "glob filter", examples: ["**/*.ts"] })),
|
||||
sel: Type.Optional(
|
||||
Type.String({
|
||||
description: "contextual pattern selector",
|
||||
examples: ["function_declaration", "call_expression", "identifier"],
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(Type.Number({ description: "max matches", default: 50 })),
|
||||
offset: Type.Optional(Type.Number({ description: "skip first n matches", default: 0 })),
|
||||
context: Type.Optional(Type.Number({ description: "context lines" })),
|
||||
skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
|
||||
});
|
||||
|
||||
export interface AstGrepToolDetails {
|
||||
@@ -94,55 +77,48 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<AstGrepToolDetails>> {
|
||||
return untilAborted(signal, async () => {
|
||||
const patterns = [...new Set(params.pat.map(pattern => pattern.trim()).filter(pattern => pattern.length > 0))];
|
||||
if (patterns.length === 0) {
|
||||
throw new ToolError("`pat` must include at least one non-empty pattern");
|
||||
const pattern = params.pat.trim();
|
||||
if (pattern.length === 0) {
|
||||
throw new ToolError("`pat` must be a non-empty pattern");
|
||||
}
|
||||
const limit = params.limit === undefined ? 50 : Math.floor(params.limit);
|
||||
if (!Number.isFinite(limit) || limit < 1) {
|
||||
throw new ToolError("Limit must be a positive number");
|
||||
const patterns = [pattern];
|
||||
const skip = params.skip === undefined ? 0 : Math.floor(params.skip);
|
||||
if (!Number.isFinite(skip) || skip < 0) {
|
||||
throw new ToolError("skip must be a non-negative number");
|
||||
}
|
||||
const offset = params.offset === undefined ? 0 : Math.floor(params.offset);
|
||||
if (!Number.isFinite(offset) || offset < 0) {
|
||||
throw new ToolError("Offset must be a non-negative number");
|
||||
}
|
||||
const context = params.context === undefined ? undefined : Math.floor(params.context);
|
||||
if (context !== undefined && (!Number.isFinite(context) || context < 0)) {
|
||||
throw new ToolError("Context must be a non-negative number");
|
||||
}
|
||||
|
||||
const formatScopePath = (targetPath: string): string => {
|
||||
const relative = path.relative(this.session.cwd, targetPath).replace(/\\/g, "/");
|
||||
return relative.length === 0 ? "." : relative;
|
||||
};
|
||||
let searchPath: string | undefined;
|
||||
let scopePath: string | undefined;
|
||||
let globFilter = params.glob ? normalizePathLikeInput(params.glob) || undefined : undefined;
|
||||
const rawPath = params.path ? normalizePathLikeInput(params.path) || undefined : undefined;
|
||||
if (rawPath) {
|
||||
const internalRouter = this.session.internalRouter;
|
||||
if (internalRouter?.canHandle(rawPath)) {
|
||||
if (hasGlobPathChars(rawPath)) {
|
||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||
}
|
||||
const resource = await internalRouter.resolve(rawPath);
|
||||
if (!resource.sourcePath) {
|
||||
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
let globFilter: string | undefined;
|
||||
const rawPath = normalizePathLikeInput(params.path);
|
||||
if (rawPath.length === 0) {
|
||||
throw new ToolError("`path` must be a non-empty path or glob");
|
||||
}
|
||||
const internalRouter = this.session.internalRouter;
|
||||
if (internalRouter?.canHandle(rawPath)) {
|
||||
if (hasGlobPathChars(rawPath)) {
|
||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||
}
|
||||
const resource = await internalRouter.resolve(rawPath);
|
||||
if (!resource.sourcePath) {
|
||||
throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
|
||||
if (multiSearchPath) {
|
||||
searchPath = multiSearchPath.basePath;
|
||||
globFilter = multiSearchPath.glob;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
} else {
|
||||
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
|
||||
if (multiSearchPath) {
|
||||
searchPath = multiSearchPath.basePath;
|
||||
globFilter = multiSearchPath.glob;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
|
||||
scopePath = formatScopePath(searchPath);
|
||||
}
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,13 +134,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
|
||||
const result = await astGrep({
|
||||
patterns,
|
||||
lang: params.lang?.trim(),
|
||||
path: resolvedSearchPath,
|
||||
glob: globFilter,
|
||||
selector: params.sel?.trim(),
|
||||
limit,
|
||||
offset,
|
||||
context,
|
||||
offset: skip,
|
||||
includeMeta: true,
|
||||
signal,
|
||||
});
|
||||
@@ -201,7 +173,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
const noMatchMessage = dedupedParseErrors.length
|
||||
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path`/`glob` or set `lang` before concluding absence."
|
||||
? "No matches found. Parse issues mean the query may be mis-scoped; narrow `path` before concluding absence."
|
||||
: "No matches found";
|
||||
const parseMessage = dedupedParseErrors.length
|
||||
? `\n${formatParseErrors(dedupedParseErrors).join("\n")}`
|
||||
@@ -308,13 +280,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
|
||||
// =============================================================================
|
||||
|
||||
interface AstGrepRenderArgs {
|
||||
pat?: string[];
|
||||
lang?: string;
|
||||
pat?: string;
|
||||
path?: string;
|
||||
sel?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
context?: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
const COLLAPSED_MATCH_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
|
||||
@@ -323,15 +291,10 @@ export const astGrepToolRenderer = {
|
||||
inline: true,
|
||||
renderCall(args: AstGrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const meta: string[] = [];
|
||||
if (args.lang) meta.push(`lang:${args.lang}`);
|
||||
if (args.path) meta.push(`in ${args.path}`);
|
||||
if (args.sel) meta.push("selector");
|
||||
if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
|
||||
if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
|
||||
if (args.context !== undefined) meta.push(`context:${args.context}`);
|
||||
if (args.pat && args.pat.length > 1) meta.push(`${args.pat.length} patterns`);
|
||||
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
|
||||
|
||||
const description = args.pat?.length === 1 ? args.pat[0] : args.pat ? `${args.pat.length} patterns` : "?";
|
||||
const description = args.pat ?? "?";
|
||||
const text = renderStatusLine({ icon: "pending", title: "AST Grep", description, meta }, uiTheme);
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
@@ -355,19 +318,14 @@ export const astGrepToolRenderer = {
|
||||
const limitReached = details?.limitReached ?? false;
|
||||
|
||||
if (matchCount === 0) {
|
||||
const description = args?.pat?.length === 1 ? args.pat[0] : undefined;
|
||||
const description = args?.pat;
|
||||
const meta = ["0 matches"];
|
||||
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
|
||||
if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
|
||||
const header = renderStatusLine({ icon: "warning", title: "AST Grep", description, meta }, uiTheme);
|
||||
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
|
||||
if (details?.parseErrors?.length) {
|
||||
lines.push(
|
||||
uiTheme.fg(
|
||||
"warning",
|
||||
"Query may be mis-scoped; narrow `path`/`glob` or set `lang` before concluding absence",
|
||||
),
|
||||
);
|
||||
lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `path` before concluding absence"));
|
||||
const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
|
||||
for (const err of capped) {
|
||||
lines.push(uiTheme.fg("warning", ` - ${err}`));
|
||||
@@ -384,7 +342,7 @@ export const astGrepToolRenderer = {
|
||||
if (details?.scopePath) meta.push(`in ${details.scopePath}`);
|
||||
meta.push(`searched ${filesSearched}`);
|
||||
if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
|
||||
const description = args?.pat?.length === 1 ? args.pat[0] : undefined;
|
||||
const description = args?.pat;
|
||||
const header = renderStatusLine(
|
||||
{ icon: limitReached ? "warning" : "success", title: "AST Grep", description, meta },
|
||||
uiTheme,
|
||||
|
||||
@@ -19,7 +19,6 @@ import { createFileRecorder } from "./file-recorder";
|
||||
import { formatMatchLine } from "./match-line-format";
|
||||
import { formatFullOutputReference, type OutputMeta } from "./output-meta";
|
||||
import {
|
||||
combineSearchGlobs,
|
||||
hasGlobPathChars,
|
||||
normalizePathLikeInput,
|
||||
parseSearchPath,
|
||||
@@ -38,16 +37,13 @@ import { toolResult } from "./tool-result";
|
||||
|
||||
const grepSchema = Type.Object({
|
||||
pattern: Type.String({ description: "regex pattern", examples: ["function\\s+\\w+", "TODO"] }),
|
||||
path: Type.Optional(Type.String({ description: "path to search", examples: ["src/", "src/foo.ts"] })),
|
||||
glob: Type.Optional(Type.String({ description: "glob filter", examples: ["*.js"] })),
|
||||
type: Type.Optional(Type.String({ description: "file type filter", examples: ["js", "py", "rust"] })),
|
||||
path: Type.String({
|
||||
description: "file, directory, glob, comma-separated paths, or internal URL to search",
|
||||
examples: ["src/", "src/foo.ts", "src/**/*.ts"],
|
||||
}),
|
||||
i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })),
|
||||
pre: Type.Optional(Type.Number({ description: "lines before matches" })),
|
||||
post: Type.Optional(Type.Number({ description: "lines after matches" })),
|
||||
multiline: Type.Optional(Type.Boolean({ description: "multiline matching" })),
|
||||
gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })),
|
||||
limit: Type.Optional(Type.Number({ description: "max matches", default: 20 })),
|
||||
offset: Type.Optional(Type.Number({ description: "skip first n matches", default: 0 })),
|
||||
skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
|
||||
});
|
||||
|
||||
export type GrepToolInput = Static<typeof grepSchema>;
|
||||
@@ -98,7 +94,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
_onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
|
||||
_toolContext?: AgentToolContext,
|
||||
): Promise<AgentToolResult<GrepToolDetails>> {
|
||||
const { pattern, path: searchDir, glob, type, i, gitignore, pre, post, multiline, limit, offset } = params;
|
||||
const { pattern, path: searchDir, i, gitignore, skip } = params;
|
||||
|
||||
return untilAborted(signal, async () => {
|
||||
const normalizedPattern = pattern.trim();
|
||||
@@ -107,25 +103,16 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
throw new ToolError("Pattern must not be empty");
|
||||
}
|
||||
|
||||
const normalizedOffset = offset === undefined ? 0 : Number.isFinite(offset) ? Math.floor(offset) : Number.NaN;
|
||||
if (normalizedOffset < 0 || !Number.isFinite(normalizedOffset)) {
|
||||
throw new ToolError("Offset must be a non-negative number");
|
||||
const normalizedSkip = skip === undefined ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN;
|
||||
if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
|
||||
throw new ToolError("Skip must be a non-negative number");
|
||||
}
|
||||
|
||||
const rawLimit = limit === undefined ? undefined : Number.isFinite(limit) ? Math.floor(limit) : Number.NaN;
|
||||
if (rawLimit !== undefined && (!Number.isFinite(rawLimit) || rawLimit < 0)) {
|
||||
throw new ToolError("Limit must be a non-negative number");
|
||||
}
|
||||
const normalizedLimit = rawLimit !== undefined && rawLimit > 0 ? rawLimit : undefined;
|
||||
|
||||
const defaultContextBefore = this.session.settings.get("grep.contextBefore");
|
||||
const defaultContextAfter = this.session.settings.get("grep.contextAfter");
|
||||
const normalizedContextBefore = pre ?? defaultContextBefore;
|
||||
const normalizedContextAfter = post ?? defaultContextAfter;
|
||||
const normalizedContextBefore = this.session.settings.get("grep.contextBefore");
|
||||
const normalizedContextAfter = this.session.settings.get("grep.contextAfter");
|
||||
const ignoreCase = i ?? false;
|
||||
const useGitignore = gitignore ?? true;
|
||||
const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
|
||||
const effectiveMultiline = multiline ?? patternHasNewline;
|
||||
const effectiveMultiline = patternHasNewline;
|
||||
|
||||
const useHashLines = resolveFileDisplayMode(this.session).hashLines;
|
||||
const formatScopePath = (targetPath: string): string => {
|
||||
@@ -135,41 +122,36 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
let searchPath: string;
|
||||
let scopePath: string;
|
||||
let exactFilePaths: string[] | undefined;
|
||||
let globFilter = glob ? normalizePathLikeInput(glob) || undefined : undefined;
|
||||
const internalRouter = this.session.internalRouter;
|
||||
if (searchDir?.trim()) {
|
||||
const rawPath = normalizePathLikeInput(searchDir);
|
||||
if (internalRouter?.canHandle(rawPath)) {
|
||||
if (hasGlobPathChars(rawPath)) {
|
||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||
}
|
||||
const resource = await internalRouter.resolve(rawPath);
|
||||
if (!resource.sourcePath) {
|
||||
throw new ToolError(`Cannot grep internal URL without a backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
|
||||
if (multiSearchPath) {
|
||||
searchPath = multiSearchPath.basePath;
|
||||
globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
|
||||
exactFilePaths = multiSearchPath.exactFilePaths;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
if (parsedPath.glob) {
|
||||
globFilter = combineSearchGlobs(parsedPath.glob, globFilter);
|
||||
}
|
||||
scopePath = formatScopePath(searchPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
searchPath = resolveToCwd(".", this.session.cwd);
|
||||
scopePath = ".";
|
||||
let globFilter: string | undefined;
|
||||
const rawPath = normalizePathLikeInput(searchDir);
|
||||
if (rawPath.length === 0) {
|
||||
throw new ToolError("`path` must be a non-empty path or glob");
|
||||
}
|
||||
const internalRouter = this.session.internalRouter;
|
||||
if (internalRouter?.canHandle(rawPath)) {
|
||||
if (hasGlobPathChars(rawPath)) {
|
||||
throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
|
||||
}
|
||||
const resource = await internalRouter.resolve(rawPath);
|
||||
if (!resource.sourcePath) {
|
||||
throw new ToolError(`Cannot grep internal URL without a backing file: ${rawPath}`);
|
||||
}
|
||||
searchPath = resource.sourcePath;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
} else {
|
||||
const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
|
||||
if (multiSearchPath) {
|
||||
searchPath = multiSearchPath.basePath;
|
||||
globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
|
||||
exactFilePaths = multiSearchPath.exactFilePaths;
|
||||
scopePath = multiSearchPath.scopePath;
|
||||
} else {
|
||||
const parsedPath = parseSearchPath(rawPath);
|
||||
searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
|
||||
globFilter = parsedPath.glob;
|
||||
scopePath = formatScopePath(searchPath);
|
||||
}
|
||||
}
|
||||
|
||||
let isDirectory: boolean;
|
||||
try {
|
||||
const stat = await Bun.file(searchPath).stat();
|
||||
@@ -180,7 +162,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
}
|
||||
|
||||
const effectiveOutputMode = GrepOutputMode.Content;
|
||||
const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT;
|
||||
const effectiveLimit = DEFAULT_MATCH_LIMIT;
|
||||
const internalLimit = Math.min(effectiveLimit * 5, 2000);
|
||||
|
||||
// Run grep
|
||||
@@ -194,7 +176,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
{
|
||||
pattern: normalizedPattern,
|
||||
path: exactFilePath,
|
||||
type: type?.trim() || undefined,
|
||||
ignoreCase,
|
||||
multiline: effectiveMultiline,
|
||||
hidden: true,
|
||||
@@ -211,7 +192,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
const relativeFilePath = path.relative(searchPath, exactFilePath).replace(/\\/g, "/");
|
||||
matches.push(...fileResult.matches.map(match => ({ ...match, path: relativeFilePath })));
|
||||
}
|
||||
const offsetMatches = matches.slice(normalizedOffset);
|
||||
const offsetMatches = matches.slice(normalizedSkip);
|
||||
result = {
|
||||
matches: offsetMatches,
|
||||
totalMatches: offsetMatches.length,
|
||||
@@ -225,14 +206,13 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
pattern: normalizedPattern,
|
||||
path: searchPath,
|
||||
glob: globFilter,
|
||||
type: type?.trim() || undefined,
|
||||
ignoreCase,
|
||||
multiline: effectiveMultiline,
|
||||
hidden: true,
|
||||
gitignore: useGitignore,
|
||||
cache: false,
|
||||
maxCount: internalLimit,
|
||||
offset: normalizedOffset > 0 ? normalizedOffset : undefined,
|
||||
offset: normalizedSkip > 0 ? normalizedSkip : undefined,
|
||||
contextBefore: normalizedContextBefore,
|
||||
contextAfter: normalizedContextAfter,
|
||||
maxColumns: DEFAULT_MAX_COLUMN,
|
||||
@@ -407,6 +387,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
outputLines.push(...renderChunkedMatchesForFile(relativePath));
|
||||
}
|
||||
}
|
||||
if (matchLimitReached || result.limitReached) {
|
||||
outputLines.push("", "Result limit reached; narrow path.");
|
||||
}
|
||||
const rawOutput = outputLines.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated);
|
||||
@@ -424,12 +407,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
resultLimitReached: result.limitReached ? internalLimit : undefined,
|
||||
};
|
||||
if (truncation.truncated) details.truncation = truncation;
|
||||
const resultBuilder = toolResult(details)
|
||||
.text(truncation.content)
|
||||
.limits({
|
||||
matchLimit: matchLimitReached ? effectiveLimit : undefined,
|
||||
resultLimit: result.limitReached ? internalLimit : undefined,
|
||||
});
|
||||
const resultBuilder = toolResult(details).text(truncation.content);
|
||||
if (truncation.truncated) {
|
||||
resultBuilder.truncation(truncation, { direction: "head" });
|
||||
}
|
||||
@@ -524,6 +502,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
if (hasContextLines && outputLines.length > 0) {
|
||||
outputLines.unshift("[grep] match lines use ':'; context lines use '-'.");
|
||||
}
|
||||
if (matchLimitReached || result.limitReached) {
|
||||
outputLines.push("", "Result limit reached; narrow path.");
|
||||
}
|
||||
const rawOutput = outputLines.join("\n");
|
||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||
const output = truncation.content;
|
||||
@@ -546,11 +527,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
if (linesTruncated) details.linesTruncated = true;
|
||||
const resultBuilder = toolResult(details)
|
||||
.text(output)
|
||||
.limits({
|
||||
matchLimit: matchLimitReached ? effectiveLimit : undefined,
|
||||
resultLimit: result.limitReached ? internalLimit : undefined,
|
||||
columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined,
|
||||
});
|
||||
.limits({ columnMax: linesTruncated ? DEFAULT_MAX_COLUMN : undefined });
|
||||
if (truncation.truncated) {
|
||||
resultBuilder.truncation(truncation, { direction: "head" });
|
||||
}
|
||||
@@ -566,15 +543,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
interface GrepRenderArgs {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
type?: string;
|
||||
i?: boolean;
|
||||
gitignore?: boolean;
|
||||
pre?: number;
|
||||
post?: number;
|
||||
multiline?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
|
||||
@@ -584,19 +555,9 @@ export const grepToolRenderer = {
|
||||
renderCall(args: GrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const meta: string[] = [];
|
||||
if (args.path) meta.push(`in ${args.path}`);
|
||||
if (args.glob) meta.push(`glob:${args.glob}`);
|
||||
if (args.type) meta.push(`type:${args.type}`);
|
||||
if (args.i) meta.push("case:insensitive");
|
||||
if (args.gitignore === false) meta.push("gitignore:false");
|
||||
if (args.pre !== undefined && args.pre > 0) {
|
||||
meta.push(`pre:${args.pre}`);
|
||||
}
|
||||
if (args.post !== undefined && args.post > 0) {
|
||||
meta.push(`post:${args.post}`);
|
||||
}
|
||||
if (args.multiline) meta.push("multiline");
|
||||
if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
|
||||
if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
|
||||
if (args.skip !== undefined && args.skip > 0) meta.push(`skip:${args.skip}`);
|
||||
|
||||
const text = renderStatusLine(
|
||||
{ icon: "pending", title: "Grep", description: args.pattern || "?", meta },
|
||||
@@ -708,9 +669,11 @@ export const grepToolRenderer = {
|
||||
}
|
||||
}
|
||||
|
||||
const renderedMatchLimit = details?.matchLimitReached ?? limits?.matchLimit?.reached;
|
||||
const renderedResultLimit = details?.resultLimitReached ?? limits?.resultLimit?.reached;
|
||||
const truncationReasons: string[] = [];
|
||||
if (limits?.matchLimit) truncationReasons.push(`limit ${limits.matchLimit.reached} matches`);
|
||||
if (limits?.resultLimit) truncationReasons.push(`limit ${limits.resultLimit.reached} results`);
|
||||
if (renderedMatchLimit) truncationReasons.push(`first ${renderedMatchLimit} matches`);
|
||||
if (renderedResultLimit) truncationReasons.push(`first ${renderedResultLimit} results`);
|
||||
if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
|
||||
if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`);
|
||||
if (truncation?.artifactId) truncationReasons.push(formatFullOutputReference(truncation.artifactId));
|
||||
|
||||
@@ -1195,7 +1195,7 @@ function b() {
|
||||
expect(output).not.toContain("schema-other.test.ts");
|
||||
expect(result.details?.fileCount).toBe(2);
|
||||
});
|
||||
it("should combine globbing from path and glob parameters", async () => {
|
||||
it("should accept nested wildcard filters in the path parameter", async () => {
|
||||
const packageDir = path.join(testDir, "node_modules", ".bun");
|
||||
const aiDir = path.join(packageDir, "ai@6.0.119+build123", "node_modules", "ai");
|
||||
const nestedDir = path.join(aiDir, "nested");
|
||||
@@ -1207,8 +1207,7 @@ function b() {
|
||||
|
||||
const result = await grepTool.execute("test-call-11-path-and-glob", {
|
||||
pattern: "providerOptions",
|
||||
path: `${packageDir}/ai@6.0.119+*/node_modules/ai`,
|
||||
glob: "**/*.{d.ts,ts}",
|
||||
path: `${packageDir}/ai@6.0.119+*/node_modules/ai/**/*.{d.ts,ts}`,
|
||||
gitignore: false,
|
||||
});
|
||||
|
||||
@@ -1220,17 +1219,16 @@ function b() {
|
||||
expect(result.details?.fileCount).toBe(2);
|
||||
});
|
||||
|
||||
it("should respect global limit and include context lines", async () => {
|
||||
it("should include configured context lines", async () => {
|
||||
const testFile = path.join(testDir, "context.txt");
|
||||
const content = ["before", "match one", "after", "middle", "match two", "after two"].join("\n");
|
||||
fs.writeFileSync(testFile, content);
|
||||
|
||||
const result = await grepTool.execute("test-call-12", {
|
||||
const contextSettings = Settings.isolated({ "grep.contextBefore": 1, "grep.contextAfter": 1 });
|
||||
const contextGrepTool = wrapToolWithMetaNotice(new GrepTool(createTestToolSession(testDir, contextSettings)));
|
||||
const result = await contextGrepTool.execute("test-call-12", {
|
||||
pattern: "match",
|
||||
path: testFile,
|
||||
limit: 1,
|
||||
pre: 1,
|
||||
post: 1,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1238,12 +1236,26 @@ function b() {
|
||||
expect(output).toMatch(/\b1-before/);
|
||||
expect(output).toMatch(/\b2:match one/);
|
||||
expect(output).toMatch(/\b3-after/);
|
||||
expect(output).toContain("[1 matches limit reached. Use limit=2 for more]");
|
||||
// Ensure second match is not present
|
||||
expect(output).not.toContain("match two");
|
||||
expect(output).toMatch(/\b5:match two/);
|
||||
});
|
||||
|
||||
it("should group multi-file matches and distribute limit with round-robin", async () => {
|
||||
it("should skip matches with the skip parameter", async () => {
|
||||
const testFile = path.join(testDir, "skip.txt");
|
||||
fs.writeFileSync(testFile, ["needle one", "needle two", "needle three"].join("\n"));
|
||||
|
||||
const result = await grepTool.execute("test-call-12-skip", {
|
||||
pattern: "needle",
|
||||
path: testFile,
|
||||
skip: 1,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).not.toContain("needle one");
|
||||
expect(output).toContain("needle two");
|
||||
expect(output).toContain("needle three");
|
||||
});
|
||||
|
||||
it("should group multi-file matches", async () => {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
fs.writeFileSync(path.join(testDir, `file-${i}.txt`), `needle in file ${i}\nextra needle ${i}`);
|
||||
}
|
||||
@@ -1252,7 +1264,6 @@ function b() {
|
||||
const result = await grepTool.execute("test-call-13-round-robin", {
|
||||
pattern: "needle",
|
||||
path: testDir,
|
||||
limit: 4,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1261,19 +1272,18 @@ function b() {
|
||||
expect(output).toContain("# file-3.txt");
|
||||
expect(output).toContain("# dominant.txt");
|
||||
expect(output).not.toContain("# .");
|
||||
expect(output).toContain("[4 matches limit reached. Use limit=8 for more]");
|
||||
expect(output).not.toContain("Result limit reached");
|
||||
expect(result.details?.fileCount).toBe(4);
|
||||
expect(result.details?.matchCount).toBe(4);
|
||||
expect(result.details?.matchCount).toBe(10);
|
||||
});
|
||||
|
||||
it("should not repeat file headings when round-robin selects multiple matches per file", async () => {
|
||||
it("should not repeat file headings for multiple matches per file", async () => {
|
||||
fs.writeFileSync(path.join(testDir, "alpha.txt"), "needle a1\nneedle a2\nneedle a3");
|
||||
fs.writeFileSync(path.join(testDir, "beta.txt"), "needle b1\nneedle b2\nneedle b3");
|
||||
|
||||
const result = await grepTool.execute("test-call-14-grouped-headings", {
|
||||
pattern: "needle",
|
||||
path: testDir,
|
||||
limit: 4,
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
@@ -1283,8 +1293,8 @@ function b() {
|
||||
expect(betaHeadings).toBe(1);
|
||||
expect(result.details?.fileMatches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "alpha.txt", count: 2 }),
|
||||
expect.objectContaining({ path: "beta.txt", count: 2 }),
|
||||
expect.objectContaining({ path: "alpha.txt", count: 3 }),
|
||||
expect.objectContaining({ path: "beta.txt", count: 3 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1368,7 +1378,7 @@ function b() {
|
||||
expect(result.details?.fileCount).toBe(1);
|
||||
expect(result.details?.matchCount).toBe(1);
|
||||
});
|
||||
it("should apply default limit of 20 when limit is not provided", async () => {
|
||||
it("should apply the fixed default match cap", async () => {
|
||||
const lines = Array.from({ length: 60 }, (_, i) => `needle ${i + 1}`);
|
||||
fs.writeFileSync(path.join(testDir, "default-limit.txt"), lines.join("\n"));
|
||||
|
||||
@@ -1378,7 +1388,7 @@ function b() {
|
||||
});
|
||||
|
||||
const output = getTextOutput(result);
|
||||
expect(output).toContain("[20 matches limit reached. Use limit=40 for more]");
|
||||
expect(output).toContain("Result limit reached; narrow path.");
|
||||
expect(result.details?.matchCount).toBe(20);
|
||||
expect(result.details?.matchLimitReached).toBe(20);
|
||||
});
|
||||
|
||||
@@ -72,7 +72,6 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const result = await tool!.execute("ast-edit-test", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
lang: "typescript",
|
||||
path: filePath,
|
||||
});
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -109,7 +108,6 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const previewResult = await tool!.execute("ast-edit-preview", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
lang: "typescript",
|
||||
path: filePath,
|
||||
});
|
||||
expect(previewResult.details).toBeDefined();
|
||||
@@ -155,7 +153,6 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const previewResult = await tool!.execute("ast-edit-preview", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
lang: "typescript",
|
||||
path: filePath,
|
||||
});
|
||||
expect((previewResult.details as { totalReplacements?: number } | undefined)?.totalReplacements).toBe(1);
|
||||
@@ -206,9 +203,7 @@ describe("ast_edit tool schema", () => {
|
||||
|
||||
const previewResult = await tool!.execute("ast-edit-glob", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
lang: "typescript",
|
||||
path: `${packagesDir}/pkg-*/src`,
|
||||
glob: "**/*.ts",
|
||||
path: `${packagesDir}/pkg-*/src/**/*.ts`,
|
||||
});
|
||||
|
||||
const text = previewResult.content.find(content => content.type === "text")?.text ?? "";
|
||||
|
||||
@@ -17,7 +17,7 @@ function createTestSession(cwd = "/tmp/test", overrides: Partial<ToolSession> =
|
||||
}
|
||||
|
||||
describe("ast_grep parse errors", () => {
|
||||
it("collapses per-pattern parse errors for the same file", async () => {
|
||||
it("reports parse errors for the searched file", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-grep-parse-"));
|
||||
try {
|
||||
const filePath = path.join(tempDir, "broken.ts");
|
||||
@@ -28,8 +28,7 @@ describe("ast_grep parse errors", () => {
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool!.execute("ast-grep-parse", {
|
||||
pat: ["someUnlikelyCall($A)", "anotherUnlikelyCall($A)"],
|
||||
lang: "typescript",
|
||||
pat: "someUnlikelyCall($A)",
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
@@ -42,7 +41,6 @@ describe("ast_grep parse errors", () => {
|
||||
expect(details?.parseErrors).toHaveLength(1);
|
||||
expect(details?.parseErrors?.[0]).toContain("broken.ts: parse error (syntax tree contains error nodes)");
|
||||
expect(details?.parseErrors?.[0]).not.toContain("someUnlikelyCall($A):");
|
||||
expect(details?.parseErrors?.[0]).not.toContain("anotherUnlikelyCall($A):");
|
||||
expect(text.match(/parse error \(syntax tree contains error nodes\)/g)?.length ?? 0).toBe(1);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
@@ -65,11 +63,8 @@ describe("ast_grep parse errors", () => {
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool!.execute("ast-grep-glob", {
|
||||
pat: ["providerOptions"],
|
||||
sel: "identifier",
|
||||
lang: "typescript",
|
||||
path: `${packagesDir}/pkg-*/src`,
|
||||
glob: "**/*.ts",
|
||||
pat: "providerOptions",
|
||||
path: `${packagesDir}/pkg-*/src/**/*.ts`,
|
||||
});
|
||||
|
||||
const text = result.content.find(content => content.type === "text")?.text ?? "";
|
||||
@@ -100,9 +95,7 @@ describe("ast_grep parse errors", () => {
|
||||
expect(tool).toBeDefined();
|
||||
|
||||
const result = await tool!.execute("ast-grep-tlaplus", {
|
||||
pat: ["Inc"],
|
||||
sel: "identifier",
|
||||
lang: "pluscal",
|
||||
pat: "Inc",
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("grepToolRenderer", () => {
|
||||
const bodyLines = renderedLines.slice(1);
|
||||
|
||||
expect(bodyLines).toHaveLength(6);
|
||||
expect(bodyLines.at(-1)).toContain("truncated: limit 6 matches");
|
||||
expect(bodyLines.at(-1)).toContain("truncated: first 6 matches");
|
||||
expect(bodyLines.some(line => line.includes("1 more match"))).toBe(true);
|
||||
expect(bodyLines.some(line => line.includes("gamma:1"))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -116,11 +116,8 @@ describe("tool path root alias", () => {
|
||||
if (!tool) throw new Error("Missing ast_grep tool");
|
||||
|
||||
const result = await tool.execute("ast-grep-root-alias", {
|
||||
pat: ["rootAliasSymbol"],
|
||||
sel: "identifier",
|
||||
lang: "typescript",
|
||||
path: "/",
|
||||
glob: "**/*.ts",
|
||||
pat: "rootAliasSymbol",
|
||||
path: "/**/*.ts",
|
||||
});
|
||||
const details = result.details as { scopePath?: string } | undefined;
|
||||
|
||||
@@ -143,9 +140,7 @@ describe("tool path root alias", () => {
|
||||
|
||||
const preview = await tool.execute("ast-edit-root-alias", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
lang: "typescript",
|
||||
path: "/",
|
||||
glob: "**/*.ts",
|
||||
path: "/**/*.ts",
|
||||
});
|
||||
const details = preview.details as { scopePath?: string; totalReplacements?: number } | undefined;
|
||||
|
||||
|
||||
@@ -134,11 +134,8 @@ describe("search tool path lists", () => {
|
||||
if (!tool) throw new Error("Missing ast_grep tool");
|
||||
|
||||
const result = await tool.execute("ast-grep-quoted-path", {
|
||||
pat: ["providerOptions"],
|
||||
sel: "identifier",
|
||||
lang: "typescript",
|
||||
path: '"packages/"',
|
||||
glob: '"**/*.ts"',
|
||||
pat: "providerOptions",
|
||||
path: '"packages/**/*.ts"',
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -156,11 +153,8 @@ describe("search tool path lists", () => {
|
||||
if (!tool) throw new Error("Missing ast_grep tool");
|
||||
|
||||
const result = await tool.execute("ast-grep-comma-paths", {
|
||||
pat: ["providerOptions"],
|
||||
sel: "identifier",
|
||||
lang: "typescript",
|
||||
path: "apps/,packages/,phases/",
|
||||
glob: "**/*.ts",
|
||||
pat: "providerOptions",
|
||||
path: "apps/**/*.ts,packages/**/*.ts,phases/**/*.ts",
|
||||
});
|
||||
const text = getText(result);
|
||||
const details = result.details as { fileCount?: number; scopePath?: string } | undefined;
|
||||
@@ -171,7 +165,7 @@ describe("search tool path lists", () => {
|
||||
expect(text).toContain("## └─ ast.ts");
|
||||
expect(text).not.toContain("# other");
|
||||
expect(details?.fileCount).toBe(3);
|
||||
expect(details?.scopePath).toBe("apps/, packages/, phases/");
|
||||
expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts");
|
||||
});
|
||||
|
||||
it("ast_edit applies across a space-separated path list", async () => {
|
||||
@@ -189,9 +183,7 @@ describe("search tool path lists", () => {
|
||||
|
||||
const preview = await tool.execute("ast-edit-space-paths", {
|
||||
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
||||
lang: "typescript",
|
||||
path: "apps/ packages/ phases/",
|
||||
glob: "**/*.ts",
|
||||
path: "apps/**/*.ts packages/**/*.ts phases/**/*.ts",
|
||||
});
|
||||
const text = getText(preview);
|
||||
const details = preview.details as { totalReplacements?: number; scopePath?: string } | undefined;
|
||||
@@ -202,7 +194,7 @@ describe("search tool path lists", () => {
|
||||
expect(text).toContain("## └─ ast.ts (1 replacement)");
|
||||
expect(text).not.toContain("# other");
|
||||
expect(details?.totalReplacements).toBe(3);
|
||||
expect(details?.scopePath).toBe("apps/, packages/, phases/");
|
||||
expect(details?.scopePath).toBe("apps/**/*.ts, packages/**/*.ts, phases/**/*.ts");
|
||||
|
||||
queue.nextToolChoice();
|
||||
const invoker = queue.peekInFlightInvoker();
|
||||
@@ -319,7 +311,6 @@ describe("search tool path lists", () => {
|
||||
const result = await tool.execute("grep-no-empty-headings", {
|
||||
pattern: "shared-needle",
|
||||
path: "apps/,packages/,phases/",
|
||||
limit: 2,
|
||||
});
|
||||
const lines = getText(result).split("\n");
|
||||
|
||||
@@ -338,7 +329,11 @@ describe("search tool path lists", () => {
|
||||
it("grep explains context-line gutters without changing match and context separators", async () => {
|
||||
await Bun.write(path.join(tempDir, "context.txt"), "#if FLAG\nneedle\n#endif\n");
|
||||
|
||||
const tools = await createTools(createTestSession(tempDir));
|
||||
const tools = await createTools(
|
||||
createTestSession(tempDir, {
|
||||
settings: Settings.isolated({ "grep.contextBefore": 1, "grep.contextAfter": 1 }),
|
||||
}),
|
||||
);
|
||||
const tool = tools.find(entry => entry.name === "grep");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("Missing grep tool");
|
||||
@@ -346,8 +341,6 @@ describe("search tool path lists", () => {
|
||||
const result = await tool.execute("grep-context-label", {
|
||||
pattern: "needle",
|
||||
path: "context.txt",
|
||||
pre: 1,
|
||||
post: 1,
|
||||
});
|
||||
const text = getText(result);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user