Skip to content

Commit aa702cf

Browse files
clawsweeper[bot]SymbolStarosolmaz
authored
fix(qqbot): derive outbound watchdog from configured timeouts (#85267) (#86500)
Summary: - The branch replaces QQBot's hardcoded outbound response watchdog with a resolver based on existing agent/provider `timeoutSeconds` settings, adds regression tests, and updates the changelog. - PR surface: Source +113, Tests +116, Docs +1. Total +230 across 5 files. - Reproducibility: yes. at source level: current main and the latest release use a hardcoded 300000 ms QQBot o ... s an 1800s provider timeout. I did not run the reporter's live QQBot/Ollama setup in this read-only review. Automerge notes: - PR branch already contained follow-up commit before automerge: test(qqbot): cover slow provider response watchdog - PR branch already contained follow-up commit before automerge: fix(qqbot): derive outbound watchdog from configured timeouts (#85267) - PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8527… Validation: - ClawSweeper review passed for head 7bd8292. - Required merge gates passed before the squash merge. Prepared head SHA: 7bd8292 Review: #86500 (comment) Co-authored-by: SymbolStar <symbolstar@users.noreply.github.com> Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: osolmaz Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
1 parent 6f695c1 commit aa702cf

5 files changed

Lines changed: 232 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
3333
- Cron: seed active scheduled and manual cron task rows with a progress summary so status surfaces do not look blank while jobs run. (#86313) Thanks @ferminquant.
3434
- Updater: exclude prerelease git tags from stable channel resolution so source updates do not check out newer alpha/rc/preview/canary tags. (#86260) Thanks @stevenepalmer.
3535
- Security/Audit: flag webhook `hooks.token` reuse of active Gateway password auth in `openclaw security audit` while keeping password-mode startup compatibility. (#84338) Thanks @coygeek.
36+
- QQBot: derive the outbound reply watchdog from configured agent and provider timeouts so slow local model replies are not cut off at five minutes. Fixes #85267. (#85271) Thanks @SymbolStar.
3637
- Agents/heartbeat: stop heartbeat turns after the first valid `heartbeat_respond` so repeated response loops do not burn tokens. (#86357) Thanks @udaymanish6.
3738
- Tasks: keep retained lost tasks out of default status health counts, explain their cleanup window during maintenance, and prune lost task records after 24 hours instead of the general 7-day terminal retention.
3839
- Memory-core: keep REM dreaming focused on live light-staged memories and mark staged entries as considered so old recall history no longer dominates fresh candidates. (#86302) Thanks @SebTardif.

extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,47 @@ describe("dispatchOutbound", () => {
171171
vi.clearAllMocks();
172172
});
173173

174+
it("keeps waiting past 300s when a slow provider timeout is configured", async () => {
175+
vi.useFakeTimers();
176+
try {
177+
const runtime = makeRuntime({
178+
onDeliver: async (deliver) => {
179+
await new Promise<void>((resolve) => setTimeout(resolve, 301_000));
180+
await deliver({ text: "late answer" }, { kind: "block" });
181+
},
182+
});
183+
let settled = false;
184+
185+
const dispatchPromise = dispatchOutbound(makeInbound(), {
186+
runtime,
187+
cfg: {
188+
models: { providers: { ollama: { timeoutSeconds: 1800 } } },
189+
},
190+
account,
191+
}).finally(() => {
192+
settled = true;
193+
});
194+
195+
await vi.advanceTimersByTimeAsync(300_000);
196+
197+
expect(settled).toBe(false);
198+
expect(sendTextMock).not.toHaveBeenCalled();
199+
200+
await vi.advanceTimersByTimeAsync(1_000);
201+
await dispatchPromise;
202+
203+
expect(sendTextMock).toHaveBeenCalledWith(
204+
expect.anything(),
205+
"late answer",
206+
expect.anything(),
207+
expect.anything(),
208+
);
209+
} finally {
210+
vi.clearAllTimers();
211+
vi.useRealTimers();
212+
}
213+
});
214+
174215
it("marks voice-only inbound as audio without adding voice paths to MediaPaths", async () => {
175216
let finalized: Record<string, unknown> | undefined;
176217
const runtime = makeRuntime({ onFinalize: (ctx) => (finalized = ctx) });

extensions/qqbot/src/engine/gateway/outbound-dispatch.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { StreamingController, shouldUseOfficialC2cStream } from "../messaging/streaming-c2c.js";
3434
import { audioFileToSilkBase64 } from "../utils/audio.js";
3535
import type { InboundContext } from "./inbound-context.js";
36+
import { resolveResponseTimeoutMs } from "./response-timeout.js";
3637
import type {
3738
GatewayAccount,
3839
EngineLogger,
@@ -42,7 +43,12 @@ import type {
4243

4344
// ============ Config ============
4445

45-
const RESPONSE_TIMEOUT = 300_000;
46+
// Historical floor for the QQBot outbound response watchdog (5 min). The
47+
// effective wait budget is now derived from existing
48+
// `agents.defaults.timeoutSeconds` and `models.providers.<id>.timeoutSeconds`
49+
// via `resolveResponseTimeoutMs(cfg)` — see issue #85267, where a slow
50+
// local ollama/qwen3.5:27b turn was capped at 5 min despite a configured
51+
// 1800s provider timeout.
4652
const TOOL_ONLY_TIMEOUT = 60_000;
4753
const MAX_TOOL_RENEWALS = 3;
4854
const TOOL_MEDIA_SEND_TIMEOUT = 45_000;
@@ -149,12 +155,16 @@ export async function dispatchOutbound(
149155
};
150156

151157
// ---- Timeout promise ----
158+
// #85267: derive watchdog from existing agent / provider timeout config so
159+
// a longer configured ceiling (e.g. slow local ollama models) is not
160+
// silently undercut by a plugin-local 5-minute cap.
161+
const responseTimeoutMs = resolveResponseTimeoutMs(cfg);
152162
const timeoutPromise = new Promise<void>((_, reject) => {
153163
timeoutId = setTimeout(() => {
154164
if (!hasResponse) {
155165
reject(new Error("Response timeout"));
156166
}
157-
}, RESPONSE_TIMEOUT);
167+
}, responseTimeoutMs);
158168
});
159169

160170
// ---- Deliver deps ----
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
DEFAULT_RESPONSE_TIMEOUT_MS,
4+
resolveResponseTimeoutMs,
5+
} from "./response-timeout.js";
6+
7+
describe("resolveResponseTimeoutMs", () => {
8+
it("falls back to the historical 5-minute floor when no timeouts configured", () => {
9+
expect(resolveResponseTimeoutMs({})).toBe(DEFAULT_RESPONSE_TIMEOUT_MS);
10+
expect(resolveResponseTimeoutMs(undefined)).toBe(DEFAULT_RESPONSE_TIMEOUT_MS);
11+
expect(resolveResponseTimeoutMs(null)).toBe(DEFAULT_RESPONSE_TIMEOUT_MS);
12+
});
13+
14+
it("honors longer agents.defaults.timeoutSeconds", () => {
15+
expect(
16+
resolveResponseTimeoutMs({ agents: { defaults: { timeoutSeconds: 900 } } }),
17+
).toBe(900_000);
18+
});
19+
20+
it("ignores agents.defaults.timeoutSeconds shorter than the historical floor", () => {
21+
// Issue #85267: a configured 60s agent timeout must not undercut the
22+
// historical 5-minute watchdog floor for previously-working setups.
23+
expect(
24+
resolveResponseTimeoutMs({ agents: { defaults: { timeoutSeconds: 60 } } }),
25+
).toBe(DEFAULT_RESPONSE_TIMEOUT_MS);
26+
});
27+
28+
it("honors models.providers.<id>.timeoutSeconds for slow local providers (#85267)", () => {
29+
// Direct repro shape: ollama + qwen3.5:27b with 1800s timeout. Without
30+
// this fix, QQBot capped at 300s and surfaced "LLM request timed out".
31+
expect(
32+
resolveResponseTimeoutMs({
33+
models: { providers: { ollama: { timeoutSeconds: 1800 } } },
34+
}),
35+
).toBe(1_800_000);
36+
});
37+
38+
it("takes the maximum across multiple configured providers and agents", () => {
39+
expect(
40+
resolveResponseTimeoutMs({
41+
agents: { defaults: { timeoutSeconds: 600 } },
42+
models: {
43+
providers: {
44+
ollama: { timeoutSeconds: 1800 },
45+
"lm-studio": { timeoutSeconds: 900 },
46+
openai: { timeoutSeconds: 60 },
47+
},
48+
},
49+
}),
50+
).toBe(1_800_000);
51+
});
52+
53+
it("ignores non-positive or non-numeric timeout values", () => {
54+
expect(
55+
resolveResponseTimeoutMs({
56+
agents: { defaults: { timeoutSeconds: -1 } },
57+
models: {
58+
providers: {
59+
ollama: { timeoutSeconds: 0 },
60+
broken: { timeoutSeconds: "1800" as unknown as number },
61+
naN: { timeoutSeconds: Number.NaN },
62+
},
63+
},
64+
}),
65+
).toBe(DEFAULT_RESPONSE_TIMEOUT_MS);
66+
});
67+
68+
it("clamps to MAX_SAFE_TIMEOUT_MS for absurd inputs", () => {
69+
const huge = resolveResponseTimeoutMs({
70+
models: { providers: { ollama: { timeoutSeconds: 10_000_000 } } },
71+
});
72+
expect(huge).toBeLessThanOrEqual(2_147_000_000);
73+
expect(huge).toBeGreaterThan(DEFAULT_RESPONSE_TIMEOUT_MS);
74+
});
75+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* QQBot outbound response watchdog timeout resolver.
3+
*
4+
* Background — issue #85267:
5+
* The reporter ran openclaw + ollama + `qwen3.5:27b` (a slow local model)
6+
* with `models.providers.ollama.timeoutSeconds: 1800` and saw the
7+
* QQBot reply path abort at ~5 minutes with "LLM request timed out",
8+
* despite the direct ollama call to the same model working. The
9+
* embedded-runner / idle-timeout layer already honors longer
10+
* provider timeouts (see `src/agents/pi-embedded-runner/run/llm-idle-timeout.ts`),
11+
* but the QQBot outbound dispatcher held an independent hardcoded
12+
* `RESPONSE_TIMEOUT = 300_000` watchdog that quietly undercut the
13+
* configured ceiling.
14+
*
15+
* Fix shape (clawsweeper `clawsweeper:fix-shape-clear`):
16+
* Don't add a new QQBot-only knob. Instead derive the QQBot wait
17+
* budget from the existing agent/provider timeout settings the user
18+
* already configured:
19+
* - `agents.defaults.timeoutSeconds`
20+
* - `models.providers.<id>.timeoutSeconds` (max across configured providers)
21+
* Take the maximum and clamp to `[DEFAULT_RESPONSE_TIMEOUT_MS, MAX_SAFE_TIMEOUT_MS]`.
22+
* The default floor preserves the existing 5-minute guard for users
23+
* that have not configured any longer ceiling — i.e. a no-op for
24+
* typical cloud-model deployments.
25+
*/
26+
27+
/**
28+
* Default QQBot outbound response watchdog when no config override is
29+
* present. Preserves the historical 5-minute guard for unconfigured
30+
* deployments.
31+
*/
32+
export const DEFAULT_RESPONSE_TIMEOUT_MS = 300_000;
33+
34+
/**
35+
* Upper bound to keep the watchdog inside the safe `setTimeout` range
36+
* (approximately 24.8 days). Mirrors `MAX_SAFE_TIMEOUT_MS` in
37+
* `src/agents/pi-embedded-runner/run/llm-idle-timeout.ts`.
38+
*/
39+
const MAX_SAFE_TIMEOUT_MS = 2_147_000_000;
40+
41+
interface AgentsDefaultsLike {
42+
timeoutSeconds?: unknown;
43+
}
44+
45+
interface AgentsBlockLike {
46+
defaults?: AgentsDefaultsLike;
47+
}
48+
49+
interface ProviderEntryLike {
50+
timeoutSeconds?: unknown;
51+
}
52+
53+
interface ModelsBlockLike {
54+
providers?: Record<string, ProviderEntryLike | undefined> | undefined;
55+
}
56+
57+
interface CfgShape {
58+
agents?: AgentsBlockLike;
59+
models?: ModelsBlockLike;
60+
}
61+
62+
function positiveSecondsToMs(value: unknown): number | undefined {
63+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
64+
return undefined;
65+
}
66+
return Math.floor(value * 1000);
67+
}
68+
69+
/**
70+
* Resolve the QQBot outbound response watchdog (ms).
71+
*
72+
* The watchdog is the longest of:
73+
* - `DEFAULT_RESPONSE_TIMEOUT_MS` (5 min, historical floor)
74+
* - `cfg.agents.defaults.timeoutSeconds` converted to ms
75+
* - the maximum `cfg.models.providers.<id>.timeoutSeconds` across
76+
* configured providers, converted to ms
77+
*
78+
* Returns at most `MAX_SAFE_TIMEOUT_MS` so the chosen value is always
79+
* a safe `setTimeout` argument.
80+
*/
81+
export function resolveResponseTimeoutMs(cfg: unknown): number {
82+
const candidates: number[] = [DEFAULT_RESPONSE_TIMEOUT_MS];
83+
84+
const typed = (cfg ?? {}) as CfgShape;
85+
86+
const agentDefaultMs = positiveSecondsToMs(typed.agents?.defaults?.timeoutSeconds);
87+
if (agentDefaultMs !== undefined) {
88+
candidates.push(agentDefaultMs);
89+
}
90+
91+
const providers = typed.models?.providers;
92+
if (providers && typeof providers === "object") {
93+
for (const entry of Object.values(providers)) {
94+
const providerMs = positiveSecondsToMs(entry?.timeoutSeconds);
95+
if (providerMs !== undefined) {
96+
candidates.push(providerMs);
97+
}
98+
}
99+
}
100+
101+
const chosen = Math.max(...candidates);
102+
return Math.min(chosen, MAX_SAFE_TIMEOUT_MS);
103+
}

0 commit comments

Comments
 (0)