Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/src/tests/exec-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function createSettings(
model: "test-model",
contextWindow: 256 * 1024,
autoCompactWindow: 128 * 1024,
streamIdleTimeoutMs: 180_000,
thinkingEnabled: false,
reasoningEffort: "high",
debugLogEnabled: false,
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/common/llm-retry.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -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";
}
}
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ export type SessionManagerOptions = {
maxRequestFilesBytes?: number;
contextWindow?: number;
autoCompactWindow?: number;
streamIdleTimeoutMs?: number;
retainRatio?: number;
retainTokens?: number;
webSearchTool?: string;
Expand Down Expand Up @@ -453,6 +454,7 @@ export class SessionManager {
maxRequestFilesBytes?: number;
contextWindow?: number;
autoCompactWindow?: number;
streamIdleTimeoutMs?: number;
retainRatio?: number;
retainTokens?: number;
webSearchTool?: string;
Expand Down Expand Up @@ -791,6 +793,7 @@ export class SessionManager {
let idleTimedOut = false;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let idleTimeoutPromise: Promise<never>;
const streamIdleTimeoutMs = this.getResolvedSettings().streamIdleTimeoutMs ?? LLM_STREAM_IDLE_TIMEOUT_MS;
const forwardAbort = () => attemptController.abort(outerSignal?.reason);
const clearAttempt = () => {
if (idleTimer) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/settings-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export function inspectSettings(options: {
"env",
"contextWindow",
"autoCompactWindow",
"streamIdleTimeoutMs",
"retainRatio",
"retainTokens",
"model",
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> & {
Expand Down Expand Up @@ -103,6 +104,7 @@ export type DeepcodingSettings = {
env?: DeepcodingEnv;
contextWindow?: number | string;
autoCompactWindow?: number | string;
streamIdleTimeoutMs?: number;
retainRatio?: number;
retainTokens?: number;
model?: string;
Expand Down Expand Up @@ -136,6 +138,7 @@ export type ResolvedDeepcodingSettings = {
model: string;
contextWindow: number;
autoCompactWindow: number;
streamIdleTimeoutMs: number;
retainRatio?: number;
retainTokens?: number;
temperature?: number;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1039,6 +1062,7 @@ export function resolveSettingsSources(
model,
contextWindow,
autoCompactWindow,
streamIdleTimeoutMs,
retainRatio,
retainTokens,
temperature,
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand All @@ -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) => {
Expand All @@ -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 () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/tests/settings-and-notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ 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,
DEFAULT_FILE_REFRESH_MARGIN_SECONDS,
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,
Expand Down Expand Up @@ -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" },
Expand Down