From 310856e81f80303d98a637242d282be7b9defce6 Mon Sep 17 00:00:00 2001 From: goofoo Date: Thu, 16 Apr 2026 12:06:49 -0400 Subject: [PATCH] Fix: add watchdog timer to unblock Gemini when OpenClaw stalls Tool calls involving web search or slow external services could leave Gemini stuck in the "execute" state indefinitely. The URLSession has a 120s hard timeout, but that's long enough to make the conversation feel completely frozen. Race the delegate call against a 60s watchdog using withTaskGroup. The first result wins and cancels the other child task. If the watchdog fires, Gemini receives a clear timeout failure and can speak an apology instead of hanging silently. Fixes #12 Co-Authored-By: Claude Sonnet 4.6 --- .../OpenClaw/ToolCallRouter.swift | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/samples/CameraAccess/CameraAccess/OpenClaw/ToolCallRouter.swift b/samples/CameraAccess/CameraAccess/OpenClaw/ToolCallRouter.swift index a20babf4..9e8e2b4f 100644 --- a/samples/CameraAccess/CameraAccess/OpenClaw/ToolCallRouter.swift +++ b/samples/CameraAccess/CameraAccess/OpenClaw/ToolCallRouter.swift @@ -7,6 +7,12 @@ class ToolCallRouter { private var consecutiveFailures = 0 private let maxConsecutiveFailures = 3 + /// Maximum time to wait for a single tool call before returning a timeout + /// failure to Gemini. Kept well below the URLSession 120s hard limit so the + /// conversation unblocks in a reasonable time even when OpenClaw is slow + /// (e.g. a web search that never finishes). + private let toolCallTimeoutSeconds: UInt64 = 60 + init(bridge: OpenClawBridge) { self.bridge = bridge } @@ -38,7 +44,26 @@ class ToolCallRouter { let task = Task { @MainActor in let taskDesc = call.args["task"] as? String ?? String(describing: call.args) - let result = await bridge.delegateTask(task: taskDesc, toolName: callName) + let timeoutSeconds = self.toolCallTimeoutSeconds + + // Race the actual delegate call against a watchdog timer. Whichever + // finishes first wins; the other child task is cancelled immediately. + let result = await withTaskGroup(of: ToolResult.self) { group in + group.addTask { + await self.bridge.delegateTask(task: taskDesc, toolName: callName) + } + group.addTask { + try? await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) + NSLog("[ToolCall] Watchdog fired for %@ after %llu seconds", callId, timeoutSeconds) + return .failure( + "The gateway did not respond within \(timeoutSeconds) seconds. " + + "Tell the user the action timed out and suggest they try again." + ) + } + let first = await group.next()! + group.cancelAll() + return first + } guard !Task.isCancelled else { NSLog("[ToolCall] Task %@ was cancelled, skipping response", callId)