Skip to content

Commit ab1e583

Browse files
committed
fix(codex): sync app-server dynamic tool protocol
1 parent 70664e6 commit ab1e583

22 files changed

Lines changed: 655 additions & 158 deletions

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe("Codex app-server attempt context", () => {
1818
it("returns a run context report without deferred Codex dynamic tool schemas", () => {
1919
const tools = [
2020
{
21+
type: "function",
2122
name: "message",
2223
description: "Send a message.",
2324
inputSchema: {
@@ -28,15 +29,23 @@ describe("Codex app-server attempt context", () => {
2829
},
2930
},
3031
{
31-
name: "web_search",
32-
description: "Search the web.",
33-
inputSchema: {
34-
type: "object",
35-
properties: {
36-
query: { type: "string" },
32+
type: "namespace",
33+
name: "openclaw",
34+
description: "",
35+
tools: [
36+
{
37+
type: "function",
38+
name: "web_search",
39+
description: "Search the web.",
40+
inputSchema: {
41+
type: "object",
42+
properties: {
43+
query: { type: "string" },
44+
},
45+
},
46+
deferLoading: true,
3747
},
38-
},
39-
deferLoading: true,
48+
],
4049
},
4150
] as CodexDynamicToolSpec[];
4251

extensions/codex/src/app-server/attempt-context.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import {
1717
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
1818
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
1919
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
20-
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
20+
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
21+
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
2122
import { isJsonObject } from "./protocol.js";
2223
import type { CodexAppServerThreadBinding } from "./session-binding.js";
2324
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
@@ -280,7 +281,7 @@ export function buildCodexSystemPromptReport(params: {
280281
skillsPrompt: string;
281282
tools: CodexDynamicToolSpec[];
282283
}): CodexSystemPromptReport {
283-
const toolEntries = params.tools.map(buildCodexToolReportEntry);
284+
const toolEntries = flattenCodexDynamicToolFunctions(params.tools).map(buildCodexToolReportEntry);
284285
const schemaChars = toolEntries.reduce((sum, tool) => sum + tool.schemaChars, 0);
285286
const skillsPrompt = params.skillsPrompt.trim();
286287
const bootstrapMaxChars = readPositiveNumber(
@@ -344,7 +345,7 @@ function buildCodexSkillReportEntries(
344345
.filter((entry) => entry.blockChars > 0);
345346
}
346347

347-
function buildCodexToolReportEntry(tool: CodexDynamicToolSpec): CodexToolReportEntry {
348+
function buildCodexToolReportEntry(tool: CodexDynamicToolFunctionSpec): CodexToolReportEntry {
348349
const summary = tool.description.trim();
349350
if (tool.deferLoading === true) {
350351
return {
@@ -854,13 +855,15 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
854855
}
855856

856857
/** Returns whether the current dynamic tool list can serve workspace memory. */
857-
export function hasCodexWorkspaceMemoryTools(tools: readonly { name: string }[]): boolean {
858+
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
858859
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
859860
}
860861

861862
/** Lists available memory tool names understood by Codex workspace memory routing. */
862-
export function getCodexWorkspaceMemoryToolNames(tools: readonly { name: string }[]): string[] {
863-
const availableToolNames = new Set(tools.map((tool) => normalizeCodexDynamicToolName(tool.name)));
863+
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
864+
const availableToolNames = new Set(
865+
flattenCodexDynamicToolFunctions(tools).map((tool) => normalizeCodexDynamicToolName(tool.name)),
866+
);
864867
return Array.from(CODEX_MEMORY_TOOL_NAMES).filter((name) => availableToolNames.has(name));
865868
}
866869

extensions/codex/src/app-server/dynamic-tool-build.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
shouldUseDirectCodexDynamicToolsForModel,
3030
} from "./dynamic-tool-profile.js";
3131
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
32+
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
3233
import { createCodexTestModel } from "./test-support.js";
3334

3435
let tempDir: string;
@@ -401,7 +402,9 @@ describe("Codex app-server dynamic tool build", () => {
401402
expect(shouldUseDirectCodexDynamicToolsForModel("gpt-5.4-nano")).toBe(true);
402403
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.4-nano")).toBe("direct");
403404
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.5")).toBe("searchable");
404-
const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search");
405+
const webSearch = flattenCodexDynamicToolFunctions(toolBridge.specs).find(
406+
(tool) => tool.name === "web_search",
407+
);
405408
expect(webSearch).not.toHaveProperty("deferLoading");
406409
expect(webSearch).not.toHaveProperty("namespace");
407410
});

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

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
2828
createCodexDynamicToolBridge,
2929
} from "./dynamic-tools.js";
30-
import type { JsonValue } from "./protocol.js";
30+
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
3131

3232
function createTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
3333
return {
@@ -115,6 +115,20 @@ function expectDynamicSpec(
115115
}
116116
}
117117

118+
function flattenSpecsWithNamespace(
119+
specs: readonly CodexDynamicToolSpec[],
120+
): Array<CodexDynamicToolFunctionSpec & { namespace?: string }> {
121+
return specs.flatMap((spec) =>
122+
spec.type === "namespace"
123+
? spec.tools.map((tool) => ({ ...tool, namespace: spec.name }))
124+
: [spec],
125+
);
126+
}
127+
128+
function specNames(specs: readonly CodexDynamicToolSpec[]): string[] {
129+
return flattenSpecsWithNamespace(specs).map((tool) => tool.name);
130+
}
131+
118132
function expectNoNamespace(spec: unknown) {
119133
const record = requireRecord(spec, "tool spec");
120134
expect(record).not.toHaveProperty("namespace");
@@ -176,11 +190,12 @@ describe("createCodexDynamicToolBridge", () => {
176190
signal: new AbortController().signal,
177191
});
178192

179-
const webSearch = bridge.specs.find((tool) => tool.name === "web_search");
180-
const message = bridge.specs.find((tool) => tool.name === "message");
181-
const heartbeat = bridge.specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME);
182-
const sessionsSpawn = bridge.specs.find((tool) => tool.name === "sessions_spawn");
183-
const sessionsYield = bridge.specs.find((tool) => tool.name === "sessions_yield");
193+
const specs = flattenSpecsWithNamespace(bridge.specs);
194+
const webSearch = specs.find((tool) => tool.name === "web_search");
195+
const message = specs.find((tool) => tool.name === "message");
196+
const heartbeat = specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME);
197+
const sessionsSpawn = specs.find((tool) => tool.name === "sessions_spawn");
198+
const sessionsYield = specs.find((tool) => tool.name === "sessions_yield");
184199

185200
expectDynamicSpec(webSearch, {
186201
name: "web_search",
@@ -212,14 +227,21 @@ describe("createCodexDynamicToolBridge", () => {
212227
directToolNames: ["message"],
213228
});
214229

230+
const specs = flattenSpecsWithNamespace(bridge.specs);
215231
expect(bridge.specs).toHaveLength(2);
216-
expectDynamicSpec(bridge.specs[0], { name: "message" });
217-
expectDynamicSpec(bridge.specs[1], {
218-
name: "web_search",
219-
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
220-
deferLoading: true,
221-
});
222-
expectNoNamespace(bridge.specs[0]);
232+
expectDynamicSpec(
233+
specs.find((tool) => tool.name === "message"),
234+
{ name: "message" },
235+
);
236+
expectDynamicSpec(
237+
specs.find((tool) => tool.name === "web_search"),
238+
{
239+
name: "web_search",
240+
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
241+
deferLoading: true,
242+
},
243+
);
244+
expectNoNamespace(specs.find((tool) => tool.name === "message"));
223245
});
224246

225247
it("can register a durable tool schema while denying execution for the current turn", async () => {
@@ -236,11 +258,8 @@ describe("createCodexDynamicToolBridge", () => {
236258
hookContext: { runId: "run-unavailable", onToolOutcome },
237259
});
238260

239-
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
240-
expect(bridge.specs.map((tool) => tool.name)).toEqual([
241-
"message",
242-
HEARTBEAT_RESPONSE_TOOL_NAME,
243-
]);
261+
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
262+
expect(specNames(bridge.specs)).toEqual(["message", HEARTBEAT_RESPONSE_TOOL_NAME]);
244263

245264
const result = await bridge.handleToolCall(
246265
{
@@ -312,11 +331,11 @@ describe("createCodexDynamicToolBridge", () => {
312331
signal: new AbortController().signal,
313332
});
314333

315-
expect(bridge.availableSpecs[0]?.inputSchema).toEqual({
334+
expect(flattenSpecsWithNamespace(bridge.availableSpecs)[0]?.inputSchema).toEqual({
316335
type: "object",
317336
properties: { current: { type: "string" } },
318337
});
319-
expect(bridge.specs[0]?.inputSchema).toEqual({
338+
expect(flattenSpecsWithNamespace(bridge.specs)[0]?.inputSchema).toEqual({
320339
type: "object",
321340
properties: { durable: { type: "string" } },
322341
});
@@ -352,8 +371,8 @@ describe("createCodexDynamicToolBridge", () => {
352371
unsubscribeDiagnostics();
353372
}
354373

355-
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
356-
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
374+
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
375+
expect(specNames(bridge.specs)).toEqual(["message"]);
357376
expect(bridge.telemetry.quarantinedTools).toEqual([
358377
{
359378
tool: "fuzzplugin_move_angles",
@@ -450,8 +469,8 @@ describe("createCodexDynamicToolBridge", () => {
450469
signal: new AbortController().signal,
451470
});
452471

453-
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
454-
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
472+
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
473+
expect(specNames(bridge.specs)).toEqual(["message"]);
455474
expect(bridge.telemetry.quarantinedTools).toEqual([
456475
{
457476
tool: "tool[0]",
@@ -509,8 +528,8 @@ describe("createCodexDynamicToolBridge", () => {
509528
signal: new AbortController().signal,
510529
});
511530

512-
expect(registeredBridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
513-
expect(registeredBridge.specs.map((tool) => tool.name)).toEqual(["message"]);
531+
expect(specNames(registeredBridge.availableSpecs)).toEqual(["message"]);
532+
expect(specNames(registeredBridge.specs)).toEqual(["message"]);
514533
});
515534

516535
it("can expose all dynamic tools directly for compatibility", () => {

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

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import type {
4848
CodexDynamicToolCallParams,
4949
CodexDynamicToolCallResponse,
5050
CodexDynamicToolDiagnosticTerminalType,
51+
CodexDynamicToolFunctionSpec,
5152
CodexDynamicToolSpec,
5253
JsonValue,
5354
} from "./protocol.js";
@@ -201,20 +202,16 @@ export function createCodexDynamicToolBridge(params: {
201202
...(params.directToolNames ?? []),
202203
]);
203204
return {
204-
availableSpecs: availableTools.map((entry) =>
205-
createCodexDynamicToolSpec({
206-
entry,
207-
loading: params.loading ?? "searchable",
208-
directToolNames,
209-
}),
210-
),
211-
specs: registeredSpecTools.map((entry) =>
212-
createCodexDynamicToolSpec({
213-
entry,
214-
loading: params.loading ?? "searchable",
215-
directToolNames,
216-
}),
217-
),
205+
availableSpecs: createCodexDynamicToolSpecs({
206+
entries: availableTools,
207+
loading: params.loading ?? "searchable",
208+
directToolNames,
209+
}),
210+
specs: createCodexDynamicToolSpecs({
211+
entries: registeredSpecTools,
212+
loading: params.loading ?? "searchable",
213+
directToolNames,
214+
}),
218215
telemetry,
219216
handleToolCall: async (call, options) => {
220217
const toolEntry = toolMap.get(call.tool);
@@ -502,24 +499,41 @@ function wrapProjectedCodexDynamicTools(
502499
return { tools: wrappedTools, quarantinedTools };
503500
}
504501

505-
function createCodexDynamicToolSpec(params: {
506-
entry: ProjectedCodexDynamicTool;
502+
function createCodexDynamicToolSpecs(params: {
503+
entries: readonly ProjectedCodexDynamicTool[];
507504
loading: CodexDynamicToolsLoading;
508505
directToolNames: ReadonlySet<string>;
509-
}): CodexDynamicToolSpec {
510-
const base = {
506+
}): CodexDynamicToolSpec[] {
507+
const specs: CodexDynamicToolSpec[] = [];
508+
const namespaceTools: CodexDynamicToolFunctionSpec[] = [];
509+
for (const entry of params.entries) {
510+
const functionSpec = createCodexDynamicToolFunctionSpec({ entry });
511+
if (params.loading === "direct" || params.directToolNames.has(entry.name)) {
512+
specs.push(functionSpec);
513+
continue;
514+
}
515+
namespaceTools.push({ ...functionSpec, deferLoading: true });
516+
}
517+
if (namespaceTools.length > 0) {
518+
specs.push({
519+
type: "namespace",
520+
name: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
521+
description: "",
522+
tools: namespaceTools,
523+
});
524+
}
525+
return specs;
526+
}
527+
528+
function createCodexDynamicToolFunctionSpec(params: {
529+
entry: ProjectedCodexDynamicTool;
530+
}): CodexDynamicToolFunctionSpec {
531+
return {
532+
type: "function",
511533
name: params.entry.name,
512534
description: params.entry.description,
513535
inputSchema: params.entry.inputSchema,
514536
};
515-
if (params.loading === "direct" || params.directToolNames.has(params.entry.name)) {
516-
return base;
517-
}
518-
return {
519-
...base,
520-
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
521-
deferLoading: true,
522-
};
523537
}
524538

525539
function projectCodexDynamicTools(tools: readonly AnyAgentTool[]): {

extensions/codex/src/app-server/protocol-generated/json/v2/GetAccountResponse.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@
4545
},
4646
{
4747
"properties": {
48+
"credentialSource": {
49+
"allOf": [
50+
{
51+
"$ref": "#/definitions/AmazonBedrockCredentialSource"
52+
}
53+
],
54+
"default": "awsManaged"
55+
},
4856
"type": {
4957
"enum": [
5058
"amazonBedrock"
@@ -61,6 +69,13 @@
6169
}
6270
]
6371
},
72+
"AmazonBedrockCredentialSource": {
73+
"enum": [
74+
"codexManaged",
75+
"awsManaged"
76+
],
77+
"type": "string"
78+
},
6479
"PlanType": {
6580
"enum": [
6681
"free",

0 commit comments

Comments
 (0)