Implement coalescing for high-frequency UI events and enhance subagent budget management

- Introduced a coalescing mechanism for UI events in the sidebar and agent loop to prevent performance issues caused by high-frequency updates.
- Added budget management for foreground and background subagents, ensuring timely execution and preventing timeouts.
- Enhanced the `runAgent` function to handle budget hits and provide appropriate feedback on subagent execution status.
- Updated the UI to batch process events, improving responsiveness and reducing the likelihood of UI freezes during agent operations.
This commit is contained in:
Pawan Osman
2026-07-15 16:45:46 +03:00
parent f01e337cb6
commit 5dab80f764
4 changed files with 295 additions and 26 deletions
+171 -22
View File
@@ -30,9 +30,98 @@ const MULTITASK_REMINDER =
"(run_in_background=true), launching multiple subagents AT THE SAME TIME in a single turn.\n</reminder>";
const MAX_STEPS = 50;
/** Foreground subagent hard budget (abort + settle). */
const SUBAGENT_MAX_MS = 6 * 60_000;
/** Background subagent hard budget. */
const BG_SUBAGENT_MAX_MS = 10 * 60_000;
/** Coalesce high-frequency stream UI events (ms). */
const STREAM_COALESCE_MS = 40;
/**
* Batch text/thinking/tool-args deltas so streaming cannot flood the host
* reducer + webview (main cause of UI freezes that look like "stuck" tools).
* Terminal events flush pending deltas first to preserve order.
*/
function coalesceEmit(raw: (e: AgentEvent) => void): (e: AgentEvent) => void {
const pending = new Map<string, AgentEvent>();
let timer: ReturnType<typeof setTimeout> | undefined;
const flush = () => {
timer = undefined;
if (!pending.size) return;
const batch = [...pending.values()];
pending.clear();
for (const e of batch) {
try { raw(e); } catch { /* ignore */ }
}
};
const schedule = () => {
if (!timer) timer = setTimeout(flush, STREAM_COALESCE_MS);
};
return (event: AgentEvent) => {
if (event.type === "text-delta") {
const prev = pending.get("text");
if (prev && prev.type === "text-delta") {
pending.set("text", { type: "text-delta", text: prev.text + event.text });
} else {
pending.set("text", event);
}
schedule();
return;
}
if (event.type === "thinking-delta") {
const prev = pending.get("think");
if (prev && prev.type === "thinking-delta") {
pending.set("think", { type: "thinking-delta", text: prev.text + event.text });
} else {
pending.set("think", event);
}
schedule();
return;
}
if (event.type === "tool-call-args") {
// Latest full argsText wins (provider sends cumulative chunks).
pending.set(`args:${event.callId}`, event);
schedule();
return;
}
if (event.type === "subagent-event") {
const child = event.event;
// Coalesce nested high-freq child stream events per parent call.
if (child.type === "text-delta" || child.type === "thinking-delta" || child.type === "tool-call-args") {
const key =
child.type === "tool-call-args"
? `sub:${event.callId}:args:${child.callId}`
: `sub:${event.callId}:${child.type}`;
if (child.type === "text-delta" || child.type === "thinking-delta") {
const prev = pending.get(key);
if (prev && prev.type === "subagent-event" && prev.event.type === child.type) {
pending.set(key, {
type: "subagent-event",
callId: event.callId,
event: { type: child.type, text: (prev.event as { text: string }).text + child.text },
});
} else {
pending.set(key, event);
}
} else {
pending.set(key, event);
}
schedule();
return;
}
}
// Ordering: flush coalesced deltas before discrete events.
if (pending.size) {
if (timer) { clearTimeout(timer); timer = undefined; }
flush();
}
try { raw(event); } catch { /* ignore */ }
};
}
export async function runAgent(opts: RunAgentOptions): Promise<void> {
const { apiBaseUrl, apiKey, model, prompt, attachments, history, maxTokens, maxSteps, autoContinue, contextTokens, sampling, modelParams, anthropic, oauthKind, systemPromptOverride, extraInstructions, enableFileReading, enableTerminalSuggestions, enableWorkspaceContext, approve, isSubagent, customSubagents, subagentModel, registerSubagentAbort, askUser, onAfterRun, onBeforeShell, onAfterEdit, onHook, signal, emit } = opts;
const { apiBaseUrl, apiKey, model, prompt, attachments, history, maxTokens, maxSteps, autoContinue, contextTokens, sampling, modelParams, anthropic, oauthKind, systemPromptOverride, extraInstructions, enableFileReading, enableTerminalSuggestions, enableWorkspaceContext, approve, isSubagent, customSubagents, subagentModel, registerSubagentAbort, askUser, onAfterRun, onBeforeShell, onAfterEdit, onHook, signal, emit: rawEmit } = opts;
const emit = coalesceEmit(rawEmit);
// Mutable so the SwitchMode tool can change it mid-run.
let mode = opts.mode;
// multitask is agentic (full tool access); treat it like agent for gating.
@@ -96,6 +185,12 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
});
}
let finalText = "";
const budgetMs = opts?.runInBackground ? BG_SUBAGENT_MAX_MS : SUBAGENT_MAX_MS;
let budgetHit = false;
const budgetTimer = setTimeout(() => {
budgetHit = true;
try { childAC.abort(); } catch { /* ignore */ }
}, budgetMs);
// Isolated chat: empty history, own context budget. Parent only gets
// the final summary string (tool result / run-result) — never sub steps.
const runP = runAgent({
@@ -120,9 +215,11 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
signal: childAC.signal,
emit: (e) => {
if (e.type === "run-result") finalText = e.text;
// UI stream only — not parent history.
// UI stream only — not parent history. Coalesced via parent emit.
if (callId) emit({ type: "subagent-event", callId, event: e });
},
}).finally(() => {
clearTimeout(budgetTimer);
});
// Background subagents return immediately; they keep streaming via emit.
if (opts?.runInBackground) {
@@ -131,8 +228,18 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
// and capture its summary so it can be fed back into the loop on completion.
const idx = bgSubagents.length;
const tracked = runP
.then(() => ({ title, text: finalText || "(subagent finished with no summary)" }))
.catch((e) => ({ title, text: `(subagent failed: ${e instanceof Error ? e.message : String(e)})` }))
.then(() => ({
title,
text: budgetHit
? `(subagent timed out after ${Math.round(budgetMs / 1000)}s)`
: (finalText || "(subagent finished with no summary)"),
}))
.catch((e) => ({
title,
text: budgetHit
? `(subagent timed out after ${Math.round(budgetMs / 1000)}s)`
: `(subagent failed: ${e instanceof Error ? e.message : String(e)})`,
}))
.finally(() => {
parentSig.removeEventListener("abort", onParentAbort);
onHook?.("subagentStop", { subagent: title });
@@ -144,14 +251,17 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
try {
await runP;
} catch (e) {
if (budgetHit) return `(subagent timed out after ${Math.round(budgetMs / 1000)}s)`;
if (childAC.signal.aborted || parentSig.aborted) {
return "(subagent cancelled)";
}
return `(subagent failed: ${e instanceof Error ? e.message : String(e)})`;
} finally {
clearTimeout(budgetTimer);
parentSig.removeEventListener("abort", onParentAbort);
onHook?.("subagentStop", { subagent: subagentName || "subagent" });
}
if (budgetHit) return `(subagent timed out after ${Math.round(budgetMs / 1000)}s)`;
if (childAC.signal.aborted || parentSig.aborted) return "(subagent cancelled)";
return finalText || "(subagent finished with no summary)";
};
@@ -537,7 +647,25 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
});
const results = new Array<{ status: "completed" | "error"; output: string; diff?: string; startLine?: number; endLine?: number; image?: { mime: string; base64: string } }>(parsed.length);
const ro: Promise<void>[] = [];
const completedUi = new Set<number>();
const finishUi = (i: number) => {
if (completedUi.has(i) || !results[i]) return;
completedUi.add(i);
const { call } = parsed[i];
const r = results[i];
// Surface completion as soon as the tool settles — don't wait for
// siblings. Prevents one slow tool from freezing the whole card strip.
emit({
type: "tool-call-completed",
callId: call.id,
name: call.name,
status: r.status,
result: r.output,
diff: r.diff,
startLine: r.startLine,
endLine: r.endLine,
});
};
const exec = async (i: number) => {
const { call, input, badArgs } = parsed[i];
@@ -546,6 +674,7 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
status: "error",
output: `error: tool arguments were not valid JSON (likely truncated — the payload was too large). Retry with a smaller edit: split the change into multiple smaller ${call.name} calls.`,
};
finishUi(i);
return;
}
// MCP tool dispatch.
@@ -653,44 +782,64 @@ export async function runAgent(opts: RunAgentOptions): Promise<void> {
if (status === "completed" && isEditTool && onAfterEdit) {
onAfterEdit(String(input?.path ?? ""));
}
finishUi(i);
} catch (e) {
results[i] = { status: "error", output: `error: ${e instanceof Error ? e.message : String(e)}` };
finishUi(i);
}
};
// Early-exit paths inside exec that set results without finishUi.
const wrapExec = async (i: number) => {
try {
await exec(i);
} finally {
// Guarantee UI settles even if a branch forgot finishUi.
if (results[i]) finishUi(i);
else {
results[i] = { status: "error", output: "error: tool produced no result" };
finishUi(i);
}
}
};
// Cap parallel RO tools so a burst of Grep/Glob/Task can't thrash CPU/IO.
// Worker pool (not batch-wait): a long Task doesn't block the next free slot.
const RO_CONCURRENCY = 8;
const roIdx: number[] = [];
for (let i = 0; i < parsed.length; i++) {
const name = parsed[i].call.name;
const tool = TOOLS[name];
// Run read-only built-in tools in parallel; MCP + mutating tools serialize below.
if (tool && !tool.mutating && !name.startsWith("mcp__")) {
ro.push(exec(i));
}
if (tool && !tool.mutating && !name.startsWith("mcp__")) roIdx.push(i);
}
if (roIdx.length) {
let cursor = 0;
const workers = Array.from(
{ length: Math.min(RO_CONCURRENCY, roIdx.length) },
async () => {
while (cursor < roIdx.length) {
const i = roIdx[cursor++];
await wrapExec(i);
}
},
);
await Promise.all(workers);
}
await Promise.all(ro);
for (let i = 0; i < parsed.length; i++) {
const name = parsed[i].call.name;
const tool = TOOLS[name];
if (!tool || tool.mutating || name.startsWith("mcp__")) {
await exec(i);
await wrapExec(i);
}
}
for (let i = 0; i < parsed.length; i++) {
const { call } = parsed[i];
const r = results[i];
const r = results[i] ?? { status: "error" as const, output: "error: tool produced no result" };
if (call.name === "WritePlan" && r.status === "completed") {
planWritten = true;
}
emit({
type: "tool-call-completed",
callId: call.id,
name: call.name,
status: r.status,
result: r.output,
diff: r.diff,
startLine: r.startLine,
endLine: r.endLine,
});
// History in call order (model expects stable tool-result sequencing).
history.push({ kind: "tool-result", callId: call.id, name: call.name, output: r.output, status: r.status, image: r.image });
}
// After launching background Task(s), wait for that wave before calling the
+8 -2
View File
@@ -110,8 +110,14 @@ export function setToolTimeoutOverrides(sec: Record<string, number> | undefined)
* Always settles (never hangs) even if `p` never resolves.
*/
export function withToolTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
// Guard against a never-settling promise with no explicit limit.
const limit = !ms || ms <= 0 ? 120_000 : ms;
// ms <= 0: no outer race (Task/AskQuestion manage their own lifetime).
// Still attach a settle handler so a late rejection cannot become unhandled.
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(() => {
+93
View File
@@ -1275,6 +1275,27 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
}
// Only deliver events to the webview when this conversation is on screen.
// High-frequency stream events are coalesced so postMessage + applyEvent
// cannot stall the extension host (looks like stuck tools/subagents).
type PendingUi = { event: AgentEvent; apply: boolean };
const uiPending = new Map<string, PendingUi>();
let uiTimer: ReturnType<typeof setTimeout> | undefined;
const flushUi = () => {
uiTimer = undefined;
if (!uiPending.size) return;
const batch = [...uiPending.values()];
uiPending.clear();
for (const { event, apply } of batch) {
if (apply) {
session.turns = applyEvent(session.turns, event as unknown as SharedAgentEvent);
this._schedulePersistTurns(convId, session);
}
this._view?.webview.postMessage({ type: "agentEvent", convId, event });
}
};
const scheduleUi = () => {
if (!uiTimer) uiTimer = setTimeout(flushUi, 48);
};
const emit = (event: AgentEvent) => {
if (event.type === "error") {
SidebarProvider.log.appendLine(`[${new Date().toISOString()}] [agent] ${event.message}`);
@@ -1284,6 +1305,78 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// Maintain authoritative host turns from the same reducer the UI uses, then
// persist (throttled) so any webview reload restores the live state exactly.
const ev = event as unknown as SharedAgentEvent;
// Coalesce stream deltas before applying / posting.
if (ev.type === "text-delta") {
const prev = uiPending.get("text");
if (prev && (prev.event as SharedAgentEvent).type === "text-delta") {
const p = prev.event as Extract<AgentEvent, { type: "text-delta" }>;
uiPending.set("text", {
event: { type: "text-delta", text: p.text + ev.text },
apply: true,
});
} else {
uiPending.set("text", { event, apply: true });
}
scheduleUi();
return;
}
if (ev.type === "thinking-delta") {
const prev = uiPending.get("think");
if (prev && (prev.event as SharedAgentEvent).type === "thinking-delta") {
const p = prev.event as Extract<AgentEvent, { type: "thinking-delta" }>;
uiPending.set("think", {
event: { type: "thinking-delta", text: p.text + ev.text },
apply: true,
});
} else {
uiPending.set("think", { event, apply: true });
}
scheduleUi();
return;
}
if (ev.type === "tool-call-args") {
uiPending.set(`args:${ev.callId}`, { event, apply: true });
scheduleUi();
return;
}
if (ev.type === "subagent-event") {
const child = ev.event as SharedAgentEvent;
if (child.type === "text-delta" || child.type === "thinking-delta" || child.type === "tool-call-args") {
const key =
child.type === "tool-call-args"
? `sub:${ev.callId}:args:${(child as { callId: string }).callId}`
: `sub:${ev.callId}:${child.type}`;
if (child.type === "text-delta" || child.type === "thinking-delta") {
const prev = uiPending.get(key);
if (prev && (prev.event as SharedAgentEvent).type === "subagent-event") {
const pe = (prev.event as Extract<AgentEvent, { type: "subagent-event" }>).event;
if (pe.type === child.type) {
uiPending.set(key, {
event: {
type: "subagent-event",
callId: ev.callId,
event: { type: child.type, text: (pe as { text: string }).text + child.text },
},
apply: true,
});
scheduleUi();
return;
}
}
}
uiPending.set(key, { event, apply: true });
scheduleUi();
return;
}
}
// Discrete events: flush coalesced stream first (ordering).
if (uiPending.size) {
if (uiTimer) { clearTimeout(uiTimer); uiTimer = undefined; }
flushUi();
}
if (ev.type === "run-status") {
if (ev.status === "finished" || ev.status === "cancelled" || ev.status === "error") {
session.turns = forceSettleOpenWork(
+23 -2
View File
@@ -882,6 +882,15 @@ export function App() {
};
React.useEffect(() => {
// rAF-batch stream-driven re-renders so tool/args/text deltas don't thrash React.
let raf = 0;
const scheduleForce = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
force();
});
};
const handler = (event: MessageEvent<InMessage>) => {
const msg = event.data;
switch (msg.type) {
@@ -1006,12 +1015,23 @@ export function App() {
// Keep the rendered error block; persist so the chat survives reloads.
s.status = { text: "Error: " + ev.message, error: true };
post({ type: "persistTurns", convId: msg.convId, turns: s.turns });
} else if (ev.type === "mode-changed") {
} else if (ev.type === "mode-changed") {
setMode(ev.mode);
post({ type: "setMode", mode: ev.mode });
}
}
force();
// Immediate paint on settle / tool complete; coalesce stream deltas.
if (
settled ||
ev.type === "tool-call-completed" ||
ev.type === "tool-call-started" ||
ev.type === "error" ||
ev.type === "run-result"
) {
force();
} else {
scheduleForce();
}
break;
}
}
@@ -1029,6 +1049,7 @@ export function App() {
window.addEventListener("beforeunload", flush);
post({ type: "ready" });
return () => {
if (raf) cancelAnimationFrame(raf);
flush();
window.removeEventListener("message", handler);
window.removeEventListener("pagehide", flush);