Enhance file reading functionality with improved error handling and timeout management

- Refactored the `readFileTool` to incorporate a timeout mechanism, preventing hangs on network or missing paths.
- Introduced a new `withAbortTimeout` function to manage promise timeouts and abort signals effectively.
- Enhanced error messages for various file read scenarios, providing clearer feedback on issues like missing paths and permission errors.
- Updated the `safePath` function to utilize a new `normalizePathInput` utility for better path normalization.
- Adjusted tool timeout settings in both the agent and UI to ensure consistent timeout behavior across components.
This commit is contained in:
Pawan Osman
2026-07-15 18:24:11 +03:00
parent 01a0da3bc9
commit affe3e1c21
5 changed files with 669 additions and 450 deletions
+554 -383
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -61,7 +61,8 @@ export const TOOL_TIMEOUT_MS: Record<string, number> = {
SemanticSearch: 30_000,
SearchDocs: 25_000,
ListDir: 10_000,
Read: 20_000,
// Inner Read has 3s stat + 12s I/O; outer net must be slightly above.
Read: 15_000,
ReadLints: 15_000,
WebSearch: 20_000,
WebFetch: 25_000,
+74 -39
View File
@@ -11,28 +11,71 @@ import * as vscode from "vscode";
import * as path from "path";
export function getWorkspaceRoot(): string {
const folders = vscode.workspace.workspaceFolders;
if (folders && folders.length > 0) {
return folders[0].uri.fsPath;
}
return process.cwd();
const folders = vscode.workspace.workspaceFolders;
if (folders && folders.length > 0) {
return folders[0].uri.fsPath;
}
return process.cwd();
}
/** Recently viewed files (workspace-relative), most recent first. */
export function getRecentFiles(): string[] {
const root = getWorkspaceRoot();
const out: string[] = [];
for (const tab of vscode.window.tabGroups.all.flatMap((g) => g.tabs)) {
const input = tab.input as { uri?: vscode.Uri } | undefined;
const uri = input?.uri;
if (uri && uri.scheme === "file" && uri.fsPath.startsWith(root)) {
const rel = path.relative(root, uri.fsPath).split(path.sep).join("/");
if (!out.includes(rel)) {
out.push(uri.fsPath);
}
}
}
return out;
const root = getWorkspaceRoot();
const out: string[] = [];
for (const tab of vscode.window.tabGroups.all.flatMap((g) => g.tabs)) {
const input = tab.input as { uri?: vscode.Uri } | undefined;
const uri = input?.uri;
if (uri && uri.scheme === "file" && uri.fsPath.startsWith(root)) {
const rel = path.relative(root, uri.fsPath).split(path.sep).join("/");
if (!out.includes(rel)) {
out.push(uri.fsPath);
}
}
}
return out;
}
/**
* Normalize a model/user path: spaces, quotes, file:// URIs, mixed separators.
* Does not shell-quote — callers that inject into a shell must quote the result.
*/
export function normalizePathInput(rel: string): string {
let s = String(rel ?? "").trim();
// file:///C:/foo%20bar or file://localhost/C:/...
if (/^file:\/\//i.test(s)) {
try {
s = decodeURIComponent(vscode.Uri.parse(s).fsPath);
} catch {
s = s.replace(/^file:\/\/\/?/i, "").replace(/\//g, path.sep);
try {
s = decodeURIComponent(s);
} catch {
/* keep */
}
}
}
// Strip surrounding quotes the model wraps around paths with spaces.
// Also handle nested `"path with spaces"` and smart quotes.
for (let i = 0; i < 3; i++) {
const t = s.trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")) || (t.startsWith("`") && t.endsWith("`")) || (t.startsWith("\u201c") && t.endsWith("\u201d")) || (t.startsWith("\u2018") && t.endsWith("\u2019"))) {
s = t.slice(1, -1).trim();
continue;
}
s = t;
break;
}
// Model sometimes escapes spaces as `\ ` (unix-style).
s = s.replace(/\\ /g, " ");
// Collapse only internal runs of spaces that are clearly accidental? Keep
// real spaces in folder names — do not collapse.
// Normalize separators; path.resolve will also fix mixed ones.
s = s.replace(/\//g, path.sep);
// Drop trailing separators except drive root (C:\).
if (s.length > 3 && (s.endsWith(path.sep) || s.endsWith("/") || s.endsWith("\\"))) {
s = s.replace(/[\\/]+$/, "");
}
return s;
}
/**
@@ -41,25 +84,17 @@ export function getRecentFiles(): string[] {
* quote the result (see shell.ts quotePath).
*/
export function safePath(rel: string): string {
const root = getWorkspaceRoot();
// Normalize user input: trim, unify slashes, drop surrounding quotes the
// model sometimes wraps around paths that contain spaces.
let s = String(rel ?? "").trim();
if (
(s.startsWith('"') && s.endsWith('"')) ||
(s.startsWith("'") && s.endsWith("'"))
) {
s = s.slice(1, -1);
}
s = s.replace(/\//g, path.sep);
const abs = path.isAbsolute(s) ? s : path.join(root, s);
const norm = path.resolve(abs);
const ws = path.resolve(root);
// Case-insensitive root check on Windows (C:\ vs c:\).
const normKey = process.platform === "win32" ? norm.toLowerCase() : norm;
const wsKey = process.platform === "win32" ? ws.toLowerCase() : ws;
if (normKey !== wsKey && !normKey.startsWith(wsKey + path.sep)) {
throw new Error(`path outside workspace: ${rel}`);
}
return norm;
const root = getWorkspaceRoot();
const s = normalizePathInput(rel);
if (!s) throw new Error("empty path");
const abs = path.isAbsolute(s) ? s : path.join(root, s);
const norm = path.resolve(abs);
const ws = path.resolve(root);
// Case-insensitive root check on Windows (C:\ vs c:\).
const normKey = process.platform === "win32" ? norm.toLowerCase() : norm;
const wsKey = process.platform === "win32" ? ws.toLowerCase() : ws;
if (normKey !== wsKey && !normKey.startsWith(wsKey + path.sep)) {
throw new Error(`path outside workspace: ${rel}`);
}
return norm;
}
+38 -26
View File
@@ -231,45 +231,51 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// callId is globally unique; abort tool/subagent and settle the card now.
for (const [cid, s] of this._sessions) {
const a = s.subagentAborts.get(data.callId);
// Always try abort first so hung Read/Shell stop even if card already settled.
if (a) {
try { a(); } catch { /* ignore */ }
s.subagentAborts.delete(data.callId);
}
// Mark card settled immediately so spinner stops even if the worker
// never emits a terminal event (timeout / hung process).
// never emits a terminal event (timeout / hung process / missing path).
let hit = false;
let toolName = "Tool";
const timedOut = data.reason === "timeout";
s.turns = s.turns.map((turn) => {
if (turn.role !== "assistant") return turn;
let changed = false;
const blocks = turn.blocks.map((b) => {
if (b.kind !== "tool" || b.callId !== data.callId) return b;
if (b.status !== "running" && b.subStatus !== "running") return b;
hit = true;
changed = true;
const timedOut = data.reason === "timeout";
const subStatus =
b.name === "Task" || b.name === "task" || b.subStatus
? (timedOut ? ("error" as const) : ("cancelled" as const))
: b.subStatus;
return {
...b,
status: "error" as const,
result: b.result || (timedOut ? `(timeout after ${Math.round((b.timeoutMs || 0) / 1000)}s)` : "(cancelled)"),
subStatus,
};
toolName = b.name;
// Force settle even if already "error" but still showing running UI race.
if (b.status === "running" || b.subStatus === "running" || timedOut) {
hit = true;
changed = true;
const subStatus =
b.name === "Task" || b.name === "task" || b.subStatus
? (timedOut ? ("error" as const) : ("cancelled" as const))
: b.subStatus;
return {
...b,
status: "error" as const,
result:
b.result ||
(timedOut
? `(timeout after ${Math.round((b.timeoutMs || 0) / 1000)}s)`
: "(cancelled)"),
subStatus,
};
}
return b;
});
return changed ? { ...turn, blocks } : turn;
});
if (!hit && !a) continue;
this._persistTurnsNow(cid, s);
// Find tool name for a proper completed event.
let toolName = "Tool";
for (const turn of s.turns) {
if (turn.role !== "assistant") continue;
const b = turn.blocks.find((x) => x.kind === "tool" && x.callId === data.callId);
if (b && b.kind === "tool") { toolName = b.name; break; }
}
const resultMsg = data.reason === "timeout" ? "(timeout)" : "(cancelled)";
const resultMsg = timedOut
? `(timeout after tool budget)`
: "(cancelled)";
// Always push completed so webview spinner dies even if turns map missed.
this._view?.webview.postMessage({
type: "agentEvent",
convId: cid,
@@ -288,7 +294,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
event: {
type: "subagent-event",
callId: data.callId,
event: { type: "run-status", status: data.reason === "timeout" ? "error" : "cancelled" },
event: { type: "run-status", status: timedOut ? "error" : "cancelled" },
},
});
}
@@ -571,7 +577,13 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
const base = folders && folders.length > 0 ? folders[0].uri : undefined;
const isAbsolute = /^([a-zA-Z]:[\\/]|\/)/.test(relPath);
const uri = isAbsolute ? vscode.Uri.file(relPath) : base ? vscode.Uri.joinPath(base, relPath) : vscode.Uri.file(relPath);
const doc = await vscode.workspace.openTextDocument(uri);
// Race open so a missing/network path cannot hang the extension host forever.
const doc = await Promise.race([
vscode.workspace.openTextDocument(uri),
new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error("timed out opening file (path missing or unreachable)")), 5_000),
),
]);
const editor = await vscode.window.showTextDocument(doc, { preview: true });
if (startLine) {
const s = Math.max(0, startLine - 1);
@@ -582,7 +594,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
}
} catch (err: any) {
vscode.window.showErrorMessage(`OpenCursor: Could not open ${relPath}: ${err.message}`);
vscode.window.showErrorMessage(`OpenCursor: Could not open ${relPath}: ${err?.message || err}`);
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ const TOOL_TIMEOUT_DEFAULTS: { name: string; sec: number }[] = [
{ name: "SemanticSearch", sec: 30 },
{ name: "SearchDocs", sec: 25 },
{ name: "ListDir", sec: 10 },
{ name: "Read", sec: 20 },
{ name: "Read", sec: 15 },
{ name: "ReadLints", sec: 15 },
{ name: "WebSearch", sec: 20 },
{ name: "WebFetch", sec: 25 },