diff --git a/packages/cli/src/tests/exec-runner.test.ts b/packages/cli/src/tests/exec-runner.test.ts index df4da8f..d0758cd 100644 --- a/packages/cli/src/tests/exec-runner.test.ts +++ b/packages/cli/src/tests/exec-runner.test.ts @@ -31,6 +31,7 @@ function createSettings( model: "test-model", contextWindow: 256 * 1024, autoCompactWindow: 128 * 1024, + streamIdleTimeoutMs: 180_000, thinkingEnabled: false, reasoningEffort: "high", debugLogEnabled: false, diff --git a/packages/core/src/common/llm-retry.ts b/packages/core/src/common/llm-retry.ts index 977854a..c7782e8 100644 --- a/packages/core/src/common/llm-retry.ts +++ b/packages/core/src/common/llm-retry.ts @@ -1,7 +1,9 @@ import { getLlmErrorDetails } from "./llm-error"; export const MAX_LLM_RETRIES = 5; -export const LLM_STREAM_IDLE_TIMEOUT_MS = 60_000; +// Thinking models can stay silent for minutes; 60s caused false-positive retries. +export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 180_000; +export const LLM_STREAM_IDLE_TIMEOUT_MS = DEFAULT_STREAM_IDLE_TIMEOUT_MS; const BASE_RETRY_DELAY_MS = 800; const RETRYABLE_NETWORK_CODES = new Set([ @@ -18,8 +20,8 @@ const RETRYABLE_NETWORK_CODES = new Set([ ]); export class LlmStreamIdleTimeoutError extends Error { - constructor() { - super(`Model stream was idle for ${LLM_STREAM_IDLE_TIMEOUT_MS / 1000} seconds.`); + constructor(timeoutMs: number = LLM_STREAM_IDLE_TIMEOUT_MS) { + super(`Model stream was idle for ${Math.round(timeoutMs / 1000)} seconds.`); this.name = "LlmStreamIdleTimeoutError"; } } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 1d51254..0f00b81 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -399,6 +399,7 @@ export type SessionManagerOptions = { maxRequestFilesBytes?: number; contextWindow?: number; autoCompactWindow?: number; + streamIdleTimeoutMs?: number; retainRatio?: number; retainTokens?: number; webSearchTool?: string; @@ -453,6 +454,7 @@ export class SessionManager { maxRequestFilesBytes?: number; contextWindow?: number; autoCompactWindow?: number; + streamIdleTimeoutMs?: number; retainRatio?: number; retainTokens?: number; webSearchTool?: string; @@ -791,6 +793,7 @@ export class SessionManager { let idleTimedOut = false; let idleTimer: ReturnType | null = null; let idleTimeoutPromise: Promise; + const streamIdleTimeoutMs = this.getResolvedSettings().streamIdleTimeoutMs ?? LLM_STREAM_IDLE_TIMEOUT_MS; const forwardAbort = () => attemptController.abort(outerSignal?.reason); const clearAttempt = () => { if (idleTimer) { @@ -806,10 +809,10 @@ export class SessionManager { idleTimeoutPromise = new Promise((_, reject) => { idleTimer = setTimeout(() => { idleTimedOut = true; - const error = new LlmStreamIdleTimeoutError(); + const error = new LlmStreamIdleTimeoutError(streamIdleTimeoutMs); attemptController.abort(error); reject(error); - }, LLM_STREAM_IDLE_TIMEOUT_MS); + }, streamIdleTimeoutMs); }); }; if (outerSignal?.aborted) { @@ -841,7 +844,7 @@ export class SessionManager { idleTimeoutPromise!, ]); } catch (error) { - const requestError = idleTimedOut ? new LlmStreamIdleTimeoutError() : error; + const requestError = idleTimedOut ? new LlmStreamIdleTimeoutError(streamIdleTimeoutMs) : error; this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), location: debug?.location ?? "SessionManager.createChatCompletionStream:create", @@ -997,7 +1000,7 @@ export class SessionManager { throw new LlmStreamDisconnectedError(); } } catch (error) { - const streamError = idleTimedOut ? new LlmStreamIdleTimeoutError() : error; + const streamError = idleTimedOut ? new LlmStreamIdleTimeoutError(streamIdleTimeoutMs) : error; this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), location: debug?.location ?? "SessionManager.createChatCompletionStream:stream", diff --git a/packages/core/src/settings-diagnostics.ts b/packages/core/src/settings-diagnostics.ts index a7251b4..973d9d0 100644 --- a/packages/core/src/settings-diagnostics.ts +++ b/packages/core/src/settings-diagnostics.ts @@ -154,6 +154,7 @@ export function inspectSettings(options: { "env", "contextWindow", "autoCompactWindow", + "streamIdleTimeoutMs", "retainRatio", "retainTokens", "model", diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 8dbbf89..27ab748 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -4,6 +4,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { compactBudget, validateCompactRetention } from "./common/compaction"; +import { DEFAULT_STREAM_IDLE_TIMEOUT_MS } from "./common/llm-retry"; import { normalizePlusApiKey } from "./common/plus-subscription"; export type DeepcodingEnv = Record & { @@ -103,6 +104,7 @@ export type DeepcodingSettings = { env?: DeepcodingEnv; contextWindow?: number | string; autoCompactWindow?: number | string; + streamIdleTimeoutMs?: number; retainRatio?: number; retainTokens?: number; model?: string; @@ -136,6 +138,7 @@ export type ResolvedDeepcodingSettings = { model: string; contextWindow: number; autoCompactWindow: number; + streamIdleTimeoutMs: number; retainRatio?: number; retainTokens?: number; temperature?: number; @@ -177,6 +180,8 @@ export const DEFAULT_FILE_REFRESH_MARGIN_SECONDS = 60 * 60; export const DEFAULT_FILE_QUOTA_CLEANUP_BATCH = 100; export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024; export const MAX_FILES_API_TIMEOUT_MS = 10 * 60 * 1000; +export const MIN_STREAM_IDLE_TIMEOUT_MS = 30_000; +export const MAX_STREAM_IDLE_TIMEOUT_MS = 30 * 60 * 1000; export function getDefaultContextWindow(model: string): number { return DEEPSEEK_V4_MODELS.has(model) ? DEEPSEEK_V4_CONTEXT_WINDOW : DEFAULT_CONTEXT_WINDOW; @@ -799,6 +804,24 @@ export function resolveSettingsSources( const retainTokens = retention?.retainTokens; const retainRatio = retainTokens === undefined ? (retention?.retainRatio ?? 0.16) : undefined; compactBudget(contextWindow, autoCompactWindow, { retainRatio, retainTokens }); + const streamIdleTimeoutMs = choose( + "streamIdleTimeoutMs", + [ + [ + "project.streamIdleTimeoutMs", + parseIntegerInRange( + projectSettings?.streamIdleTimeoutMs, + MIN_STREAM_IDLE_TIMEOUT_MS, + MAX_STREAM_IDLE_TIMEOUT_MS + ), + ], + [ + "user.streamIdleTimeoutMs", + parseIntegerInRange(userSettings?.streamIdleTimeoutMs, MIN_STREAM_IDLE_TIMEOUT_MS, MAX_STREAM_IDLE_TIMEOUT_MS), + ], + ], + DEFAULT_STREAM_IDLE_TIMEOUT_MS + ); const thinkingEnabled = choose( "thinkingEnabled", @@ -1039,6 +1062,7 @@ export function resolveSettingsSources( model, contextWindow, autoCompactWindow, + streamIdleTimeoutMs, retainRatio, retainTokens, temperature, diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts index 4bae293..91eb48d 100644 --- a/packages/core/src/tests/session.test.ts +++ b/packages/core/src/tests/session.test.ts @@ -4062,7 +4062,7 @@ test("SessionManager treats a clean EOF without a terminal chunk as a disconnect assert.equal(retryError, "Model stream disconnected before completion."); }); -test("SessionManager retries a stream after sixty seconds without data", async (t) => { +test("SessionManager retries a stream after the configured idle timeout", async (t) => { t.mock.timers.enable({ apis: ["setTimeout"] }); const controller = new AbortController(); let retryError = ""; @@ -4083,7 +4083,7 @@ test("SessionManager retries a stream after sixty seconds without data", async ( const manager = new SessionManager({ projectRoot: process.cwd(), createOpenAIClient: () => ({ client: client as any, model: "test-model", thinkingEnabled: false }), - getResolvedSettings: () => ({ model: "test-model" }), + getResolvedSettings: () => ({ model: "test-model", streamIdleTimeoutMs: 90_000 }), renderMarkdown: (text) => text, onAssistantMessage: () => {}, onLlmRetry: (event) => { @@ -4098,10 +4098,10 @@ test("SessionManager retries a stream after sixty seconds without data", async ( { signal: controller.signal } ); await Promise.resolve(); - t.mock.timers.tick(60_000); + t.mock.timers.tick(90_000); await assert.rejects(responsePromise, (error: Error) => error.name === "AbortError"); - assert.equal(retryError, "Model stream was idle for 60 seconds."); + assert.equal(retryError, "Model stream was idle for 90 seconds."); }); test("SessionManager retains an uncommitted prompt in the queue when skill matching is cancelled", async () => { diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index a5ca060..2e61e5e 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -10,6 +10,7 @@ import { type NotifyContext, type NotifySpawn, } from "../common/notify"; +import { DEFAULT_STREAM_IDLE_TIMEOUT_MS } from "../common/llm-retry"; import { DEFAULT_FILE_EXPIRES_AFTER_SECONDS, DEFAULT_FILE_QUOTA_CLEANUP_BATCH, @@ -17,6 +18,8 @@ import { DEFAULT_FILES_API_TIMEOUT_MS, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_MODEL, + MAX_STREAM_IDLE_TIMEOUT_MS, + MIN_STREAM_IDLE_TIMEOUT_MS, applyModelConfigSelection, readDeepcodePlusApiKey, readDeepcodePlusSettings, @@ -150,6 +153,24 @@ test("resolveSettingsSources validates Files API settings and uses project prece assert.equal(resolved.maxRequestFilesBytes, 20_000); }); +test("resolveSettings accepts streamIdleTimeoutMs within bounds and falls back outside them", () => { + const resolved = resolveSettings( + { streamIdleTimeoutMs: 90_000 }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.streamIdleTimeoutMs, 90_000); + + for (const value of [MIN_STREAM_IDLE_TIMEOUT_MS - 1, MAX_STREAM_IDLE_TIMEOUT_MS + 1]) { + const fallback = resolveSettings( + { streamIdleTimeoutMs: value }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(fallback.streamIdleTimeoutMs, DEFAULT_STREAM_IDLE_TIMEOUT_MS); + } +}); + test("resolveSettings reads top-level multimodal and ignores invalid values", () => { const on = resolveSettings( { multimodal: "on" },