Files
PawanOsman-ChatGPT/src/agent/tools/shared.ts
T
Pawan Osman 6b8301a28e Enhance tool timeout management and UI feedback for subagents
- Updated the `runAgent` function to include timeout management for tool calls, allowing for better handling of execution limits and user feedback.
- Introduced `timeoutMs` and `startedAt` properties in the `tool-call-started` event to facilitate countdown displays in the UI.
- Enhanced the UI to show countdown timers for running tools, improving user awareness of execution status.
- Implemented immediate UI updates on tool cancellation or timeout, ensuring a responsive experience even during long-running operations.
- Refactored the `SidebarProvider` to handle subagent cancellations more effectively, providing clearer status updates in the UI.
2026-07-15 17:56:30 +03:00

592 lines
18 KiB
TypeScript

/*
* Copyright (c) 2026 Pawan Osman <https://github.com/PawanOsman>
*
* This file is part of OpenCursor — AI coding agent chat inside VS Code.
* https://github.com/PawanOsman/OpenCursor
*
* Licensed under the MIT License. See LICENSE file in the project root.
*/
import * as fs from "fs/promises";
import * as path from "path";
import { spawn } from "child_process";
import type { ChildProcess } from "child_process";
import type { SubagentRunner, QuestionAsker } from "./types";
// Directories never walked/listed (tools + indexing).
export const IGNORE = new Set([
".git",
"node_modules",
"dist",
"out",
"build",
".next",
".nuxt",
".output",
".turbo",
".cache",
"coverage",
".venv",
"venv",
"__pycache__",
".tox",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"target",
"vendor",
"Pods",
".gradle",
".idea",
".vscode",
"bower_components",
"jspm_packages",
".pnpm-store",
".yarn",
"site-packages",
]);
// ---------------------------------------------------------------------------
// Per-tool hard timeouts (ms). Prevents a hung Grep/Glob/Shell/etc. from
// blocking the agent loop forever. Task/AskQuestion are excluded (own budgets).
// ---------------------------------------------------------------------------
export const TOOL_TIMEOUT_MS: Record<string, number> = {
// Outer safety net: slightly above each tool's own cap so the tool can
// clean up (kill process / mark done) before the loop aborts it.
Shell: 45_000,
AwaitShell: 60_000,
Grep: 20_000,
Glob: 20_000,
FileSearch: 15_000,
SemanticSearch: 30_000,
SearchDocs: 25_000,
ListDir: 10_000,
Read: 20_000,
ReadLints: 15_000,
WebSearch: 20_000,
WebFetch: 25_000,
StrReplace: 20_000,
Write: 20_000,
Delete: 10_000,
EditNotebook: 20_000,
CallMcpTool: 45_000,
FetchMcpResource: 30_000,
ListMcpResources: 15_000,
TodoWrite: 5_000,
TodoRead: 5_000,
WritePlan: 10_000,
SwitchMode: 5_000,
// Foreground Task budget (bg subagents use BG_SUBAGENT_MAX_MS in loop).
Task: 6 * 60_000,
};
/** Default when a tool has no explicit entry. */
export const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
/** Tools that manage their own lifetime (user wait only). Task has a hard budget. */
export const NO_TOOL_TIMEOUT = new Set(["AskQuestion"]);
/** Built-in defaults in seconds (for settings UI). */
export const DEFAULT_TOOL_TIMEOUTS_SEC: Record<string, number> = Object.fromEntries(
Object.entries(TOOL_TIMEOUT_MS).map(([k, v]) => [k, Math.round(v / 1000)]),
);
/** User overrides from settings (tool name → seconds). Empty/missing = built-in default. */
let toolTimeoutOverridesSec: Record<string, number> = {};
/** Apply settings overrides (seconds). Call whenever feature config loads/changes. */
export function setToolTimeoutOverrides(sec: Record<string, number> | undefined): void {
const next: Record<string, number> = {};
if (sec) {
for (const [k, v] of Object.entries(sec)) {
const n = Number(v);
if (Number.isFinite(n) && n > 0) next[k] = Math.floor(n);
}
}
toolTimeoutOverridesSec = next;
}
/**
* Race a tool promise against a hard timeout. On timeout rejects with an Error
* whose message starts with "timeout:" so the loop can surface it cleanly.
* Does not cancel the underlying work by itself — pass a linked AbortSignal
* into the tool when possible (Shell/Grep honor it).
* Always settles (never hangs) even if `p` never resolves.
*/
/**
* Race a tool promise against a hard timeout.
* On timeout: call `onTimeout` first (abort/kill), then reject immediately so
* the loop can settle UI without waiting for the underlying work.
* Late resolve/reject of `p` is ignored (no unhandled rejection).
*/
export function withToolTimeout<T>(
p: Promise<T>,
ms: number,
label: string,
onTimeout?: () => void,
): Promise<T> {
// ms <= 0: no outer race (AskQuestion manages its own lifetime).
if (!ms || ms <= 0) {
return Promise.resolve(p).catch((e) => {
throw e instanceof Error ? e : new Error(String(e));
});
}
const limit = ms;
return new Promise<T>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
try { onTimeout?.(); } catch { /* ignore */ }
reject(new Error(`timeout: ${label} exceeded ${Math.round(limit / 1000)}s`));
}, limit);
Promise.resolve(p).then(
(v) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(v);
},
(e) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
},
);
});
}
/** Resolve the hard timeout for a tool name (0 = none). Honors settings overrides. */
export function toolTimeoutMs(name: string): number {
if (NO_TOOL_TIMEOUT.has(name)) return 0;
const overrideSec = toolTimeoutOverridesSec[name];
if (overrideSec != null && overrideSec > 0) return overrideSec * 1000;
return TOOL_TIMEOUT_MS[name] ?? DEFAULT_TOOL_TIMEOUT_MS;
}
// Stopwords for the keyword-based SemanticSearch fallback.
export const STOP = new Set([
"where", "what", "which", "does", "with", "this", "that", "have", "from",
"into", "when", "how", "the", "and", "for", "are", "work", "works", "handle", "handled",
]);
// ---------------------------------------------------------------------------
// Diff helpers
// ---------------------------------------------------------------------------
/** Minimal LCS line diff, emitting only changed regions plus a little context. */
export function makeDiff(_filePath: string, before: string, after: string): string {
const a = before.split("\n");
const b = after.split("\n");
const n = a.length;
const m = b.length;
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
type Op = { t: " " | "+" | "-"; line: string };
const ops: Op[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ t: " ", line: a[i] });
i++;
j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
ops.push({ t: "-", line: a[i] });
i++;
} else {
ops.push({ t: "+", line: b[j] });
j++;
}
}
while (i < n) ops.push({ t: "-", line: a[i++] });
while (j < m) ops.push({ t: "+", line: b[j++] });
const CONTEXT = 3;
const keep = new Array(ops.length).fill(false);
for (let k = 0; k < ops.length; k++) {
if (ops[k].t !== " ") {
for (let x = Math.max(0, k - CONTEXT); x <= Math.min(ops.length - 1, k + CONTEXT); x++) keep[x] = true;
}
}
const out: string[] = [];
let prevKept = true;
for (let k = 0; k < ops.length; k++) {
if (!keep[k]) {
if (prevKept) out.push("…");
prevKept = false;
continue;
}
prevKept = true;
const o = ops[k];
out.push((o.t === " " ? " " : o.t + " ") + o.line);
}
return out.join("\n");
}
/** 1-based line number of the first difference between two texts. */
export function firstDiffLine(before: string, after: string): number {
const a = before.split("\n");
const b = after.split("\n");
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) {
if (a[i] !== b[i]) return i + 1;
}
return Math.min(a.length, b.length) + 1;
}
// ---------------------------------------------------------------------------
// Filesystem walking / globbing / fuzzy matching
// ---------------------------------------------------------------------------
/**
* Recursively collect file paths under `dir` (depth-capped). IGNORE dirs
* (node_modules, .git, build caches, …) are skipped unless `includeIgnored` is true.
* Always respects AbortSignal and maxFiles so explore tools cannot hang forever.
*/
export async function walk(
dir: string,
out: string[],
depth: number,
includeIgnored = false,
signal?: AbortSignal,
/** Soft cap so huge trees cannot hang the tool forever. */
maxFiles = 20_000,
): Promise<void> {
if (depth > 10 || out.length >= maxFiles) return;
if (signal?.aborted) return;
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (signal?.aborted || out.length >= maxFiles) return;
if (!includeIgnored && IGNORE.has(e.name)) continue;
// Always skip the heaviest trees even when includeIgnored (Glob edge cases).
if (e.name === "node_modules" || e.name === ".git") {
if (!includeIgnored) continue;
// Still skip .git internals; allow node_modules only when explicitly requested.
if (e.name === ".git") continue;
}
const full = path.join(dir, e.name);
try {
if (e.isDirectory()) {
await walk(full, out, depth + 1, includeIgnored, signal, maxFiles);
} else if (e.isFile() || e.isSymbolicLink()) {
out.push(full);
}
} catch {
/* permission / race — skip entry */
}
}
}
/** Sort file paths by mtime, most-recently-modified first. Best-effort stat. */
export async function sortByMtime(files: string[]): Promise<string[]> {
const withTimes = await Promise.all(
files.map(async (f) => {
let mtimeMs = 0;
try {
mtimeMs = (await fs.stat(f)).mtimeMs;
} catch {}
return { f, mtimeMs };
})
);
withTimes.sort((a, b) => b.mtimeMs - a.mtimeMs);
return withTimes.map((x) => x.f);
}
/** Convert a glob (supporting **, *, ?) into an anchored RegExp. */
export function globToRe(p: string): RegExp {
const esc = p
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "\u0000")
.replace(/\*/g, "[^/]*")
.replace(/\u0000/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${esc}$`);
}
/** Substring/subsequence fuzzy score (higher = better; 0 = no match). */
export function fuzzyScore(text: string, query: string): number {
if (!query) return 0;
if (text.includes(query)) return 100 + (query.length / text.length) * 50;
let qi = 0;
let score = 0;
for (let i = 0; i < text.length && qi < query.length; i++) {
if (text[i] === query[qi]) {
score++;
qi++;
}
}
return qi === query.length ? score : 0;
}
/** Slugify a string for use as a filename. */
export function slugify(s: string): string {
return (
String(s || "plan")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "plan"
);
}
/** Whether ripgrep is available on PATH (cached; 3s probe timeout). */
let rgCached: boolean | null = null;
export function rgAvailable(): Promise<boolean> {
if (rgCached != null) return Promise.resolve(rgCached);
return new Promise((res) => {
let settled = false;
const finish = (v: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
rgCached = v;
res(v);
};
const timer = setTimeout(() => {
try { c.kill(); } catch { /* ignore */ }
finish(false);
}, 3_000);
let c: ReturnType<typeof spawn>;
try {
c = spawn("rg", ["--version"]);
} catch {
finish(false);
return;
}
c.on("error", () => finish(false));
c.on("close", (code) => finish(code === 0));
});
}
// ---------------------------------------------------------------------------
// Background shell registry (shared by Shell + AwaitShell)
// ---------------------------------------------------------------------------
/** A pattern the agent wants to be notified about when it appears in output. */
export interface ShellNotify {
re: RegExp;
reason: string;
debounceMs: number;
lastNotified: number;
/** Set by the loop so a match can emit an agent event. */
emit?: (text: string) => void;
}
export interface BgShell {
id: string;
command: string;
proc: ChildProcess;
output: string;
done: boolean;
exitCode: number | null;
startedAt: number;
notify?: ShellNotify;
/** Pull any new session output into this shell's buffer (for polling). */
pump?: () => void;
}
export const bgShells = new Map<string, BgShell>();
let bgShellSeq = 0;
export function nextShellId(): string {
return `sh_${++bgShellSeq}`;
}
/**
* Feed new output into a shell, appending to its buffer and firing the
* notify_on_output hook when the pattern matches (respecting debounce).
*/
export function pushShellOutput(sh: BgShell, chunk: string): void {
sh.output += chunk;
const n = sh.notify;
if (!n || !n.emit) return;
if (!n.re.test(chunk)) return;
const now = Date.now();
if (now - n.lastNotified < Math.max(5000, n.debounceMs)) return;
n.lastNotified = now;
n.emit(`Monitored ${n.reason}: matched in shell ${sh.id}`);
}
// ---------------------------------------------------------------------------
// Persistent stateful shell sessions (cwd/env persist across commands per run)
// ---------------------------------------------------------------------------
export interface ShellSession {
proc: ChildProcess;
/** Serializes command execution so output framing stays intact. */
queue: Promise<unknown>;
buffer: string;
}
const shellSessions = new Map<string, ShellSession>();
function spawnSessionShell(cwd: string): ChildProcess {
if (process.platform === "win32") {
// -File - reads stdin as a script; NonInteractive avoids prompts that hang.
return spawn(
"powershell.exe",
["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"],
{ cwd, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] },
);
}
// Non-interactive bash (no -i): interactive mode can hang on job control / PS1.
return spawn("bash", ["--noprofile", "--norc"], {
cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, TERM: "dumb", PS1: "", PS2: "" },
});
}
/** Get (or lazily create) the persistent shell session for a run key. */
export function getShellSession(key: string, cwd: string): ShellSession {
let s = shellSessions.get(key);
if (s && !s.proc.killed && s.proc.exitCode === null && s.proc.stdin && !s.proc.stdin.destroyed) {
return s;
}
if (s) {
try { s.proc.kill(); } catch { /* ignore */ }
shellSessions.delete(key);
}
const proc = spawnSessionShell(cwd);
s = { proc, queue: Promise.resolve(), buffer: "" };
proc.stdout?.on("data", (d) => {
try { s!.buffer += d.toString(); } catch { /* ignore */ }
});
proc.stderr?.on("data", (d) => {
try { s!.buffer += d.toString(); } catch { /* ignore */ }
});
proc.on("error", () => {
/* keep buffer; next getShellSession recreates */
});
proc.on("exit", () => {
/* session is dead; next command will respawn */
});
// Prevent unhandled 'error' on stdin from crashing the extension host.
proc.stdin?.on("error", () => { /* ignore broken pipe */ });
shellSessions.set(key, s);
return s;
}
/** Tear down a run's persistent shell session (call on run end / dispose). */
export function disposeShellSession(key: string): void {
const s = shellSessions.get(key);
if (s) {
try {
if (process.platform === "win32") s.proc.kill();
else s.proc.kill("SIGKILL");
} catch { /* ignore */ }
shellSessions.delete(key);
}
}
/**
* Wait until the shell finishes, `pattern` matches its output, or `ms` elapses.
* Always resolves (never rejects). `ms <= 0` = one immediate pump + return.
*/
export function waitForShell(sh: BgShell, ms: number, pattern?: RegExp, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (interval) clearInterval(interval);
signal?.removeEventListener("abort", onAbort);
resolve();
};
const onAbort = () => {
if (!sh.done) {
sh.output += "\n(aborted)";
sh.done = true;
}
finish();
};
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
const deadline = Date.now() + Math.max(0, ms);
const check = () => {
try { sh.pump?.(); } catch { /* ignore */ }
if (sh.done) return finish();
if (pattern) {
try {
if (pattern.test(sh.output)) return finish();
} catch { /* bad pattern mid-wait */ }
}
if (ms <= 0 || Date.now() >= deadline) return finish();
};
// Hard wall-clock: never tick forever even if setInterval stalls.
const timer = setTimeout(finish, Math.max(ms, 0) + 250);
const interval = setInterval(check, 50);
check();
});
}
/**
* Render a shell's state. Header + footer carry metadata (pid, timings,
* exit_code); AwaitShell's `pattern` deliberately matches only the body.
*/
export function renderShell(sh: BgShell): string {
const elapsed = Date.now() - sh.startedAt;
const head = `[shell ${sh.id}] pid=${sh.proc.pid ?? "?"} running_for_ms=${elapsed}\n$ ${sh.command}`;
const body = sh.output.slice(0, 20000);
const footer = sh.done
? `(exit_code=${sh.exitCode} elapsed_ms=${elapsed})`
: `(still running — poll with AwaitShell shell_id="${sh.id}")`;
return `${head}\n${body}\n${footer}`;
}
// ---------------------------------------------------------------------------
// In-memory per-run todo list (TodoWrite / TodoRead)
// ---------------------------------------------------------------------------
export interface TodoItem {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
}
let TODO_LIST: TodoItem[] = [];
export function resetTodos(): void {
TODO_LIST = [];
}
export function getTodos(): TodoItem[] {
return TODO_LIST;
}
export function setTodos(list: TodoItem[]): void {
TODO_LIST = list;
}
// ---------------------------------------------------------------------------
// Injected runners (set by the agent loop to avoid circular imports)
// ---------------------------------------------------------------------------
let SUBAGENT_RUNNER: SubagentRunner | undefined;
export function setSubagentRunner(runner: SubagentRunner | undefined): void {
SUBAGENT_RUNNER = runner;
}
export function getSubagentRunner(): SubagentRunner | undefined {
return SUBAGENT_RUNNER;
}
let QUESTION_ASKER: QuestionAsker | undefined;
export function setQuestionAsker(asker: QuestionAsker | undefined): void {
QUESTION_ASKER = asker;
}
export function getQuestionAsker(): QuestionAsker | undefined {
return QUESTION_ASKER;
}