diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 20a33f83bcc..8c8456ced00 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -2141,6 +2141,7 @@ export async function runEmbeddedAttempt( trigger: params.trigger, runTimeoutMs: params.timeoutMs !== configuredRunTimeoutMs ? params.timeoutMs : undefined, modelRequestTimeoutMs: (params.model as { requestTimeoutMs?: number }).requestTimeoutMs, + model: params.model as { baseUrl?: string }, }); if (idleTimeoutMs > 0) { activeSession.agent.streamFn = streamWithIdleTimeout( diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts b/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts index a750667dc8d..230897e4267 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/llm-idle-timeout.test.ts @@ -85,6 +85,51 @@ describe("resolveLlmIdleTimeoutMs", () => { const cfg = { agents: { defaults: { timeoutSeconds: 300 } } } as OpenClawConfig; expect(resolveLlmIdleTimeoutMs({ cfg, trigger: "cron" })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS); }); + + it.each([ + "http://localhost:11434", + "http://127.0.0.1:11434", + "http://0.0.0.0:11434", + "http://[::1]:11434", + "http://my-rig.local:11434", + "http://10.0.0.5:11434", + "http://172.16.5.10:11434", + "http://192.168.1.20:11434", + ])("disables the default idle watchdog for local provider baseUrl %s", (baseUrl) => { + expect(resolveLlmIdleTimeoutMs({ model: { baseUrl } })).toBe(0); + }); + + it("keeps the default idle watchdog for remote provider baseUrls", () => { + expect(resolveLlmIdleTimeoutMs({ model: { baseUrl: "https://api.openai.com/v1" } })).toBe( + DEFAULT_LLM_IDLE_TIMEOUT_MS, + ); + expect(resolveLlmIdleTimeoutMs({ model: { baseUrl: "https://ollama.com" } })).toBe( + DEFAULT_LLM_IDLE_TIMEOUT_MS, + ); + }); + + it("ignores malformed baseUrl and keeps the default idle watchdog", () => { + expect(resolveLlmIdleTimeoutMs({ model: { baseUrl: "not-a-url" } })).toBe( + DEFAULT_LLM_IDLE_TIMEOUT_MS, + ); + expect(resolveLlmIdleTimeoutMs({ model: { baseUrl: "" } })).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS); + }); + + it("still honors an explicit provider request timeout for local providers", () => { + expect( + resolveLlmIdleTimeoutMs({ + model: { baseUrl: "http://127.0.0.1:11434" }, + modelRequestTimeoutMs: 600_000, + }), + ).toBe(600_000); + }); + + it("still applies agents.defaults.timeoutSeconds cap for local providers", () => { + const cfg = { agents: { defaults: { timeoutSeconds: 30 } } } as OpenClawConfig; + expect(resolveLlmIdleTimeoutMs({ cfg, model: { baseUrl: "http://127.0.0.1:11434" } })).toBe( + 30_000, + ); + }); }); describe("streamWithIdleTimeout", () => { diff --git a/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts b/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts index 2d680247894..cbc1e66ee08 100644 --- a/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts +++ b/src/agents/pi-embedded-runner/run/llm-idle-timeout.ts @@ -15,6 +15,43 @@ export const DEFAULT_LLM_IDLE_TIMEOUT_MS = DEFAULT_LLM_IDLE_TIMEOUT_SECONDS * 10 */ const MAX_SAFE_TIMEOUT_MS = 2_147_000_000; +/** + * Detects loopback / private-network / `.local` base URLs. Local providers + * (Ollama, LM Studio, llama.cpp) legitimately stay silent for many minutes + * during prompt evaluation and thinking, so the network-silence-as-hang + * heuristic that motivates the default idle watchdog does not apply. + */ +function isLocalProviderBaseUrl(baseUrl: string): boolean { + let host: string; + try { + host = new URL(baseUrl).hostname.toLowerCase(); + } catch { + return false; + } + if (host.startsWith("[") && host.endsWith("]")) { + host = host.slice(1, -1); + } + if ( + host === "localhost" || + host === "127.0.0.1" || + host === "0.0.0.0" || + host === "::1" || + host === "::ffff:7f00:1" || + host === "::ffff:127.0.0.1" || + host.endsWith(".local") + ) { + return true; + } + const octets = host.split(".").map((part) => Number.parseInt(part, 10)); + if (octets.length !== 4 || octets.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) { + return false; + } + const [a, b] = octets; + return ( + a === 10 || (a === 172 && b !== undefined && b >= 16 && b <= 31) || (a === 192 && b === 168) + ); +} + /** * Resolves the LLM idle timeout from configuration. * @returns Idle timeout in milliseconds, or 0 to disable @@ -24,6 +61,7 @@ export function resolveLlmIdleTimeoutMs(params?: { trigger?: EmbeddedRunTrigger; runTimeoutMs?: number; modelRequestTimeoutMs?: number; + model?: { baseUrl?: string }; }): number { const clampTimeoutMs = (valueMs: number) => Math.min(Math.floor(valueMs), MAX_SAFE_TIMEOUT_MS); const clampImplicitTimeoutMs = (valueMs: number) => @@ -72,6 +110,16 @@ export function resolveLlmIdleTimeoutMs(params?: { return 0; } + // The default watchdog is a network-silence-as-hang guard for cloud providers. + // Local providers can legitimately stream nothing for many minutes during + // prompt evaluation or thinking, so falling back to the default would abort + // valid local runs. Honor it only when the user has not opted out via the + // baseUrl pointing at loopback / private-network / `.local`. + const baseUrl = params?.model?.baseUrl; + if (typeof baseUrl === "string" && baseUrl.length > 0 && isLocalProviderBaseUrl(baseUrl)) { + return 0; + } + return DEFAULT_LLM_IDLE_TIMEOUT_MS; }