Skip to content

Commit 09baec6

Browse files
committed
fix(codex): bound dynamic tool bridge responses
1 parent a16f7fb commit 09baec6

9 files changed

Lines changed: 305 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
5555
- Channels/Telegram: publish webhook runtime state and warn when `setWebhook` has not completed after startup grace, so webhook-mode accounts no longer look healthy while registration is still failing or retrying. Refs #74299. Thanks @lolaopenclaw and @martingarramon.
5656
- Channels/Telegram: bound native command menu `deleteMyCommands` and `setMyCommands` Bot API calls and allow the same timeout-triggered transport fallback retry as other startup control calls, so Windows/WSL network stalls cannot leave command sync hanging behind an otherwise running provider. Refs #74086. Thanks @SymbolStar.
5757
- ACP/commands: accept forwarded ACP timeout config controls in the OpenClaw bridge, treat unsupported discard-close controls as recoverable cleanup, and restore native `/verbose full` plus no-arg status behavior, so Discord command menus and nested ACP turns no longer fail on supported session controls. Thanks @vincentkoc.
58+
- Codex harness: bound OpenClaw dynamic tool responses to 30 seconds and fail closed with an explicit tool result when the app-server bridge would otherwise strand the turn in `processing`. Thanks @vincentkoc.
5859
- TUI/status: clear stale `streaming` footer state when a final event arrives after the active run was already cleared and no tracked runs remain, while preserving concurrent-run ownership and inactive local `/btw` terminal handling. Fixes #64825; carries forward #64842, #64843, #64847, and #64862. Thanks @briandevans and @Yanhu007.
5960
- Channels/Discord: fail startup closed when Discord cannot resolve the bot's own identity and keep mention gating active when only configured mention patterns can detect mentions, so the provider no longer continues with a missing bot id. Fixes #42219; carries forward #46856 and #49218. Thanks @education-01 and @BenediktSchackenberg.
6061
- Channels/Discord: split long CJK replies at punctuation and code-point-safe fallback boundaries so Discord chunking stays readable without corrupting astral characters. Fixes #38597; repairs #71384. Thanks @p3nchan.

docs/plugins/codex-harness.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,12 @@ Supported `appServer` fields:
569569
| `approvalsReviewer` | `"user"` | Use `"auto_review"` to let Codex review native approval prompts. `guardian_subagent` remains a legacy alias. |
570570
| `serviceTier` | unset | Optional Codex app-server service tier: `"fast"`, `"flex"`, or `null`. Invalid legacy values are ignored. |
571571

572+
OpenClaw-owned dynamic tool calls are bounded independently from
573+
`appServer.requestTimeoutMs`: each Codex `item/tool/call` request must receive
574+
an OpenClaw response within 30 seconds. On timeout, OpenClaw aborts the tool
575+
signal where supported and returns a failed dynamic-tool response to Codex so
576+
the turn can continue instead of leaving the session in `processing`.
577+
572578
Environment overrides remain available for local testing:
573579

574580
- `OPENCLAW_CODEX_APP_SERVER_BIN`

extensions/codex/src/app-server/client.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,44 @@ describe("CodexAppServerClient", () => {
327327
});
328328
});
329329

330+
it("fails closed when a dynamic tool server request handler hangs", async () => {
331+
vi.useFakeTimers();
332+
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
333+
const harness = createClientHarness();
334+
clients.push(harness.client);
335+
harness.client.addRequestHandler((request) => {
336+
if (request.method === "item/tool/call") {
337+
return new Promise<never>(() => undefined);
338+
}
339+
return undefined;
340+
});
341+
342+
harness.send({ id: "srv-timeout", method: "item/tool/call", params: { tool: "message" } });
343+
await vi.advanceTimersByTimeAsync(__testing.CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS);
344+
await vi.waitFor(() => expect(harness.writes.length).toBe(1));
345+
346+
expect(JSON.parse(harness.writes[0] ?? "{}")).toEqual({
347+
id: "srv-timeout",
348+
result: {
349+
success: false,
350+
contentItems: [
351+
{
352+
type: "inputText",
353+
text: `OpenClaw dynamic tool call timed out after ${__testing.CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS}ms before sending a response to Codex.`,
354+
},
355+
],
356+
},
357+
});
358+
expect(warn).toHaveBeenCalledWith(
359+
"codex app-server server request timed out",
360+
expect.objectContaining({
361+
id: "srv-timeout",
362+
method: "item/tool/call",
363+
timeoutMs: __testing.CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS,
364+
}),
365+
);
366+
});
367+
330368
it("fails closed for unhandled native app-server approvals", async () => {
331369
const harness = createClientHarness();
332370
clients.push(harness.client);

extensions/codex/src/app-server/client.ts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { MIN_CODEX_APP_SERVER_VERSION } from "./version.js";
2525

2626
export { MIN_CODEX_APP_SERVER_VERSION } from "./version.js";
2727
const CODEX_APP_SERVER_PARSE_LOG_MAX = 500;
28+
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 30_000;
2829

2930
type PendingRequest = {
3031
method: string;
@@ -251,7 +252,13 @@ export class CodexAppServerClient {
251252
if (this.closed) {
252253
return;
253254
}
254-
this.child.stdin.write(`${JSON.stringify(message)}\n`);
255+
const id = "id" in message ? message.id : undefined;
256+
const method = "method" in message ? message.method : undefined;
257+
this.child.stdin.write(`${JSON.stringify(message)}\n`, (error?: Error | null) => {
258+
if (error) {
259+
embeddedAgentLog.warn("codex app-server write failed", { error, id, method });
260+
}
261+
});
255262
}
256263

257264
private handleLine(line: string): void {
@@ -311,12 +318,10 @@ export class CodexAppServerClient {
311318
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
312319
): Promise<void> {
313320
try {
314-
for (const handler of this.requestHandlers) {
315-
const result = await handler(request);
316-
if (result !== undefined) {
317-
this.writeMessage({ id: request.id, result });
318-
return;
319-
}
321+
const result = await this.runServerRequestHandlers(request);
322+
if (result !== undefined) {
323+
this.writeMessage({ id: request.id, result });
324+
return;
320325
}
321326
this.writeMessage({ id: request.id, result: defaultServerRequestResponse(request) });
322327
} catch (error) {
@@ -329,6 +334,49 @@ export class CodexAppServerClient {
329334
}
330335
}
331336

337+
private async runServerRequestHandlers(
338+
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
339+
): Promise<JsonValue | undefined> {
340+
const timeoutResponse = timeoutServerRequestResponse(request);
341+
if (!timeoutResponse) {
342+
return await this.runServerRequestHandlersWithoutTimeout(request);
343+
}
344+
345+
let timeout: ReturnType<typeof setTimeout> | undefined;
346+
try {
347+
return await Promise.race([
348+
this.runServerRequestHandlersWithoutTimeout(request),
349+
new Promise<JsonValue>((resolve) => {
350+
timeout = setTimeout(() => {
351+
embeddedAgentLog.warn("codex app-server server request timed out", {
352+
id: request.id,
353+
method: request.method,
354+
timeoutMs: CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS,
355+
});
356+
resolve(timeoutResponse);
357+
}, CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS);
358+
timeout.unref?.();
359+
}),
360+
]);
361+
} finally {
362+
if (timeout) {
363+
clearTimeout(timeout);
364+
}
365+
}
366+
}
367+
368+
private async runServerRequestHandlersWithoutTimeout(
369+
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
370+
): Promise<JsonValue | undefined> {
371+
for (const handler of this.requestHandlers) {
372+
const result = await handler(request);
373+
if (result !== undefined) {
374+
return result;
375+
}
376+
}
377+
return undefined;
378+
}
379+
332380
private handleNotification(notification: CodexServerNotification): void {
333381
for (const handler of this.notificationHandlers) {
334382
Promise.resolve(handler(notification)).catch((error: unknown) => {
@@ -407,6 +455,23 @@ export function defaultServerRequestResponse(
407455
return {};
408456
}
409457

458+
function timeoutServerRequestResponse(
459+
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
460+
): JsonValue | undefined {
461+
if (request.method !== "item/tool/call") {
462+
return undefined;
463+
}
464+
return {
465+
contentItems: [
466+
{
467+
type: "inputText",
468+
text: `OpenClaw dynamic tool call timed out after ${CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS}ms before sending a response to Codex.`,
469+
},
470+
],
471+
success: false,
472+
};
473+
}
474+
410475
function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse): void {
411476
const detectedVersion = readCodexVersionFromUserAgent(response.userAgent);
412477
if (!detectedVersion) {
@@ -505,5 +570,6 @@ function formatExitValue(value: unknown): string {
505570
export const __testing = {
506571
closeCodexAppServerTransport,
507572
closeCodexAppServerTransportAndWait,
573+
CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS,
508574
redactCodexAppServerLinePreview,
509575
} as const;

extensions/codex/src/app-server/dynamic-tools.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,43 @@ describe("createCodexDynamicToolBridge", () => {
674674
});
675675
});
676676

677+
it("passes per-call abort signals into dynamic tool execution", async () => {
678+
let capturedSignal: AbortSignal | undefined;
679+
let resolveTool: ((result: AgentToolResult<unknown>) => void) | undefined;
680+
const execute = vi.fn(
681+
async (_callId: string, _args: Record<string, unknown>, signal: AbortSignal) =>
682+
await new Promise<AgentToolResult<unknown>>((resolve) => {
683+
capturedSignal = signal;
684+
resolveTool = resolve;
685+
}),
686+
);
687+
const runController = new AbortController();
688+
const callController = new AbortController();
689+
const bridge = createCodexDynamicToolBridge({
690+
tools: [createTool({ name: "exec", execute })],
691+
signal: runController.signal,
692+
});
693+
694+
const result = bridge.handleToolCall(
695+
{
696+
threadId: "thread-1",
697+
turnId: "turn-1",
698+
callId: "call-signal",
699+
namespace: null,
700+
tool: "exec",
701+
arguments: { command: "sleep" },
702+
},
703+
{ signal: callController.signal },
704+
);
705+
await vi.waitFor(() => expect(capturedSignal).toBeDefined());
706+
707+
callController.abort(new Error("deadline"));
708+
expect(capturedSignal?.aborted).toBe(true);
709+
resolveTool?.(textToolResult("done"));
710+
711+
await expect(result).resolves.toEqual(expectInputText("done"));
712+
});
713+
677714
it("does not double-wrap dynamic tools that already have before_tool_call", async () => {
678715
const beforeToolCall = vi.fn(async () => ({ params: { mode: "safe" } }));
679716
initializeGlobalHookRunner(

extensions/codex/src/app-server/dynamic-tools.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import {
2323

2424
export type CodexDynamicToolBridge = {
2525
specs: CodexDynamicToolSpec[];
26-
handleToolCall: (params: CodexDynamicToolCallParams) => Promise<CodexDynamicToolCallResponse>;
26+
handleToolCall: (
27+
params: CodexDynamicToolCallParams,
28+
options?: { signal?: AbortSignal },
29+
) => Promise<CodexDynamicToolCallResponse>;
2730
telemetry: {
2831
didSendViaMessagingTool: boolean;
2932
messagingToolSentTexts: string[];
@@ -74,7 +77,7 @@ export function createCodexDynamicToolBridge(params: {
7477
inputSchema: toJsonValue(tool.parameters),
7578
})),
7679
telemetry,
77-
handleToolCall: async (call) => {
80+
handleToolCall: async (call, options) => {
7881
const tool = toolMap.get(call.tool);
7982
if (!tool) {
8083
return {
@@ -84,9 +87,10 @@ export function createCodexDynamicToolBridge(params: {
8487
}
8588
const args = jsonObjectToRecord(call.arguments);
8689
const startedAt = Date.now();
90+
const signal = composeAbortSignals(params.signal, options?.signal);
8791
try {
8892
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
89-
const rawResult = await tool.execute(call.callId, preparedArgs, params.signal);
93+
const rawResult = await tool.execute(call.callId, preparedArgs, signal);
9094
const rawIsError = isToolResultError(rawResult);
9195
const middlewareResult = await middlewareRunner.applyToolResultMiddleware({
9296
threadId: call.threadId,
@@ -161,6 +165,17 @@ export function createCodexDynamicToolBridge(params: {
161165
};
162166
}
163167

168+
function composeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
169+
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
170+
if (activeSignals.length === 0) {
171+
return new AbortController().signal;
172+
}
173+
if (activeSignals.length === 1) {
174+
return activeSignals[0]!;
175+
}
176+
return AbortSignal.any(activeSignals);
177+
}
178+
164179
function collectToolTelemetry(params: {
165180
toolName: string;
166181
args: Record<string, unknown>;

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,10 +329,47 @@ describe("runCodexAppServerAttempt", () => {
329329
nativeHookRelayTesting.clearNativeHookRelaysForTests();
330330
resetAgentEventsForTest();
331331
resetGlobalHookRunner();
332+
vi.useRealTimers();
332333
vi.restoreAllMocks();
333334
await fs.rm(tempDir, { recursive: true, force: true });
334335
});
335336

337+
it("returns a failed dynamic tool response when an app-server tool call exceeds the deadline", async () => {
338+
vi.useFakeTimers();
339+
let capturedSignal: AbortSignal | undefined;
340+
const onTimeout = vi.fn();
341+
const response = __testing.handleDynamicToolCallWithTimeout({
342+
call: {
343+
threadId: "thread-1",
344+
turnId: "turn-1",
345+
callId: "call-timeout",
346+
namespace: null,
347+
tool: "message",
348+
arguments: { action: "send", text: "hello" },
349+
},
350+
toolBridge: {
351+
handleToolCall: vi.fn((_call, options) => {
352+
capturedSignal = options?.signal;
353+
return new Promise<never>(() => undefined);
354+
}),
355+
},
356+
signal: new AbortController().signal,
357+
timeoutMs: 1,
358+
onTimeout,
359+
});
360+
361+
await vi.advanceTimersByTimeAsync(1);
362+
363+
await expect(response).resolves.toEqual({
364+
success: false,
365+
contentItems: [
366+
{ type: "inputText", text: "OpenClaw dynamic tool call timed out after 1ms." },
367+
],
368+
});
369+
expect(capturedSignal?.aborted).toBe(true);
370+
expect(onTimeout).toHaveBeenCalledTimes(1);
371+
});
372+
336373
it("applies before_prompt_build to Codex developer instructions and turn input", async () => {
337374
const beforePromptBuild = vi.fn(async () => ({
338375
systemPrompt: "custom codex system",

0 commit comments

Comments
 (0)