Skip to content

Commit 1e6cbfe

Browse files
wizdomhall-hashWiz Rouzard
authored andcommitted
fix(codex): expose bound conversation dynamic tools
1 parent e399a92 commit 1e6cbfe

9 files changed

Lines changed: 1357 additions & 26 deletions
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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+
}

extensions/codex/src/app-server/session-binding.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type CodexAppServerThreadBinding = {
4040
sandbox?: CodexAppServerSandboxMode;
4141
serviceTier?: CodexServiceTier;
4242
dynamicToolsFingerprint?: string;
43+
threadBindingOrigin?: "explicit" | "managed";
4344
userMcpServersFingerprint?: string;
4445
mcpServersFingerprint?: string;
4546
pluginAppsFingerprint?: string;
@@ -110,6 +111,10 @@ export async function readCodexAppServerBinding(
110111
typeof parsed.dynamicToolsFingerprint === "string"
111112
? parsed.dynamicToolsFingerprint
112113
: undefined,
114+
threadBindingOrigin:
115+
parsed.threadBindingOrigin === "explicit" || parsed.threadBindingOrigin === "managed"
116+
? parsed.threadBindingOrigin
117+
: undefined,
113118
userMcpServersFingerprint:
114119
typeof parsed.userMcpServersFingerprint === "string"
115120
? parsed.userMcpServersFingerprint
@@ -164,6 +169,7 @@ export async function writeCodexAppServerBinding(
164169
sandbox: binding.sandbox,
165170
serviceTier: binding.serviceTier,
166171
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
172+
threadBindingOrigin: binding.threadBindingOrigin,
167173
userMcpServersFingerprint: binding.userMcpServersFingerprint,
168174
mcpServersFingerprint: binding.mcpServersFingerprint,
169175
pluginAppsFingerprint: binding.pluginAppsFingerprint,

extensions/codex/src/command-handlers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,7 @@ async function resumeThread(
680680
cwd: readString(thread, "cwd") ?? "",
681681
model: isJsonObject(response) ? readString(response, "model") : undefined,
682682
modelProvider: isJsonObject(response) ? readString(response, "modelProvider") : undefined,
683+
threadBindingOrigin: "explicit",
683684
});
684685
return `Attached this OpenClaw session to Codex thread ${formatCodexDisplayText(
685686
effectiveThreadId,

0 commit comments

Comments
 (0)