fix(coding-agent): harden find cancellation and grep match formatting closes #3148
This commit is contained in:
@@ -6,12 +6,15 @@
|
|||||||
|
|
||||||
- Added `--no-context-files` (`-nc`) to disable `AGENTS.md` and `CLAUDE.md` context file discovery and loading ([#3253](https://github.com/badlogic/pi-mono/issues/3253))
|
- Added `--no-context-files` (`-nc`) to disable `AGENTS.md` and `CLAUDE.md` context file discovery and loading ([#3253](https://github.com/badlogic/pi-mono/issues/3253))
|
||||||
- Exported `loadProjectContextFiles()` as a standalone utility so extensions can discover project context files without instantiating a full `DefaultResourceLoader` ([#3142](https://github.com/badlogic/pi-mono/issues/3142))
|
- Exported `loadProjectContextFiles()` as a standalone utility so extensions can discover project context files without instantiating a full `DefaultResourceLoader` ([#3142](https://github.com/badlogic/pi-mono/issues/3142))
|
||||||
|
- Added `after_provider_response` extension hook so extensions can inspect provider HTTP status codes and headers after each provider response is received and before stream consumption begins ([#3128](https://github.com/badlogic/pi-mono/issues/3128))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed flaky `edit-tool-no-full-redraw` TUI tests by waiting for asynchronous preview and preflight error rendering instead of relying on fixed render ticks.
|
- Fixed flaky `edit-tool-no-full-redraw` TUI tests by waiting for asynchronous preview and preflight error rendering instead of relying on fixed render ticks.
|
||||||
- Fixed `kimi-coding` default model selection to use `kimi-for-coding` instead of `kimi-k2-thinking` ([#3242](https://github.com/badlogic/pi-mono/issues/3242))
|
- Fixed `kimi-coding` default model selection to use `kimi-for-coding` instead of `kimi-k2-thinking` ([#3242](https://github.com/badlogic/pi-mono/issues/3242))
|
||||||
- Fixed `ctrl+z` on native Windows to avoid crashing interactive mode, disable the default suspend binding there, and show a status message when suspend is invoked manually ([#3191](https://github.com/badlogic/pi-mono/issues/3191))
|
- Fixed `ctrl+z` on native Windows to avoid crashing interactive mode, disable the default suspend binding there, and show a status message when suspend is invoked manually ([#3191](https://github.com/badlogic/pi-mono/issues/3191))
|
||||||
|
- Fixed `find` tool cancellation and responsiveness on broad searches by making `.gitignore` discovery and `fd` execution fully abort-aware and non-blocking ([#3148](https://github.com/badlogic/pi-mono/issues/3148))
|
||||||
|
- Fixed `grep` broad-search stalls when `context=0` by formatting match lines from ripgrep JSON output instead of doing synchronous per-match file reads ([#3205](https://github.com/badlogic/pi-mono/issues/3205))
|
||||||
|
|
||||||
## [0.67.3] - 2026-04-15
|
## [0.67.3] - 2026-04-15
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { createInterface } from "node:readline";
|
||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import { spawnSync } from "child_process";
|
import { spawn } from "child_process";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { globSync } from "glob";
|
import { glob } from "glob";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||||
import { ensureTool } from "../../utils/tools-manager.js";
|
import { ensureTool } from "../../utils/tools-manager.js";
|
||||||
@@ -133,7 +134,19 @@ export function createFindToolDefinition(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onAbort = () => reject(new Error("Operation aborted"));
|
let settled = false;
|
||||||
|
let stopChild: (() => void) | undefined;
|
||||||
|
const settle = (fn: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
stopChild = undefined;
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
stopChild?.();
|
||||||
|
settle(() => reject(new Error("Operation aborted")));
|
||||||
|
};
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -145,19 +158,28 @@ export function createFindToolDefinition(
|
|||||||
// If custom operations provide glob(), use that instead of fd.
|
// If custom operations provide glob(), use that instead of fd.
|
||||||
if (customOps?.glob) {
|
if (customOps?.glob) {
|
||||||
if (!(await ops.exists(searchPath))) {
|
if (!(await ops.exists(searchPath))) {
|
||||||
reject(new Error(`Path not found: ${searchPath}`));
|
settle(() => reject(new Error(`Path not found: ${searchPath}`)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
settle(() => reject(new Error("Operation aborted")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const results = await ops.glob(pattern, searchPath, {
|
const results = await ops.glob(pattern, searchPath, {
|
||||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||||
limit: effectiveLimit,
|
limit: effectiveLimit,
|
||||||
});
|
});
|
||||||
signal?.removeEventListener("abort", onAbort);
|
if (signal?.aborted) {
|
||||||
|
settle(() => reject(new Error("Operation aborted")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
resolve({
|
settle(() =>
|
||||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
resolve({
|
||||||
details: undefined,
|
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||||
});
|
details: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,17 +205,23 @@ export function createFindToolDefinition(
|
|||||||
if (notices.length > 0) {
|
if (notices.length > 0) {
|
||||||
resultOutput += `\n\n[${notices.join(". ")}]`;
|
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||||
}
|
}
|
||||||
resolve({
|
settle(() =>
|
||||||
content: [{ type: "text", text: resultOutput }],
|
resolve({
|
||||||
details: Object.keys(details).length > 0 ? details : undefined,
|
content: [{ type: "text", text: resultOutput }],
|
||||||
});
|
details: Object.keys(details).length > 0 ? details : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default implementation uses fd.
|
// Default implementation uses fd.
|
||||||
const fdPath = await ensureTool("fd", true);
|
const fdPath = await ensureTool("fd", true);
|
||||||
|
if (signal?.aborted) {
|
||||||
|
settle(() => reject(new Error("Operation aborted")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!fdPath) {
|
if (!fdPath) {
|
||||||
reject(new Error("fd is not available and could not be downloaded"));
|
settle(() => reject(new Error("fd is not available and could not be downloaded")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,84 +238,128 @@ export function createFindToolDefinition(
|
|||||||
const rootGitignore = path.join(searchPath, ".gitignore");
|
const rootGitignore = path.join(searchPath, ".gitignore");
|
||||||
if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore);
|
if (existsSync(rootGitignore)) gitignoreFiles.add(rootGitignore);
|
||||||
try {
|
try {
|
||||||
const nestedGitignores = globSync("**/.gitignore", {
|
const nestedGitignores = await glob("**/.gitignore", {
|
||||||
cwd: searchPath,
|
cwd: searchPath,
|
||||||
dot: true,
|
dot: true,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
ignore: ["**/node_modules/**", "**/.git/**"],
|
ignore: ["**/node_modules/**", "**/.git/**"],
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
for (const file of nestedGitignores) gitignoreFiles.add(file);
|
for (const file of nestedGitignores) gitignoreFiles.add(file);
|
||||||
} catch {
|
} catch {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
settle(() => reject(new Error("Operation aborted")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
settle(() => reject(new Error("Operation aborted")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
|
for (const gitignorePath of gitignoreFiles) args.push("--ignore-file", gitignorePath);
|
||||||
args.push(pattern, searchPath);
|
args.push(pattern, searchPath);
|
||||||
|
|
||||||
const result = spawnSync(fdPath, args, { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
|
const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
signal?.removeEventListener("abort", onAbort);
|
const rl = createInterface({ input: child.stdout });
|
||||||
if (result.error) {
|
let stderr = "";
|
||||||
reject(new Error(`Failed to run fd: ${result.error.message}`));
|
const lines: string[] = [];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = result.stdout?.trim() || "";
|
stopChild = () => {
|
||||||
if (result.status !== 0) {
|
if (!child.killed) {
|
||||||
const errorMsg = result.stderr?.trim() || `fd exited with code ${result.status}`;
|
child.kill();
|
||||||
if (!output) {
|
}
|
||||||
reject(new Error(errorMsg));
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
rl.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on("line", (line) => {
|
||||||
|
lines.push(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
cleanup();
|
||||||
|
settle(() => reject(new Error(`Failed to run fd: ${error.message}`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
cleanup();
|
||||||
|
if (signal?.aborted) {
|
||||||
|
settle(() => reject(new Error("Operation aborted")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
const output = lines.join("\n");
|
||||||
if (!output) {
|
if (code !== 0) {
|
||||||
resolve({
|
const errorMsg = stderr.trim() || `fd exited with code ${code}`;
|
||||||
content: [{ type: "text", text: "No files found matching pattern" }],
|
if (!output) {
|
||||||
details: undefined,
|
settle(() => reject(new Error(errorMsg)));
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!output) {
|
||||||
|
settle(() =>
|
||||||
|
resolve({
|
||||||
|
content: [{ type: "text", text: "No files found matching pattern" }],
|
||||||
|
details: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativized: string[] = [];
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.replace(/\r$/, "").trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
||||||
|
let relativePath = line;
|
||||||
|
if (line.startsWith(searchPath)) {
|
||||||
|
relativePath = line.slice(searchPath.length + 1);
|
||||||
|
} else {
|
||||||
|
relativePath = path.relative(searchPath, line);
|
||||||
|
}
|
||||||
|
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
|
||||||
|
relativized.push(toPosixPath(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultLimitReached = relativized.length >= effectiveLimit;
|
||||||
|
const rawOutput = relativized.join("\n");
|
||||||
|
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
||||||
|
let resultOutput = truncation.content;
|
||||||
|
const details: FindToolDetails = {};
|
||||||
|
const notices: string[] = [];
|
||||||
|
if (resultLimitReached) {
|
||||||
|
notices.push(
|
||||||
|
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
||||||
|
);
|
||||||
|
details.resultLimitReached = effectiveLimit;
|
||||||
|
}
|
||||||
|
if (truncation.truncated) {
|
||||||
|
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
||||||
|
details.truncation = truncation;
|
||||||
|
}
|
||||||
|
if (notices.length > 0) {
|
||||||
|
resultOutput += `\n\n[${notices.join(". ")}]`;
|
||||||
|
}
|
||||||
|
settle(() =>
|
||||||
|
resolve({
|
||||||
|
content: [{ type: "text", text: resultOutput }],
|
||||||
|
details: Object.keys(details).length > 0 ? details : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
settle(() => reject(new Error("Operation aborted")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const error = e instanceof Error ? e : new Error(String(e));
|
||||||
const lines = output.split("\n");
|
settle(() => reject(error));
|
||||||
const relativized: string[] = [];
|
|
||||||
for (const rawLine of lines) {
|
|
||||||
const line = rawLine.replace(/\r$/, "").trim();
|
|
||||||
if (!line) continue;
|
|
||||||
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
|
||||||
let relativePath = line;
|
|
||||||
if (line.startsWith(searchPath)) {
|
|
||||||
relativePath = line.slice(searchPath.length + 1);
|
|
||||||
} else {
|
|
||||||
relativePath = path.relative(searchPath, line);
|
|
||||||
}
|
|
||||||
if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/";
|
|
||||||
relativized.push(toPosixPath(relativePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultLimitReached = relativized.length >= effectiveLimit;
|
|
||||||
const rawOutput = relativized.join("\n");
|
|
||||||
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
||||||
let resultOutput = truncation.content;
|
|
||||||
const details: FindToolDetails = {};
|
|
||||||
const notices: string[] = [];
|
|
||||||
if (resultLimitReached) {
|
|
||||||
notices.push(
|
|
||||||
`${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
|
|
||||||
);
|
|
||||||
details.resultLimitReached = effectiveLimit;
|
|
||||||
}
|
|
||||||
if (truncation.truncated) {
|
|
||||||
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
|
|
||||||
details.truncation = truncation;
|
|
||||||
}
|
|
||||||
if (notices.length > 0) {
|
|
||||||
resultOutput += `\n\n[${notices.join(". ")}]`;
|
|
||||||
}
|
|
||||||
resolve({
|
|
||||||
content: [{ type: "text", text: resultOutput }],
|
|
||||||
details: Object.keys(details).length > 0 ? details : undefined,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
signal?.removeEventListener("abort", onAbort);
|
|
||||||
reject(e);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export function createGrepToolDefinition(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Collect matches during streaming, then format them after rg exits.
|
// Collect matches during streaming, then format them after rg exits.
|
||||||
const matches: Array<{ filePath: string; lineNumber: number }> = [];
|
const matches: Array<{ filePath: string; lineNumber: number; lineText?: string }> = [];
|
||||||
rl.on("line", (line) => {
|
rl.on("line", (line) => {
|
||||||
if (!line.trim() || matchCount >= effectiveLimit) return;
|
if (!line.trim() || matchCount >= effectiveLimit) return;
|
||||||
let event: any;
|
let event: any;
|
||||||
@@ -280,7 +280,9 @@ export function createGrepToolDefinition(
|
|||||||
matchCount++;
|
matchCount++;
|
||||||
const filePath = event.data?.path?.text;
|
const filePath = event.data?.path?.text;
|
||||||
const lineNumber = event.data?.line_number;
|
const lineNumber = event.data?.line_number;
|
||||||
if (filePath && typeof lineNumber === "number") matches.push({ filePath, lineNumber });
|
const lineText = event.data?.lines?.text;
|
||||||
|
if (filePath && typeof lineNumber === "number")
|
||||||
|
matches.push({ filePath, lineNumber, lineText });
|
||||||
if (matchCount >= effectiveLimit) {
|
if (matchCount >= effectiveLimit) {
|
||||||
matchLimitReached = true;
|
matchLimitReached = true;
|
||||||
stopChild(true);
|
stopChild(true);
|
||||||
@@ -312,8 +314,19 @@ export function createGrepToolDefinition(
|
|||||||
|
|
||||||
// Format matches after streaming finishes so custom readFile() backends can be async.
|
// Format matches after streaming finishes so custom readFile() backends can be async.
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const block = await formatBlock(match.filePath, match.lineNumber);
|
if (contextValue === 0 && match.lineText !== undefined) {
|
||||||
outputLines.push(...block);
|
const relativePath = formatPath(match.filePath);
|
||||||
|
const sanitized = match.lineText
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\r/g, "")
|
||||||
|
.replace(/\n$/, "");
|
||||||
|
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
|
||||||
|
if (wasTruncated) linesTruncated = true;
|
||||||
|
outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`);
|
||||||
|
} else {
|
||||||
|
const block = await formatBlock(match.filePath, match.lineNumber);
|
||||||
|
outputLines.push(...block);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawOutput = outputLines.join("\n");
|
const rawOutput = outputLines.join("\n");
|
||||||
|
|||||||
@@ -557,6 +557,15 @@ describe("Coding Agent Tools", () => {
|
|||||||
expect(output).toContain("kept.txt");
|
expect(output).toContain("kept.txt");
|
||||||
expect(output).not.toContain("ignored.txt");
|
expect(output).not.toContain("ignored.txt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should surface fd glob parse errors", async () => {
|
||||||
|
await expect(
|
||||||
|
findTool.execute("test-call-15", {
|
||||||
|
pattern: "[",
|
||||||
|
path: testDir,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/error parsing glob|fd exited with code 1|fd error/i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ls tool", () => {
|
describe("ls tool", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user