mirror of
https://github.com/PawanOsman/ChatGPT.git
synced 2026-07-18 08:05:57 +02:00
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:
+554
-383
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user