mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix: align LLM idle timeout policy
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI: guard stale session-history reloads during fast session switches so the selected session and rendered transcript stay in sync. (#62975) Thanks @scoootscooob.
|
||||
- Agents/failover: classify Z.ai vendor code `1311` as billing and `1113` as auth, including long wrapped `1311` payloads, so these errors stop falling through to generic failover handling. (#49552) Thanks @1bcMax.
|
||||
- npm packaging: mirror bundled Slack, Telegram, Discord, and Feishu channel runtime deps at the root and harden published-install verification so fresh installs fail fast on manifest drift instead of missing-module crashes. (#63065) Thanks @scoootscooob.
|
||||
- Agents/timeouts: make the LLM idle timeout inherit `agents.defaults.timeoutSeconds` when configured, disable the unconfigured idle watchdog for cron runs, and point idle-timeout errors at `agents.defaults.llm.idleTimeoutSeconds`. Thanks @drvoss.
|
||||
|
||||
## 2026.4.8
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook AP
|
||||
|
||||
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
|
||||
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedPiAgent` abort timer.
|
||||
- LLM idle timeout: `agents.defaults.llm.idleTimeoutSeconds` aborts a model request when no response chunks arrive before the idle window. Set it explicitly for slow local models or reasoning/tool-call providers; set it to 0 to disable. If it is not set, OpenClaw uses `agents.defaults.timeoutSeconds` when configured, otherwise 60s. Cron-triggered runs with no explicit LLM or agent timeout disable the idle watchdog and rely on the cron outer timeout.
|
||||
|
||||
## Where things can end early
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
|
||||
return {
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
idleTimedOut: false,
|
||||
timedOutDuringCompaction: false,
|
||||
promptError: null,
|
||||
promptErrorSource: null,
|
||||
|
||||
@@ -35,6 +35,7 @@ export function makeAttemptResult(
|
||||
return {
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
idleTimedOut: false,
|
||||
timedOutDuringCompaction: false,
|
||||
promptError: null,
|
||||
promptErrorSource: null,
|
||||
|
||||
@@ -221,6 +221,25 @@ describe("timeout-triggered compaction", () => {
|
||||
expect(result.payloads?.[0]?.text).toContain("timed out");
|
||||
});
|
||||
|
||||
it("points idle-timeout errors at the LLM idle timeout config key", async () => {
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({
|
||||
timedOut: true,
|
||||
idleTimedOut: true,
|
||||
lastAssistant: {
|
||||
usage: { input: 20000 },
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent(overflowBaseRunParams);
|
||||
|
||||
expect(mockedCompactDirect).not.toHaveBeenCalled();
|
||||
expect(result.payloads?.[0]?.isError).toBe(true);
|
||||
expect(result.payloads?.[0]?.text).toContain("agents.defaults.llm.idleTimeoutSeconds");
|
||||
expect(result.payloads?.[0]?.text).not.toContain("agents.defaults.timeoutSeconds");
|
||||
});
|
||||
|
||||
it("does not attempt compaction for low-context timeouts on later retries", async () => {
|
||||
mockedPickFallbackThinkingLevel.mockReturnValueOnce("low");
|
||||
mockedRunEmbeddedAttempt
|
||||
|
||||
@@ -688,6 +688,7 @@ export async function runEmbeddedPiAgent(
|
||||
promptErrorSource,
|
||||
preflightRecovery,
|
||||
timedOut,
|
||||
idleTimedOut,
|
||||
timedOutDuringCompaction,
|
||||
sessionIdUsed,
|
||||
lastAssistant,
|
||||
@@ -1433,12 +1434,15 @@ export async function runEmbeddedPiAgent(
|
||||
// Emit an explicit timeout error instead of silently completing, so
|
||||
// callers do not lose the turn as an orphaned user message.
|
||||
if (timedOut && !timedOutDuringCompaction && payloads.length === 0) {
|
||||
const timeoutText = idleTimedOut
|
||||
? "The model did not produce a response before the LLM idle timeout. " +
|
||||
"Please try again, or increase `agents.defaults.llm.idleTimeoutSeconds` in your config (set to 0 to disable)."
|
||||
: "Request timed out before a response was generated. " +
|
||||
"Please try again, or increase `agents.defaults.timeoutSeconds` in your config.";
|
||||
return {
|
||||
payloads: [
|
||||
{
|
||||
text:
|
||||
"Request timed out before a response was generated. " +
|
||||
"Please try again, or increase `agents.defaults.timeoutSeconds` in your config.",
|
||||
text: timeoutText,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1266,7 +1266,10 @@ export async function runEmbeddedAttempt(
|
||||
let idleTimeoutTrigger: ((error: Error) => void) | undefined;
|
||||
|
||||
// Wrap stream with idle timeout detection
|
||||
const idleTimeoutMs = resolveLlmIdleTimeoutMs(params.config);
|
||||
const idleTimeoutMs = resolveLlmIdleTimeoutMs({
|
||||
cfg: params.config,
|
||||
trigger: params.trigger,
|
||||
});
|
||||
if (idleTimeoutMs > 0) {
|
||||
activeSession.agent.streamFn = streamWithIdleTimeout(
|
||||
activeSession.agent.streamFn,
|
||||
@@ -1377,6 +1380,7 @@ export async function runEmbeddedAttempt(
|
||||
let aborted = Boolean(params.abortSignal?.aborted);
|
||||
let yieldAborted = false;
|
||||
let timedOut = false;
|
||||
let idleTimedOut = false;
|
||||
let timedOutDuringCompaction = false;
|
||||
const getAbortReason = (signal: AbortSignal): unknown =>
|
||||
"reason" in signal ? (signal as { reason?: unknown }).reason : undefined;
|
||||
@@ -1426,6 +1430,7 @@ export async function runEmbeddedAttempt(
|
||||
void activeSession.abort();
|
||||
};
|
||||
idleTimeoutTrigger = (error) => {
|
||||
idleTimedOut = true;
|
||||
abortRun(true, error);
|
||||
};
|
||||
const abortable = <T>(promise: Promise<T>): Promise<T> => {
|
||||
@@ -2327,6 +2332,7 @@ export async function runEmbeddedAttempt(
|
||||
itemLifecycle: getItemLifecycle(),
|
||||
aborted,
|
||||
timedOut,
|
||||
idleTimedOut,
|
||||
timedOutDuringCompaction,
|
||||
promptError,
|
||||
promptErrorSource,
|
||||
|
||||
@@ -8,46 +8,82 @@ import {
|
||||
|
||||
describe("resolveLlmIdleTimeoutMs", () => {
|
||||
it("returns default when config is undefined", () => {
|
||||
expect(resolveLlmIdleTimeoutMs(undefined)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
expect(resolveLlmIdleTimeoutMs()).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("returns default when llm config is missing", () => {
|
||||
const cfg = { agents: {} } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("returns default when idleTimeoutSeconds is not set", () => {
|
||||
const cfg = { agents: { defaults: { llm: {} } } } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("returns 0 when idleTimeoutSeconds is 0 (disabled)", () => {
|
||||
const cfg = { agents: { defaults: { llm: { idleTimeoutSeconds: 0 } } } } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(0);
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(0);
|
||||
});
|
||||
|
||||
it("returns configured value in milliseconds", () => {
|
||||
const cfg = { agents: { defaults: { llm: { idleTimeoutSeconds: 30 } } } } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(30_000);
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(30_000);
|
||||
});
|
||||
|
||||
it("caps at max safe timeout", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { llm: { idleTimeoutSeconds: 10_000_000 } } },
|
||||
} as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(2_147_000_000);
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(2_147_000_000);
|
||||
});
|
||||
|
||||
it("ignores negative values", () => {
|
||||
const cfg = { agents: { defaults: { llm: { idleTimeoutSeconds: -10 } } } } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("ignores non-finite values", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { llm: { idleTimeoutSeconds: Infinity } } },
|
||||
} as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
it("falls back to agents.defaults.timeoutSeconds when llm.idleTimeoutSeconds is not set", () => {
|
||||
const cfg = { agents: { defaults: { timeoutSeconds: 300 } } } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(300_000);
|
||||
});
|
||||
|
||||
it("prefers llm.idleTimeoutSeconds over agents.defaults.timeoutSeconds", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { timeoutSeconds: 300, llm: { idleTimeoutSeconds: 120 } } },
|
||||
} as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(120_000);
|
||||
});
|
||||
|
||||
it("keeps idleTimeoutSeconds=0 disabled even when timeoutSeconds is set", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { timeoutSeconds: 300, llm: { idleTimeoutSeconds: 0 } } },
|
||||
} as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(0);
|
||||
});
|
||||
|
||||
it("disables the default idle timeout for cron when no timeout is configured", () => {
|
||||
expect(resolveLlmIdleTimeoutMs({ trigger: "cron" })).toBe(0);
|
||||
|
||||
const cfg = { agents: { defaults: { llm: {} } } } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg, trigger: "cron" })).toBe(0);
|
||||
});
|
||||
|
||||
it("uses agents.defaults.timeoutSeconds for cron before disabling the default idle timeout", () => {
|
||||
const cfg = { agents: { defaults: { timeoutSeconds: 300 } } } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg, trigger: "cron" })).toBe(300_000);
|
||||
});
|
||||
|
||||
it("keeps an explicit cron idle timeout when configured", () => {
|
||||
const cfg = { agents: { defaults: { llm: { idleTimeoutSeconds: 45 } } } } as OpenClawConfig;
|
||||
expect(resolveLlmIdleTimeoutMs({ cfg, trigger: "cron" })).toBe(45_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { EmbeddedRunTrigger } from "./params.js";
|
||||
|
||||
/**
|
||||
* Default idle timeout for LLM streaming responses in milliseconds.
|
||||
@@ -17,18 +18,34 @@ const MAX_SAFE_TIMEOUT_MS = 2_147_000_000;
|
||||
|
||||
/**
|
||||
* Resolves the LLM idle timeout from configuration.
|
||||
* @param cfg - OpenClaw configuration
|
||||
* @returns Idle timeout in milliseconds, or 0 to disable
|
||||
*/
|
||||
export function resolveLlmIdleTimeoutMs(cfg?: OpenClawConfig): number {
|
||||
const raw = cfg?.agents?.defaults?.llm?.idleTimeoutSeconds;
|
||||
// 0 means disabled (no timeout)
|
||||
export function resolveLlmIdleTimeoutMs(params?: {
|
||||
cfg?: OpenClawConfig;
|
||||
trigger?: EmbeddedRunTrigger;
|
||||
}): number {
|
||||
const raw = params?.cfg?.agents?.defaults?.llm?.idleTimeoutSeconds;
|
||||
// 0 means explicitly disabled (no timeout).
|
||||
if (raw === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
||||
return Math.min(Math.floor(raw) * 1000, MAX_SAFE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
const agentTimeoutSeconds = params?.cfg?.agents?.defaults?.timeoutSeconds;
|
||||
if (
|
||||
typeof agentTimeoutSeconds === "number" &&
|
||||
Number.isFinite(agentTimeoutSeconds) &&
|
||||
agentTimeoutSeconds > 0
|
||||
) {
|
||||
return Math.min(Math.floor(agentTimeoutSeconds) * 1000, MAX_SAFE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if (params?.trigger === "cron") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DEFAULT_LLM_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
|
||||
export type EmbeddedRunAttemptResult = {
|
||||
aborted: boolean;
|
||||
timedOut: boolean;
|
||||
/** True when the no-response LLM idle watchdog caused the timeout. */
|
||||
idleTimedOut: boolean;
|
||||
/** True if the timeout occurred while compaction was in progress or pending. */
|
||||
timedOutDuringCompaction: boolean;
|
||||
promptError: unknown;
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeAttemptResult(
|
||||
return {
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
idleTimedOut: false,
|
||||
timedOutDuringCompaction: false,
|
||||
promptError: null,
|
||||
promptErrorSource: null,
|
||||
|
||||
@@ -103,6 +103,7 @@ export function makeEmbeddedRunnerAttempt(
|
||||
return {
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
idleTimedOut: false,
|
||||
timedOutDuringCompaction: false,
|
||||
promptError: null,
|
||||
promptErrorSource: null,
|
||||
|
||||
Reference in New Issue
Block a user