|
| 1 | +import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime"; |
| 2 | +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; |
| 3 | +import type { CodexDynamicToolBridge } from "./dynamic-tools.js"; |
| 4 | +import { |
| 5 | + isJsonObject, |
| 6 | + type CodexDynamicToolCallParams, |
| 7 | + type CodexDynamicToolCallResponse, |
| 8 | + type JsonValue, |
| 9 | +} from "./protocol.js"; |
| 10 | + |
| 11 | +const CODEX_DYNAMIC_TOOL_TIMEOUT_MS = 30_000; |
| 12 | +const CODEX_DYNAMIC_TOOL_MAX_TIMEOUT_MS = 600_000; |
| 13 | +const CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS = 60_000; |
| 14 | +const LOG_FIELD_MAX_LENGTH = 160; |
| 15 | + |
| 16 | +export { |
| 17 | + CODEX_DYNAMIC_TOOL_TIMEOUT_MS, |
| 18 | + CODEX_DYNAMIC_TOOL_MAX_TIMEOUT_MS, |
| 19 | + CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS, |
| 20 | +}; |
| 21 | + |
| 22 | +export type CodexDynamicToolTimeoutConfig = OpenClawConfig | undefined; |
| 23 | + |
| 24 | +type DynamicToolTimeoutDetails = { |
| 25 | + responseMessage: string; |
| 26 | + consoleMessage: string; |
| 27 | + meta: Record<string, unknown>; |
| 28 | +}; |
| 29 | + |
| 30 | +function normalizeLogField(value: unknown): string | undefined { |
| 31 | + if (typeof value !== "string") { |
| 32 | + return undefined; |
| 33 | + } |
| 34 | + const normalized = value |
| 35 | + .replaceAll(String.fromCharCode(27), " ") |
| 36 | + .replaceAll("\r", " ") |
| 37 | + .replaceAll("\n", " ") |
| 38 | + .replaceAll("\t", " ") |
| 39 | + .trim(); |
| 40 | + if (!normalized) { |
| 41 | + return undefined; |
| 42 | + } |
| 43 | + return normalized.length > LOG_FIELD_MAX_LENGTH |
| 44 | + ? `${normalized.slice(0, LOG_FIELD_MAX_LENGTH - 3)}...` |
| 45 | + : normalized; |
| 46 | +} |
| 47 | + |
| 48 | +function readNumericTimeoutMs(value: unknown): number | undefined { |
| 49 | + if (typeof value === "number" && Number.isFinite(value)) { |
| 50 | + return Math.max(0, Math.floor(value)); |
| 51 | + } |
| 52 | + if (typeof value === "string") { |
| 53 | + const parsed = Number.parseInt(value.trim(), 10); |
| 54 | + if (Number.isFinite(parsed)) { |
| 55 | + return Math.max(0, Math.floor(parsed)); |
| 56 | + } |
| 57 | + } |
| 58 | + return undefined; |
| 59 | +} |
| 60 | + |
| 61 | +function formatDynamicToolTimeoutDetails(params: { |
| 62 | + call: CodexDynamicToolCallParams; |
| 63 | + timeoutMs: number; |
| 64 | +}): DynamicToolTimeoutDetails { |
| 65 | + const tool = normalizeLogField(params.call.tool) ?? "unknown"; |
| 66 | + const baseMeta: Record<string, unknown> = { |
| 67 | + tool: params.call.tool, |
| 68 | + toolCallId: params.call.callId, |
| 69 | + threadId: params.call.threadId, |
| 70 | + turnId: params.call.turnId, |
| 71 | + timeoutMs: params.timeoutMs, |
| 72 | + timeoutKind: "codex_dynamic_tool_rpc", |
| 73 | + }; |
| 74 | + |
| 75 | + if (tool !== "process" || !isJsonObject(params.call.arguments)) { |
| 76 | + return { |
| 77 | + responseMessage: `OpenClaw dynamic tool call timed out after ${params.timeoutMs}ms while running tool ${tool}.`, |
| 78 | + consoleMessage: `codex dynamic tool timeout: tool=${tool} toolTimeoutMs=${params.timeoutMs}; per-tool-call watchdog, not session idle`, |
| 79 | + meta: baseMeta, |
| 80 | + }; |
| 81 | + } |
| 82 | + |
| 83 | + const action = normalizeLogField(params.call.arguments.action); |
| 84 | + const sessionId = normalizeLogField(params.call.arguments.sessionId); |
| 85 | + const requestedTimeoutMs = readNumericTimeoutMs(params.call.arguments.timeout); |
| 86 | + const actionPart = action ? ` action=${action}` : ""; |
| 87 | + const sessionPart = sessionId ? ` sessionId=${sessionId}` : ""; |
| 88 | + const requestedPart = |
| 89 | + requestedTimeoutMs === undefined ? "" : ` requestedWaitMs=${requestedTimeoutMs}`; |
| 90 | + const retryHint = |
| 91 | + action === "poll" |
| 92 | + ? "; repeated lines usually mean process-poll retry churn, not model progress" |
| 93 | + : ""; |
| 94 | + const responseTarget = |
| 95 | + action || sessionId |
| 96 | + ? ` while waiting for process${actionPart}${sessionPart}` |
| 97 | + : " while waiting for the process tool"; |
| 98 | + |
| 99 | + return { |
| 100 | + responseMessage: `OpenClaw dynamic tool call timed out after ${params.timeoutMs}ms${responseTarget}. This is a tool RPC timeout, not a session idle timeout.`, |
| 101 | + consoleMessage: `codex process tool timeout:${actionPart}${sessionPart} toolTimeoutMs=${params.timeoutMs}${requestedPart}; per-tool-call watchdog, not session idle${retryHint}`, |
| 102 | + meta: { |
| 103 | + ...baseMeta, |
| 104 | + processAction: action, |
| 105 | + processSessionId: sessionId, |
| 106 | + processRequestedTimeoutMs: requestedTimeoutMs, |
| 107 | + }, |
| 108 | + }; |
| 109 | +} |
| 110 | + |
| 111 | +export function failedDynamicToolResponse(message: string): CodexDynamicToolCallResponse { |
| 112 | + return { |
| 113 | + success: false, |
| 114 | + contentItems: [{ type: "inputText", text: message }], |
| 115 | + }; |
| 116 | +} |
| 117 | + |
| 118 | +export async function handleDynamicToolCallWithTimeout(params: { |
| 119 | + call: CodexDynamicToolCallParams; |
| 120 | + toolBridge: Pick<CodexDynamicToolBridge, "handleToolCall">; |
| 121 | + signal: AbortSignal; |
| 122 | + timeoutMs: number; |
| 123 | + onTimeout?: () => void; |
| 124 | +}): Promise<CodexDynamicToolCallResponse> { |
| 125 | + if (params.signal.aborted) { |
| 126 | + return failedDynamicToolResponse("OpenClaw dynamic tool call aborted before execution."); |
| 127 | + } |
| 128 | + |
| 129 | + const controller = new AbortController(); |
| 130 | + let timeout: ReturnType<typeof setTimeout> | undefined; |
| 131 | + let timedOut = false; |
| 132 | + let resolveAbort: ((response: CodexDynamicToolCallResponse) => void) | undefined; |
| 133 | + const abortFromRun = () => { |
| 134 | + const message = "OpenClaw dynamic tool call aborted."; |
| 135 | + controller.abort(params.signal.reason ?? new Error(message)); |
| 136 | + resolveAbort?.(failedDynamicToolResponse(message)); |
| 137 | + }; |
| 138 | + const abortPromise = new Promise<CodexDynamicToolCallResponse>((resolve) => { |
| 139 | + resolveAbort = resolve; |
| 140 | + }); |
| 141 | + const timeoutPromise = new Promise<CodexDynamicToolCallResponse>((resolve) => { |
| 142 | + const timeoutMs = clampDynamicToolTimeoutMs(params.timeoutMs); |
| 143 | + timeout = setTimeout(() => { |
| 144 | + timedOut = true; |
| 145 | + const timeoutDetails = formatDynamicToolTimeoutDetails({ call: params.call, timeoutMs }); |
| 146 | + controller.abort(new Error(timeoutDetails.responseMessage)); |
| 147 | + params.onTimeout?.(); |
| 148 | + embeddedAgentLog.warn("codex dynamic tool call timed out", { |
| 149 | + ...timeoutDetails.meta, |
| 150 | + consoleMessage: timeoutDetails.consoleMessage, |
| 151 | + }); |
| 152 | + resolve(failedDynamicToolResponse(timeoutDetails.responseMessage)); |
| 153 | + }, timeoutMs); |
| 154 | + timeout.unref?.(); |
| 155 | + }); |
| 156 | + |
| 157 | + try { |
| 158 | + params.signal.addEventListener("abort", abortFromRun, { once: true }); |
| 159 | + if (params.signal.aborted) { |
| 160 | + abortFromRun(); |
| 161 | + } |
| 162 | + return await Promise.race([ |
| 163 | + params.toolBridge.handleToolCall(params.call, { signal: controller.signal }), |
| 164 | + abortPromise, |
| 165 | + timeoutPromise, |
| 166 | + ]); |
| 167 | + } catch (error) { |
| 168 | + return failedDynamicToolResponse(error instanceof Error ? error.message : String(error)); |
| 169 | + } finally { |
| 170 | + if (timeout) { |
| 171 | + clearTimeout(timeout); |
| 172 | + } |
| 173 | + params.signal.removeEventListener("abort", abortFromRun); |
| 174 | + resolveAbort = undefined; |
| 175 | + if (!timedOut && !controller.signal.aborted) { |
| 176 | + controller.abort(new Error("OpenClaw dynamic tool call finished.")); |
| 177 | + } |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | +export function resolveDynamicToolCallTimeoutMs(params: { |
| 182 | + call: CodexDynamicToolCallParams; |
| 183 | + config: CodexDynamicToolTimeoutConfig; |
| 184 | +}): number { |
| 185 | + return clampDynamicToolTimeoutMs( |
| 186 | + readDynamicToolCallTimeoutMs(params.call.arguments) ?? |
| 187 | + readConfiguredDynamicToolTimeoutMs(params.call.tool, params.config) ?? |
| 188 | + CODEX_DYNAMIC_TOOL_TIMEOUT_MS, |
| 189 | + ); |
| 190 | +} |
| 191 | + |
| 192 | +function readDynamicToolCallTimeoutMs(value: JsonValue | undefined): number | undefined { |
| 193 | + if (!isJsonObject(value)) { |
| 194 | + return undefined; |
| 195 | + } |
| 196 | + return readPositiveFiniteTimeoutMs(value.timeoutMs); |
| 197 | +} |
| 198 | + |
| 199 | +function readConfiguredDynamicToolTimeoutMs( |
| 200 | + toolName: string, |
| 201 | + config: CodexDynamicToolTimeoutConfig, |
| 202 | +): number | undefined { |
| 203 | + if (toolName === "image_generate") { |
| 204 | + const imageGenerationModel = config?.agents?.defaults?.imageGenerationModel; |
| 205 | + if (!imageGenerationModel || typeof imageGenerationModel !== "object") { |
| 206 | + return undefined; |
| 207 | + } |
| 208 | + return readPositiveFiniteTimeoutMs(imageGenerationModel.timeoutMs); |
| 209 | + } |
| 210 | + |
| 211 | + if (toolName === "image") { |
| 212 | + return ( |
| 213 | + readTimeoutSecondsAsMs(config?.tools?.media?.image?.timeoutSeconds) ?? |
| 214 | + CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS |
| 215 | + ); |
| 216 | + } |
| 217 | + |
| 218 | + return undefined; |
| 219 | +} |
| 220 | + |
| 221 | +function readTimeoutSecondsAsMs(value: unknown): number | undefined { |
| 222 | + const seconds = readPositiveFiniteTimeoutMs(value); |
| 223 | + return seconds === undefined ? undefined : seconds * 1000; |
| 224 | +} |
| 225 | + |
| 226 | +function readPositiveFiniteTimeoutMs(value: unknown): number | undefined { |
| 227 | + return typeof value === "number" && Number.isFinite(value) && value > 0 |
| 228 | + ? Math.floor(value) |
| 229 | + : undefined; |
| 230 | +} |
| 231 | + |
| 232 | +function clampDynamicToolTimeoutMs(timeoutMs: number): number { |
| 233 | + return Math.max(1, Math.min(CODEX_DYNAMIC_TOOL_MAX_TIMEOUT_MS, Math.floor(timeoutMs))); |
| 234 | +} |
0 commit comments