diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 4f09675e..b0b11ca5 100644 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -5,23 +5,16 @@ * * Test with: npx tsx src/cli-new.ts [args...] */ -import * as undici from "undici"; import { APP_NAME } from "./config.ts"; +import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { main } from "./main.ts"; process.title = APP_NAME; process.env.PI_CODING_AGENT = "true"; process.emitWarning = (() => {}) as typeof process.emitWarning; -// bodyTimeout/headersTimeout default to 300s in undici; long local-LLM stalls -// (e.g. vLLM buffering a large tool call) exceed that and abort the SSE stream -// with UND_ERR_BODY_TIMEOUT. Disable both — provider SDKs enforce their own -// AbortController-based deadlines via retry.provider.timeoutMs. -undici.setGlobalDispatcher(new undici.EnvHttpProxyAgent({ allowH2: false, bodyTimeout: 0, headersTimeout: 0 })); - -// Keep fetch and the dispatcher on the same undici implementation. Node 26.0's -// bundled fetch can otherwise consume compressed responses through npm undici's -// dispatcher without decompressing them, causing response.json() failures. -undici.install?.(); +// Configure undici's global dispatcher before provider SDKs issue requests. +// Runtime settings are applied once SettingsManager has loaded global/project settings. +configureHttpDispatcher(); main(process.argv.slice(2)); diff --git a/packages/coding-agent/src/core/http-dispatcher.ts b/packages/coding-agent/src/core/http-dispatcher.ts new file mode 100644 index 00000000..12ce9fb0 --- /dev/null +++ b/packages/coding-agent/src/core/http-dispatcher.ts @@ -0,0 +1,55 @@ +import * as undici from "undici"; + +export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000; + +export const HTTP_IDLE_TIMEOUT_CHOICES = [ + { label: "30 sec", timeoutMs: 30_000 }, + { label: "1 min", timeoutMs: 60_000 }, + { label: "2 min", timeoutMs: 120_000 }, + { label: "5 min", timeoutMs: 300_000 }, + { label: "disabled", timeoutMs: 0 }, +] as const; + +export function parseHttpIdleTimeoutMs(value: unknown): number | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.toLowerCase() === "disabled") { + return 0; + } + if (trimmed.length === 0) { + return undefined; + } + return parseHttpIdleTimeoutMs(Number(trimmed)); + } + + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.floor(value); +} + +export function formatHttpIdleTimeoutMs(timeoutMs: number): string { + const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.timeoutMs === timeoutMs); + if (choice) { + return choice.label; + } + return `${timeoutMs / 1000} sec`; +} + +export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { + const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); + if (normalizedTimeoutMs === undefined) { + throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`); + } + undici.setGlobalDispatcher( + new undici.EnvHttpProxyAgent({ + allowH2: false, + bodyTimeout: normalizedTimeoutMs, + headersTimeout: normalizedTimeoutMs, + }), + ); + // Keep fetch and the dispatcher on the same undici implementation. Node 26.0's + // bundled fetch can otherwise consume compressed responses through npm undici's + // dispatcher without decompressing them, causing response.json() failures. + undici.install?.(); +} diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 7dc6931e..2fedad35 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -4,6 +4,7 @@ import { homedir } from "os"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; +import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; export interface CompactionSettings { enabled?: boolean; // default: true @@ -110,6 +111,7 @@ export interface Settings { markdown?: MarkdownSettings; warnings?: WarningSettings; sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) + httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -726,6 +728,27 @@ export class SettingsManager { }; } + getHttpIdleTimeoutMs(): number { + const value = this.settings.httpIdleTimeoutMs; + const timeoutMs = parseHttpIdleTimeoutMs(value); + if (timeoutMs !== undefined) { + return timeoutMs; + } + if (value !== undefined) { + throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(value)}`); + } + return DEFAULT_HTTP_IDLE_TIMEOUT_MS; + } + + setHttpIdleTimeoutMs(timeoutMs: number): void { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(timeoutMs)}`); + } + this.globalSettings.httpIdleTimeoutMs = Math.floor(timeoutMs); + this.markModified("httpIdleTimeoutMs"); + this.save(); + } + getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } { return { timeoutMs: this.settings.retry?.provider?.timeoutMs, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 141a34e7..47f53c3d 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -26,6 +26,7 @@ import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; import { AuthStorage } from "./core/auth-storage.ts"; import { exportFromFile } from "./core/export-html/index.ts"; import type { ExtensionFactory } from "./core/extensions/types.ts"; +import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { KeybindingsManager } from "./core/keybindings.ts"; import type { ModelRegistry } from "./core/model-registry.ts"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; @@ -617,6 +618,7 @@ export async function main(args: string[], options?: MainOptions) { }); const { services, session, modelFallbackMessage } = runtime; const { settingsManager, modelRegistry, resourceLoader } = services; + configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); if (parsed.help) { const extensionFlags = resourceLoader diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index f99d7381..7d210028 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -11,6 +11,7 @@ import { Spacer, Text, } from "@earendil-works/pi-tui"; +import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; import type { WarningSettings } from "../../../core/settings-manager.ts"; import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; @@ -40,6 +41,7 @@ export interface SettingsConfig { steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; transport: Transport; + httpIdleTimeoutMs: number; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; currentTheme: string; @@ -68,6 +70,7 @@ export interface SettingsCallbacks { onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onTransportChange: (transport: Transport) => void; + onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; onThinkingLevelChange: (level: ThinkingLevel) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; @@ -238,6 +241,14 @@ export class SettingsSelectorComponent extends Container { currentValue: config.transport, values: ["sse", "websocket", "websocket-cached", "auto"], }, + { + id: "http-idle-timeout", + label: "HTTP idle timeout", + description: + "Maximum idle gap while waiting for HTTP headers or body chunks. Disable for local models that pause longer than five minutes.", + currentValue: formatHttpIdleTimeoutMs(config.httpIdleTimeoutMs), + values: HTTP_IDLE_TIMEOUT_CHOICES.map((choice) => choice.label), + }, { id: "hide-thinking", label: "Hide thinking", @@ -482,6 +493,13 @@ export class SettingsSelectorComponent extends Container { case "transport": callbacks.onTransportChange(newValue as Transport); break; + case "http-idle-timeout": { + const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.label === newValue); + if (choice) { + callbacks.onHttpIdleTimeoutMsChange(choice.timeoutMs); + } + break; + } case "hide-thinking": callbacks.onHideThinkingBlockChange(newValue === "true"); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c93beb93..aa8db52b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -71,6 +71,7 @@ import type { ExtensionWidgetOptions, } from "../../core/extensions/index.ts"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; +import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; import { createCompactionSummaryMessage } from "../../core/messages.ts"; import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts"; @@ -1566,6 +1567,7 @@ export class InteractiveMode { } private applyRuntimeSettings(): void { + configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.footer.setSession(this.session); this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); this.footerDataProvider.setCwd(this.sessionManager.getCwd()); @@ -3846,6 +3848,7 @@ export class InteractiveMode { steeringMode: this.session.steeringMode, followUpMode: this.session.followUpMode, transport: this.settingsManager.getTransport(), + httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), currentTheme: this.settingsManager.getTheme() || "dark", @@ -3904,6 +3907,11 @@ export class InteractiveMode { this.settingsManager.setTransport(transport); this.session.agent.transport = transport; }, + onHttpIdleTimeoutMsChange: (timeoutMs) => { + this.settingsManager.setHttpIdleTimeoutMs(timeoutMs); + configureHttpDispatcher(timeoutMs); + this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); + }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); this.footer.invalidate(); @@ -4891,6 +4899,7 @@ export class InteractiveMode { try { await this.session.reload(); + configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); this.keybindings.reload(); const activeHeader = this.customHeader ?? this.builtInHeader; if (isExpandable(activeHeader)) { diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index d01b1363..a503e3cc 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -2,7 +2,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { SettingsManager } from "../src/core/settings-manager.js"; +import { DEFAULT_HTTP_IDLE_TIMEOUT_MS } from "../src/core/http-dispatcher.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; describe("SettingsManager", () => { const testDir = join(process.cwd(), "test-settings-tmp"); @@ -257,6 +258,29 @@ describe("SettingsManager", () => { }); }); + describe("httpIdleTimeoutMs", () => { + it("should default to 5 minutes", () => { + const manager = SettingsManager.create(projectDir, agentDir); + expect(manager.getHttpIdleTimeoutMs()).toBe(DEFAULT_HTTP_IDLE_TIMEOUT_MS); + }); + + it("should use merged global and project settings", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ httpIdleTimeoutMs: 300000 })); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ httpIdleTimeoutMs: 0 })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getHttpIdleTimeoutMs()).toBe(0); + }); + + it("should reject invalid timeout values", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ httpIdleTimeoutMs: -1 })); + const manager = SettingsManager.create(projectDir, agentDir); + + expect(() => manager.getHttpIdleTimeoutMs()).toThrow("Invalid httpIdleTimeoutMs setting"); + }); + }); + describe("shellCommandPrefix", () => { it("should load shellCommandPrefix from settings", () => { const settingsPath = join(agentDir, "settings.json");