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)