diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts index 8ec1b8dfee7..bcd5674bb30 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts @@ -520,6 +520,27 @@ describe("resolveLlmIdleTimeoutMs", () => { expect(resolveLlmIdleTimeoutMs({ cfg, model })).toBe(expected); }); + it.each([ + ["local keeps no class ceiling", { baseUrl: "http://127.0.0.1:11434" }, 900_000], + [ + "self-hosted keeps the 300s tier", + { provider: "vllm", baseUrl: "https://gpu.example.com/v1" }, + 300_000, + ], + ["cloud keeps the 120s default", { provider: "openai" }, 120_000], + ])("explicit run timeout above the tiers: %s", (_label, model, expected) => { + expect(resolveLlmIdleTimeoutMs({ runTimeoutMs: 900_000, model })).toBe(expected); + }); + + it("explicit run timeouts below the class tier still bound self-hosted idle", () => { + expect( + resolveLlmIdleTimeoutMs({ + runTimeoutMs: 90_000, + model: { provider: "vllm", baseUrl: "https://gpu.example.com/v1" }, + }), + ).toBe(90_000); + }); + it("cron exempts provider-id self-hosted models from the 60s clamp", () => { expect( resolveLlmIdleTimeoutMs({ diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts index 6ab1cc6a434..97d0cf27cac 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts @@ -249,8 +249,6 @@ export function resolveLlmIdleTimeoutMs(params?: { model?: { baseUrl?: string; id?: string; provider?: string }; }): number { const clampTimeoutMs = (valueMs: number) => clampTimerTimeoutMs(valueMs) ?? 1; - const clampImplicitTimeoutMs = (valueMs: number) => - clampTimeoutMs(Math.min(valueMs, DEFAULT_LLM_IDLE_TIMEOUT_MS)); const runTimeoutMs = params?.runTimeoutMs; const agentTimeoutSeconds = params?.cfg?.agents?.defaults?.timeoutSeconds; @@ -276,6 +274,23 @@ export function resolveLlmIdleTimeoutMs(params?: { value < MAX_TIMER_TIMEOUT_MS, ); + // Run/agent budgets bound idle from below the provider-class ceiling; they + // must not shrink class tolerance (local has no ceiling, self-hosted 300s). + // Clamping every class to the cloud default reopened #85826-style kills for + // self-hosted users with explicit budgets above 120s. + const clampToClassIdleCeiling = (budgetMs: number): number => { + if (isLocalRuntimeModel) { + return clampTimeoutMs(budgetMs); + } + const classIdleTimeoutMs = + isSelfHostedRuntimeModel || + isExplicitLocalHostnameRuntimeModel || + isSelfHostedHostnameRuntimeModel + ? SELF_HOSTED_LLM_IDLE_TIMEOUT_MS + : DEFAULT_LLM_IDLE_TIMEOUT_MS; + return clampTimeoutMs(Math.min(budgetMs, classIdleTimeoutMs)); + }; + // Explicit per-model idle timeout (`models.providers..timeoutSeconds`) wins // over the NO_TIMEOUT_MS sentinel that runTimeoutMs may carry when the caller // declared "run is unlimited". The two are independent: an unlimited run does @@ -314,23 +329,11 @@ export function resolveLlmIdleTimeoutMs(params?: { } return clampTimeoutMs(Math.min(runTimeoutMs, CRON_LLM_IDLE_TIMEOUT_MS)); } - return clampImplicitTimeoutMs(runTimeoutMs); + return clampToClassIdleCeiling(runTimeoutMs); } if (agentTimeoutMs !== undefined) { - // The agent budget bounds idle from below the provider-class ceiling; it - // must not shrink class tolerance (local has no ceiling, self-hosted 300s). - // Clamping every class to the cloud default here reopened #85826-style kills. - if (isLocalRuntimeModel) { - return clampTimeoutMs(agentTimeoutMs); - } - const classIdleTimeoutMs = - isSelfHostedRuntimeModel || - isExplicitLocalHostnameRuntimeModel || - isSelfHostedHostnameRuntimeModel - ? SELF_HOSTED_LLM_IDLE_TIMEOUT_MS - : DEFAULT_LLM_IDLE_TIMEOUT_MS; - return clampTimeoutMs(Math.min(agentTimeoutMs, classIdleTimeoutMs)); + return clampToClassIdleCeiling(agentTimeoutMs); } // The default watchdog is a network-silence-as-hang guard for cloud providers.