Skip to content

Commit d9dc757

Browse files
committed
fix: align LLM idle timeout policy
1 parent e65d6eb commit d9dc757

12 files changed

Lines changed: 106 additions & 16 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- Control UI: guard stale session-history reloads during fast session switches so the selected session and rendered transcript stay in sync. (#62975) Thanks @scoootscooob.
1616
- 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.
1717
- 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.
18+
- 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.
1819

1920
## 2026.4.8
2021

docs/concepts/agent-loop.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook AP
151151

152152
- `agent.wait` default: 30s (just the wait). `timeoutMs` param overrides.
153153
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedPiAgent` abort timer.
154+
- 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.
154155

155156
## Where things can end early
156157

src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
184184
return {
185185
aborted: false,
186186
timedOut: false,
187+
idleTimedOut: false,
187188
timedOutDuringCompaction: false,
188189
promptError: null,
189190
promptErrorSource: null,

src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function makeAttemptResult(
3535
return {
3636
aborted: false,
3737
timedOut: false,
38+
idleTimedOut: false,
3839
timedOutDuringCompaction: false,
3940
promptError: null,
4041
promptErrorSource: null,

src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,25 @@ describe("timeout-triggered compaction", () => {
221221
expect(result.payloads?.[0]?.text).toContain("timed out");
222222
});
223223

224+
it("points idle-timeout errors at the LLM idle timeout config key", async () => {
225+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
226+
makeAttemptResult({
227+
timedOut: true,
228+
idleTimedOut: true,
229+
lastAssistant: {
230+
usage: { input: 20000 },
231+
} as never,
232+
}),
233+
);
234+
235+
const result = await runEmbeddedPiAgent(overflowBaseRunParams);
236+
237+
expect(mockedCompactDirect).not.toHaveBeenCalled();
238+
expect(result.payloads?.[0]?.isError).toBe(true);
239+
expect(result.payloads?.[0]?.text).toContain("agents.defaults.llm.idleTimeoutSeconds");
240+
expect(result.payloads?.[0]?.text).not.toContain("agents.defaults.timeoutSeconds");
241+
});
242+
224243
it("does not attempt compaction for low-context timeouts on later retries", async () => {
225244
mockedPickFallbackThinkingLevel.mockReturnValueOnce("low");
226245
mockedRunEmbeddedAttempt

src/agents/pi-embedded-runner/run.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,7 @@ export async function runEmbeddedPiAgent(
688688
promptErrorSource,
689689
preflightRecovery,
690690
timedOut,
691+
idleTimedOut,
691692
timedOutDuringCompaction,
692693
sessionIdUsed,
693694
lastAssistant,
@@ -1433,12 +1434,15 @@ export async function runEmbeddedPiAgent(
14331434
// Emit an explicit timeout error instead of silently completing, so
14341435
// callers do not lose the turn as an orphaned user message.
14351436
if (timedOut && !timedOutDuringCompaction && payloads.length === 0) {
1437+
const timeoutText = idleTimedOut
1438+
? "The model did not produce a response before the LLM idle timeout. " +
1439+
"Please try again, or increase `agents.defaults.llm.idleTimeoutSeconds` in your config (set to 0 to disable)."
1440+
: "Request timed out before a response was generated. " +
1441+
"Please try again, or increase `agents.defaults.timeoutSeconds` in your config.";
14361442
return {
14371443
payloads: [
14381444
{
1439-
text:
1440-
"Request timed out before a response was generated. " +
1441-
"Please try again, or increase `agents.defaults.timeoutSeconds` in your config.",
1445+
text: timeoutText,
14421446
isError: true,
14431447
},
14441448
],

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1266,7 +1266,10 @@ export async function runEmbeddedAttempt(
12661266
let idleTimeoutTrigger: ((error: Error) => void) | undefined;
12671267

12681268
// Wrap stream with idle timeout detection
1269-
const idleTimeoutMs = resolveLlmIdleTimeoutMs(params.config);
1269+
const idleTimeoutMs = resolveLlmIdleTimeoutMs({
1270+
cfg: params.config,
1271+
trigger: params.trigger,
1272+
});
12701273
if (idleTimeoutMs > 0) {
12711274
activeSession.agent.streamFn = streamWithIdleTimeout(
12721275
activeSession.agent.streamFn,
@@ -1377,6 +1380,7 @@ export async function runEmbeddedAttempt(
13771380
let aborted = Boolean(params.abortSignal?.aborted);
13781381
let yieldAborted = false;
13791382
let timedOut = false;
1383+
let idleTimedOut = false;
13801384
let timedOutDuringCompaction = false;
13811385
const getAbortReason = (signal: AbortSignal): unknown =>
13821386
"reason" in signal ? (signal as { reason?: unknown }).reason : undefined;
@@ -1426,6 +1430,7 @@ export async function runEmbeddedAttempt(
14261430
void activeSession.abort();
14271431
};
14281432
idleTimeoutTrigger = (error) => {
1433+
idleTimedOut = true;
14291434
abortRun(true, error);
14301435
};
14311436
const abortable = <T>(promise: Promise<T>): Promise<T> => {
@@ -2327,6 +2332,7 @@ export async function runEmbeddedAttempt(
23272332
itemLifecycle: getItemLifecycle(),
23282333
aborted,
23292334
timedOut,
2335+
idleTimedOut,
23302336
timedOutDuringCompaction,
23312337
promptError,
23322338
promptErrorSource,

src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,46 +8,82 @@ import {
88

99
describe("resolveLlmIdleTimeoutMs", () => {
1010
it("returns default when config is undefined", () => {
11-
expect(resolveLlmIdleTimeoutMs(undefined)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
11+
expect(resolveLlmIdleTimeoutMs()).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
1212
});
1313

1414
it("returns default when llm config is missing", () => {
1515
const cfg = { agents: {} } as OpenClawConfig;
16-
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
16+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
1717
});
1818

1919
it("returns default when idleTimeoutSeconds is not set", () => {
2020
const cfg = { agents: { defaults: { llm: {} } } } as OpenClawConfig;
21-
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
21+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
2222
});
2323

2424
it("returns 0 when idleTimeoutSeconds is 0 (disabled)", () => {
2525
const cfg = { agents: { defaults: { llm: { idleTimeoutSeconds: 0 } } } } as OpenClawConfig;
26-
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(0);
26+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(0);
2727
});
2828

2929
it("returns configured value in milliseconds", () => {
3030
const cfg = { agents: { defaults: { llm: { idleTimeoutSeconds: 30 } } } } as OpenClawConfig;
31-
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(30_000);
31+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(30_000);
3232
});
3333

3434
it("caps at max safe timeout", () => {
3535
const cfg = {
3636
agents: { defaults: { llm: { idleTimeoutSeconds: 10_000_000 } } },
3737
} as OpenClawConfig;
38-
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(2_147_000_000);
38+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(2_147_000_000);
3939
});
4040

4141
it("ignores negative values", () => {
4242
const cfg = { agents: { defaults: { llm: { idleTimeoutSeconds: -10 } } } } as OpenClawConfig;
43-
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
43+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
4444
});
4545

4646
it("ignores non-finite values", () => {
4747
const cfg = {
4848
agents: { defaults: { llm: { idleTimeoutSeconds: Infinity } } },
4949
} as OpenClawConfig;
50-
expect(resolveLlmIdleTimeoutMs(cfg)).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
50+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS);
51+
});
52+
53+
it("falls back to agents.defaults.timeoutSeconds when llm.idleTimeoutSeconds is not set", () => {
54+
const cfg = { agents: { defaults: { timeoutSeconds: 300 } } } as OpenClawConfig;
55+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(300_000);
56+
});
57+
58+
it("prefers llm.idleTimeoutSeconds over agents.defaults.timeoutSeconds", () => {
59+
const cfg = {
60+
agents: { defaults: { timeoutSeconds: 300, llm: { idleTimeoutSeconds: 120 } } },
61+
} as OpenClawConfig;
62+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(120_000);
63+
});
64+
65+
it("keeps idleTimeoutSeconds=0 disabled even when timeoutSeconds is set", () => {
66+
const cfg = {
67+
agents: { defaults: { timeoutSeconds: 300, llm: { idleTimeoutSeconds: 0 } } },
68+
} as OpenClawConfig;
69+
expect(resolveLlmIdleTimeoutMs({ cfg })).toBe(0);
70+
});
71+
72+
it("disables the default idle timeout for cron when no timeout is configured", () => {
73+
expect(resolveLlmIdleTimeoutMs({ trigger: "cron" })).toBe(0);
74+
75+
const cfg = { agents: { defaults: { llm: {} } } } as OpenClawConfig;
76+
expect(resolveLlmIdleTimeoutMs({ cfg, trigger: "cron" })).toBe(0);
77+
});
78+
79+
it("uses agents.defaults.timeoutSeconds for cron before disabling the default idle timeout", () => {
80+
const cfg = { agents: { defaults: { timeoutSeconds: 300 } } } as OpenClawConfig;
81+
expect(resolveLlmIdleTimeoutMs({ cfg, trigger: "cron" })).toBe(300_000);
82+
});
83+
84+
it("keeps an explicit cron idle timeout when configured", () => {
85+
const cfg = { agents: { defaults: { llm: { idleTimeoutSeconds: 45 } } } } as OpenClawConfig;
86+
expect(resolveLlmIdleTimeoutMs({ cfg, trigger: "cron" })).toBe(45_000);
5187
});
5288
});
5389

src/agents/pi-embedded-runner/run/llm-idle-timeout.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { StreamFn } from "@mariozechner/pi-agent-core";
22
import { streamSimple } from "@mariozechner/pi-ai";
33
import type { OpenClawConfig } from "../../../config/config.js";
4+
import type { EmbeddedRunTrigger } from "./params.js";
45

56
/**
67
* Default idle timeout for LLM streaming responses in milliseconds.
@@ -17,18 +18,34 @@ const MAX_SAFE_TIMEOUT_MS = 2_147_000_000;
1718

1819
/**
1920
* Resolves the LLM idle timeout from configuration.
20-
* @param cfg - OpenClaw configuration
2121
* @returns Idle timeout in milliseconds, or 0 to disable
2222
*/
23-
export function resolveLlmIdleTimeoutMs(cfg?: OpenClawConfig): number {
24-
const raw = cfg?.agents?.defaults?.llm?.idleTimeoutSeconds;
25-
// 0 means disabled (no timeout)
23+
export function resolveLlmIdleTimeoutMs(params?: {
24+
cfg?: OpenClawConfig;
25+
trigger?: EmbeddedRunTrigger;
26+
}): number {
27+
const raw = params?.cfg?.agents?.defaults?.llm?.idleTimeoutSeconds;
28+
// 0 means explicitly disabled (no timeout).
2629
if (raw === 0) {
2730
return 0;
2831
}
2932
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
3033
return Math.min(Math.floor(raw) * 1000, MAX_SAFE_TIMEOUT_MS);
3134
}
35+
36+
const agentTimeoutSeconds = params?.cfg?.agents?.defaults?.timeoutSeconds;
37+
if (
38+
typeof agentTimeoutSeconds === "number" &&
39+
Number.isFinite(agentTimeoutSeconds) &&
40+
agentTimeoutSeconds > 0
41+
) {
42+
return Math.min(Math.floor(agentTimeoutSeconds) * 1000, MAX_SAFE_TIMEOUT_MS);
43+
}
44+
45+
if (params?.trigger === "cron") {
46+
return 0;
47+
}
48+
3249
return DEFAULT_LLM_IDLE_TIMEOUT_MS;
3350
}
3451

src/agents/pi-embedded-runner/run/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & {
3939
export type EmbeddedRunAttemptResult = {
4040
aborted: boolean;
4141
timedOut: boolean;
42+
/** True when the no-response LLM idle watchdog caused the timeout. */
43+
idleTimedOut: boolean;
4244
/** True if the timeout occurred while compaction was in progress or pending. */
4345
timedOutDuringCompaction: boolean;
4446
promptError: unknown;

0 commit comments

Comments
 (0)