fix(codex): sync app-server dynamic tool protocol

This commit is contained in:
Vincent Koc
2026-06-17 04:08:08 +02:00
parent 70664e6083
commit ab1e5832d2
22 changed files with 655 additions and 158 deletions
@@ -18,6 +18,7 @@ describe("Codex app-server attempt context", () => {
it("returns a run context report without deferred Codex dynamic tool schemas", () => {
const tools = [
{
type: "function",
name: "message",
description: "Send a message.",
inputSchema: {
@@ -28,15 +29,23 @@ describe("Codex app-server attempt context", () => {
},
},
{
name: "web_search",
description: "Search the web.",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
type: "namespace",
name: "openclaw",
description: "",
tools: [
{
type: "function",
name: "web_search",
description: "Search the web.",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
},
},
deferLoading: true,
},
},
deferLoading: true,
],
},
] as CodexDynamicToolSpec[];
@@ -17,7 +17,8 @@ import {
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
import { isJsonObject } from "./protocol.js";
import type { CodexAppServerThreadBinding } from "./session-binding.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
@@ -280,7 +281,7 @@ export function buildCodexSystemPromptReport(params: {
skillsPrompt: string;
tools: CodexDynamicToolSpec[];
}): CodexSystemPromptReport {
const toolEntries = params.tools.map(buildCodexToolReportEntry);
const toolEntries = flattenCodexDynamicToolFunctions(params.tools).map(buildCodexToolReportEntry);
const schemaChars = toolEntries.reduce((sum, tool) => sum + tool.schemaChars, 0);
const skillsPrompt = params.skillsPrompt.trim();
const bootstrapMaxChars = readPositiveNumber(
@@ -344,7 +345,7 @@ function buildCodexSkillReportEntries(
.filter((entry) => entry.blockChars > 0);
}
function buildCodexToolReportEntry(tool: CodexDynamicToolSpec): CodexToolReportEntry {
function buildCodexToolReportEntry(tool: CodexDynamicToolFunctionSpec): CodexToolReportEntry {
const summary = tool.description.trim();
if (tool.deferLoading === true) {
return {
@@ -854,13 +855,15 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
}
/** Returns whether the current dynamic tool list can serve workspace memory. */
export function hasCodexWorkspaceMemoryTools(tools: readonly { name: string }[]): boolean {
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
}
/** Lists available memory tool names understood by Codex workspace memory routing. */
export function getCodexWorkspaceMemoryToolNames(tools: readonly { name: string }[]): string[] {
const availableToolNames = new Set(tools.map((tool) => normalizeCodexDynamicToolName(tool.name)));
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
const availableToolNames = new Set(
flattenCodexDynamicToolFunctions(tools).map((tool) => normalizeCodexDynamicToolName(tool.name)),
);
return Array.from(CODEX_MEMORY_TOOL_NAMES).filter((name) => availableToolNames.has(name));
}
@@ -29,6 +29,7 @@ import {
shouldUseDirectCodexDynamicToolsForModel,
} from "./dynamic-tool-profile.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
import { createCodexTestModel } from "./test-support.js";
let tempDir: string;
@@ -401,7 +402,9 @@ describe("Codex app-server dynamic tool build", () => {
expect(shouldUseDirectCodexDynamicToolsForModel("gpt-5.4-nano")).toBe(true);
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.4-nano")).toBe("direct");
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.5")).toBe("searchable");
const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search");
const webSearch = flattenCodexDynamicToolFunctions(toolBridge.specs).find(
(tool) => tool.name === "web_search",
);
expect(webSearch).not.toHaveProperty("deferLoading");
expect(webSearch).not.toHaveProperty("namespace");
});
@@ -27,7 +27,7 @@ import {
CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
createCodexDynamicToolBridge,
} from "./dynamic-tools.js";
import type { JsonValue } from "./protocol.js";
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
function createTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
return {
@@ -115,6 +115,20 @@ function expectDynamicSpec(
}
}
function flattenSpecsWithNamespace(
specs: readonly CodexDynamicToolSpec[],
): Array<CodexDynamicToolFunctionSpec & { namespace?: string }> {
return specs.flatMap((spec) =>
spec.type === "namespace"
? spec.tools.map((tool) => ({ ...tool, namespace: spec.name }))
: [spec],
);
}
function specNames(specs: readonly CodexDynamicToolSpec[]): string[] {
return flattenSpecsWithNamespace(specs).map((tool) => tool.name);
}
function expectNoNamespace(spec: unknown) {
const record = requireRecord(spec, "tool spec");
expect(record).not.toHaveProperty("namespace");
@@ -176,11 +190,12 @@ describe("createCodexDynamicToolBridge", () => {
signal: new AbortController().signal,
});
const webSearch = bridge.specs.find((tool) => tool.name === "web_search");
const message = bridge.specs.find((tool) => tool.name === "message");
const heartbeat = bridge.specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME);
const sessionsSpawn = bridge.specs.find((tool) => tool.name === "sessions_spawn");
const sessionsYield = bridge.specs.find((tool) => tool.name === "sessions_yield");
const specs = flattenSpecsWithNamespace(bridge.specs);
const webSearch = specs.find((tool) => tool.name === "web_search");
const message = specs.find((tool) => tool.name === "message");
const heartbeat = specs.find((tool) => tool.name === HEARTBEAT_RESPONSE_TOOL_NAME);
const sessionsSpawn = specs.find((tool) => tool.name === "sessions_spawn");
const sessionsYield = specs.find((tool) => tool.name === "sessions_yield");
expectDynamicSpec(webSearch, {
name: "web_search",
@@ -212,14 +227,21 @@ describe("createCodexDynamicToolBridge", () => {
directToolNames: ["message"],
});
const specs = flattenSpecsWithNamespace(bridge.specs);
expect(bridge.specs).toHaveLength(2);
expectDynamicSpec(bridge.specs[0], { name: "message" });
expectDynamicSpec(bridge.specs[1], {
name: "web_search",
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
deferLoading: true,
});
expectNoNamespace(bridge.specs[0]);
expectDynamicSpec(
specs.find((tool) => tool.name === "message"),
{ name: "message" },
);
expectDynamicSpec(
specs.find((tool) => tool.name === "web_search"),
{
name: "web_search",
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
deferLoading: true,
},
);
expectNoNamespace(specs.find((tool) => tool.name === "message"));
});
it("can register a durable tool schema while denying execution for the current turn", async () => {
@@ -236,11 +258,8 @@ describe("createCodexDynamicToolBridge", () => {
hookContext: { runId: "run-unavailable", onToolOutcome },
});
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
expect(bridge.specs.map((tool) => tool.name)).toEqual([
"message",
HEARTBEAT_RESPONSE_TOOL_NAME,
]);
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
expect(specNames(bridge.specs)).toEqual(["message", HEARTBEAT_RESPONSE_TOOL_NAME]);
const result = await bridge.handleToolCall(
{
@@ -312,11 +331,11 @@ describe("createCodexDynamicToolBridge", () => {
signal: new AbortController().signal,
});
expect(bridge.availableSpecs[0]?.inputSchema).toEqual({
expect(flattenSpecsWithNamespace(bridge.availableSpecs)[0]?.inputSchema).toEqual({
type: "object",
properties: { current: { type: "string" } },
});
expect(bridge.specs[0]?.inputSchema).toEqual({
expect(flattenSpecsWithNamespace(bridge.specs)[0]?.inputSchema).toEqual({
type: "object",
properties: { durable: { type: "string" } },
});
@@ -352,8 +371,8 @@ describe("createCodexDynamicToolBridge", () => {
unsubscribeDiagnostics();
}
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
expect(specNames(bridge.specs)).toEqual(["message"]);
expect(bridge.telemetry.quarantinedTools).toEqual([
{
tool: "fuzzplugin_move_angles",
@@ -450,8 +469,8 @@ describe("createCodexDynamicToolBridge", () => {
signal: new AbortController().signal,
});
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
expect(specNames(bridge.availableSpecs)).toEqual(["message"]);
expect(specNames(bridge.specs)).toEqual(["message"]);
expect(bridge.telemetry.quarantinedTools).toEqual([
{
tool: "tool[0]",
@@ -509,8 +528,8 @@ describe("createCodexDynamicToolBridge", () => {
signal: new AbortController().signal,
});
expect(registeredBridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
expect(registeredBridge.specs.map((tool) => tool.name)).toEqual(["message"]);
expect(specNames(registeredBridge.availableSpecs)).toEqual(["message"]);
expect(specNames(registeredBridge.specs)).toEqual(["message"]);
});
it("can expose all dynamic tools directly for compatibility", () => {
@@ -48,6 +48,7 @@ import type {
CodexDynamicToolCallParams,
CodexDynamicToolCallResponse,
CodexDynamicToolDiagnosticTerminalType,
CodexDynamicToolFunctionSpec,
CodexDynamicToolSpec,
JsonValue,
} from "./protocol.js";
@@ -201,20 +202,16 @@ export function createCodexDynamicToolBridge(params: {
...(params.directToolNames ?? []),
]);
return {
availableSpecs: availableTools.map((entry) =>
createCodexDynamicToolSpec({
entry,
loading: params.loading ?? "searchable",
directToolNames,
}),
),
specs: registeredSpecTools.map((entry) =>
createCodexDynamicToolSpec({
entry,
loading: params.loading ?? "searchable",
directToolNames,
}),
),
availableSpecs: createCodexDynamicToolSpecs({
entries: availableTools,
loading: params.loading ?? "searchable",
directToolNames,
}),
specs: createCodexDynamicToolSpecs({
entries: registeredSpecTools,
loading: params.loading ?? "searchable",
directToolNames,
}),
telemetry,
handleToolCall: async (call, options) => {
const toolEntry = toolMap.get(call.tool);
@@ -502,24 +499,41 @@ function wrapProjectedCodexDynamicTools(
return { tools: wrappedTools, quarantinedTools };
}
function createCodexDynamicToolSpec(params: {
entry: ProjectedCodexDynamicTool;
function createCodexDynamicToolSpecs(params: {
entries: readonly ProjectedCodexDynamicTool[];
loading: CodexDynamicToolsLoading;
directToolNames: ReadonlySet<string>;
}): CodexDynamicToolSpec {
const base = {
}): CodexDynamicToolSpec[] {
const specs: CodexDynamicToolSpec[] = [];
const namespaceTools: CodexDynamicToolFunctionSpec[] = [];
for (const entry of params.entries) {
const functionSpec = createCodexDynamicToolFunctionSpec({ entry });
if (params.loading === "direct" || params.directToolNames.has(entry.name)) {
specs.push(functionSpec);
continue;
}
namespaceTools.push({ ...functionSpec, deferLoading: true });
}
if (namespaceTools.length > 0) {
specs.push({
type: "namespace",
name: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
description: "",
tools: namespaceTools,
});
}
return specs;
}
function createCodexDynamicToolFunctionSpec(params: {
entry: ProjectedCodexDynamicTool;
}): CodexDynamicToolFunctionSpec {
return {
type: "function",
name: params.entry.name,
description: params.entry.description,
inputSchema: params.entry.inputSchema,
};
if (params.loading === "direct" || params.directToolNames.has(params.entry.name)) {
return base;
}
return {
...base,
namespace: CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE,
deferLoading: true,
};
}
function projectCodexDynamicTools(tools: readonly AnyAgentTool[]): {
@@ -45,6 +45,14 @@
},
{
"properties": {
"credentialSource": {
"allOf": [
{
"$ref": "#/definitions/AmazonBedrockCredentialSource"
}
],
"default": "awsManaged"
},
"type": {
"enum": [
"amazonBedrock"
@@ -61,6 +69,13 @@
}
]
},
"AmazonBedrockCredentialSource": {
"enum": [
"codexManaged",
"awsManaged"
],
"type": "string"
},
"PlanType": {
"enum": [
"free",
@@ -861,6 +861,14 @@
}
]
},
"SubAgentActivityKind": {
"enum": [
"started",
"interacted",
"interrupted"
],
"type": "string"
},
"SubAgentSource": {
"oneOf": [
{
@@ -1047,6 +1055,14 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
@@ -1617,6 +1633,38 @@
"title": "CollabAgentToolCallThreadItem",
"type": "object"
},
{
"properties": {
"agentPath": {
"type": "string"
},
"agentThreadId": {
"type": "string"
},
"id": {
"type": "string"
},
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"type": {
"enum": [
"subAgentActivity"
],
"title": "SubAgentActivityThreadItemType",
"type": "string"
}
},
"required": [
"agentPath",
"agentThreadId",
"id",
"kind",
"type"
],
"title": "SubAgentActivityThreadItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -1675,6 +1723,32 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"durationMs": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"type": {
"enum": [
"sleep"
],
"title": "SleepThreadItemType",
"type": "string"
}
},
"required": [
"durationMs",
"id",
"type"
],
"title": "SleepThreadItem",
"type": "object"
},
{
"properties": {
"id": {
@@ -1790,11 +1864,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -861,6 +861,14 @@
}
]
},
"SubAgentActivityKind": {
"enum": [
"started",
"interacted",
"interrupted"
],
"type": "string"
},
"SubAgentSource": {
"oneOf": [
{
@@ -1047,6 +1055,14 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"sessionId": {
"description": "Session id shared by threads that belong to the same session tree.",
"type": "string"
@@ -1617,6 +1633,38 @@
"title": "CollabAgentToolCallThreadItem",
"type": "object"
},
{
"properties": {
"agentPath": {
"type": "string"
},
"agentThreadId": {
"type": "string"
},
"id": {
"type": "string"
},
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"type": {
"enum": [
"subAgentActivity"
],
"title": "SubAgentActivityThreadItemType",
"type": "string"
}
},
"required": [
"agentPath",
"agentThreadId",
"id",
"kind",
"type"
],
"title": "SubAgentActivityThreadItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -1675,6 +1723,32 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"durationMs": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"type": {
"enum": [
"sleep"
],
"title": "SleepThreadItemType",
"type": "string"
}
},
"required": [
"durationMs",
"id",
"type"
],
"title": "SleepThreadItem",
"type": "object"
},
{
"properties": {
"id": {
@@ -1790,11 +1864,6 @@
]
},
"ThreadSource": {
"enum": [
"user",
"subagent",
"memory_consolidation"
],
"type": "string"
},
"ThreadStatus": {
@@ -610,6 +610,14 @@
"minLength": 1,
"type": "string"
},
"SubAgentActivityKind": {
"enum": [
"started",
"interacted",
"interrupted"
],
"type": "string"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -1133,6 +1141,38 @@
"title": "CollabAgentToolCallThreadItem",
"type": "object"
},
{
"properties": {
"agentPath": {
"type": "string"
},
"agentThreadId": {
"type": "string"
},
"id": {
"type": "string"
},
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"type": {
"enum": [
"subAgentActivity"
],
"title": "SubAgentActivityThreadItemType",
"type": "string"
}
},
"required": [
"agentPath",
"agentThreadId",
"id",
"kind",
"type"
],
"title": "SubAgentActivityThreadItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -1191,6 +1231,32 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"durationMs": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"type": {
"enum": [
"sleep"
],
"title": "SleepThreadItemType",
"type": "string"
}
},
"required": [
"durationMs",
"id",
"type"
],
"title": "SleepThreadItem",
"type": "object"
},
{
"properties": {
"id": {
@@ -610,6 +610,14 @@
"minLength": 1,
"type": "string"
},
"SubAgentActivityKind": {
"enum": [
"started",
"interacted",
"interrupted"
],
"type": "string"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -1133,6 +1141,38 @@
"title": "CollabAgentToolCallThreadItem",
"type": "object"
},
{
"properties": {
"agentPath": {
"type": "string"
},
"agentThreadId": {
"type": "string"
},
"id": {
"type": "string"
},
"kind": {
"$ref": "#/definitions/SubAgentActivityKind"
},
"type": {
"enum": [
"subAgentActivity"
],
"title": "SubAgentActivityThreadItemType",
"type": "string"
}
},
"required": [
"agentPath",
"agentThreadId",
"id",
"kind",
"type"
],
"title": "SubAgentActivityThreadItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -1191,6 +1231,32 @@
"title": "ImageViewThreadItem",
"type": "object"
},
{
"properties": {
"durationMs": {
"format": "uint64",
"minimum": 0,
"type": "integer"
},
"id": {
"type": "string"
},
"type": {
"enum": [
"sleep"
],
"title": "SleepThreadItemType",
"type": "string"
}
},
"required": [
"durationMs",
"id",
"type"
],
"title": "SleepThreadItem",
"type": "object"
},
{
"properties": {
"id": {
+20 -1
View File
@@ -65,12 +65,31 @@ export type CodexUserInput =
path: string;
};
export type CodexDynamicToolSpec = JsonObject & {
export type CodexDynamicToolFunctionSpec = JsonObject & {
type: "function";
name: string;
description: string;
inputSchema: JsonValue;
deferLoading?: boolean;
};
export type CodexDynamicToolNamespaceTool = CodexDynamicToolFunctionSpec;
export type CodexDynamicToolNamespaceSpec = JsonObject & {
type: "namespace";
name: string;
description: string;
tools: CodexDynamicToolNamespaceTool[];
};
export type CodexDynamicToolSpec = CodexDynamicToolFunctionSpec | CodexDynamicToolNamespaceSpec;
export function flattenCodexDynamicToolFunctions(
tools: readonly CodexDynamicToolSpec[] | undefined,
): CodexDynamicToolFunctionSpec[] {
return (tools ?? []).flatMap((tool) => (tool.type === "namespace" ? tool.tools : [tool]));
}
export type CodexTurnEnvironmentParams = JsonObject & {
environmentId: string;
cwd: string;
@@ -23,7 +23,11 @@ import {
emitDynamicToolTerminalDiagnostic,
} from "./dynamic-tool-diagnostics.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import type { CodexDynamicToolCallParams } from "./protocol.js";
import {
flattenCodexDynamicToolFunctions,
type CodexDynamicToolCallParams,
type CodexDynamicToolSpec,
} from "./protocol.js";
import {
createParams,
createCodexRuntimePlanFixture,
@@ -39,6 +43,10 @@ function flushDiagnosticEvents() {
return waitForDiagnosticEventsDrained();
}
function specNames(specs: readonly CodexDynamicToolSpec[]): string[] {
return flattenCodexDynamicToolFunctions(specs).map((tool) => tool.name);
}
function activeDiagnosticToolKeys(events: DiagnosticEventPayload[]): Set<string> {
const active = new Set<string>();
for (const event of events) {
@@ -366,7 +374,7 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
"features.code_mode_only"?: boolean;
mcp_servers?: Record<string, unknown>;
};
dynamicTools?: Array<{ name: string }>;
dynamicTools?: CodexDynamicToolSpec[];
environments?: unknown[];
}
| undefined;
@@ -382,7 +390,7 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
},
});
expect(startParams?.environments).toBeUndefined();
expect(startParams?.dynamicTools?.map((tool) => tool.name)).toEqual([
expect(specNames(startParams?.dynamicTools ?? [])).toEqual([
"message",
"node_exec",
"node_process",
@@ -41,7 +41,12 @@ import {
} from "./event-projector.js";
import { buildCodexPluginAppCacheKey } from "./plugin-app-cache-key.js";
import { buildCodexPluginThreadConfig } from "./plugin-thread-config.js";
import type { CodexServerNotification } from "./protocol.js";
import {
flattenCodexDynamicToolFunctions,
type CodexDynamicToolFunctionSpec,
type CodexDynamicToolSpec,
type CodexServerNotification,
} from "./protocol.js";
import {
assistantMessage,
createAppServerHarness,
@@ -149,6 +154,7 @@ function createMessageDynamicTool(
actions: string[] = ["send"],
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
return {
type: "function",
name: "message",
description,
inputSchema: {
@@ -169,6 +175,7 @@ function createNamedDynamicTool(
name: string,
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
return {
type: "function",
name,
description: `${name} test tool`,
inputSchema: {
@@ -382,6 +389,20 @@ type RuntimeDynamicToolForTest = Parameters<
typeof createCodexDynamicToolBridge
>[0]["tools"][number];
function flattenSpecsWithNamespace(
specs: readonly CodexDynamicToolSpec[],
): Array<CodexDynamicToolFunctionSpec & { namespace?: string }> {
return specs.flatMap((spec) =>
spec.type === "namespace"
? spec.tools.map((tool) => ({ ...tool, namespace: spec.name }))
: [spec],
);
}
function specNames(specs: readonly CodexDynamicToolSpec[]): string[] {
return flattenCodexDynamicToolFunctions(specs).map((tool) => tool.name);
}
function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
return {
name,
@@ -506,11 +527,11 @@ describe("runCodexAppServerAttempt", () => {
const startRequest = request.mock.calls.find(([method]) => method === "thread/start");
const startParams = startRequest?.[1] as Record<string, unknown> | undefined;
const startConfig = startParams?.config as Record<string, unknown> | undefined;
const startDynamicTools = startParams?.dynamicTools as Array<{ name: string }> | undefined;
const startDynamicTools = startParams?.dynamicTools as CodexDynamicToolSpec[] | undefined;
expect(startConfig?.["features.code_mode"]).toBe(false);
expect(startConfig?.["features.code_mode_only"]).toBe(false);
expect(startParams?.environments).toEqual([]);
expect(startDynamicTools?.map((tool) => tool.name)).toEqual([
expect(specNames(startDynamicTools ?? [])).toEqual([
"message",
"sandbox_exec",
"sandbox_process",
@@ -631,7 +652,7 @@ describe("runCodexAppServerAttempt", () => {
const startParams = startRequest?.[1] as
| {
cwd?: string;
dynamicTools?: Array<{ name: string }>;
dynamicTools?: CodexDynamicToolSpec[];
environments?: Array<{ environmentId?: string; cwd?: string }>;
sandbox?: string;
config?: {
@@ -649,7 +670,7 @@ describe("runCodexAppServerAttempt", () => {
expect(startParams?.config?.["features.code_mode"]).toBe(true);
expect(startParams?.config?.["features.code_mode_only"]).toBe(false);
expect(startParams?.config?.["features.apply_patch_streaming_events"]).toBe(true);
expect(startParams?.dynamicTools?.map((tool) => tool.name)).toEqual(["message"]);
expect(specNames(startParams?.dynamicTools ?? [])).toEqual(["message"]);
expect(startParams?.environments).toEqual([
{ environmentId: environmentAddParams?.environmentId, cwd: "/workspace" },
]);
@@ -902,10 +923,10 @@ describe("runCodexAppServerAttempt", () => {
});
const startRequest = request.mock.calls.find(([method]) => method === "thread/start");
const dynamicToolNames = (
(startRequest?.[1] as { dynamicTools?: Array<{ name: string }> } | undefined)?.dynamicTools ??
[]
).map((tool) => tool.name);
const dynamicToolNames = specNames(
(startRequest?.[1] as { dynamicTools?: CodexDynamicToolSpec[] } | undefined)?.dynamicTools ??
[],
);
expect(dynamicToolNames).toContain("message");
expect(dynamicToolNames).toContain("web_search");
@@ -1572,11 +1593,12 @@ describe("runCodexAppServerAttempt", () => {
directToolNames: ["message"],
});
const message = toolBridge.specs.find((tool) => tool.name === "message");
const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search");
const heartbeat = toolBridge.specs.find((tool) => tool.name === "heartbeat_respond");
const sessionsSpawn = toolBridge.specs.find((tool) => tool.name === "sessions_spawn");
const sessionsYield = toolBridge.specs.find((tool) => tool.name === "sessions_yield");
const specs = flattenSpecsWithNamespace(toolBridge.specs);
const message = specs.find((tool) => tool.name === "message");
const webSearch = specs.find((tool) => tool.name === "web_search");
const heartbeat = specs.find((tool) => tool.name === "heartbeat_respond");
const sessionsSpawn = specs.find((tool) => tool.name === "sessions_spawn");
const sessionsYield = specs.find((tool) => tool.name === "sessions_yield");
expect(message).not.toHaveProperty("namespace");
expect(message).not.toHaveProperty("deferLoading");
@@ -1624,7 +1646,7 @@ describe("runCodexAppServerAttempt", () => {
const normalInstructions = testing.buildDeveloperInstructions(createRunParams(), {
dynamicTools: normalBridge.availableSpecs,
});
const registeredToolNames = normalBridge.specs.map((tool) => tool.name);
const registeredToolNames = specNames(normalBridge.specs);
expect(registeredToolNames).toContain("message");
expect(registeredToolNames).toContain("heartbeat_respond");
@@ -1646,8 +1668,8 @@ describe("runCodexAppServerAttempt", () => {
registeredTools,
);
expect(heartbeatBridge.specs.map((tool) => tool.name)).toEqual(registeredToolNames);
expect(nextNormalBridge.specs.map((tool) => tool.name)).toEqual(registeredToolNames);
expect(specNames(heartbeatBridge.specs)).toEqual(registeredToolNames);
expect(specNames(nextNormalBridge.specs)).toEqual(registeredToolNames);
});
it("keeps the persistent dynamic schema stable across heartbeat-only turns", async () => {
@@ -1700,13 +1722,9 @@ describe("runCodexAppServerAttempt", () => {
registeredTools,
);
expect(heartbeatBridge.availableSpecs.map((tool) => tool.name)).toEqual(["heartbeat_respond"]);
expect(heartbeatBridge.specs.map((tool) => tool.name)).toEqual(
normalBridge.specs.map((tool) => tool.name),
);
expect(nextNormalBridge.specs.map((tool) => tool.name)).toEqual(
normalBridge.specs.map((tool) => tool.name),
);
expect(specNames(heartbeatBridge.availableSpecs)).toEqual(["heartbeat_respond"]);
expect(specNames(heartbeatBridge.specs)).toEqual(specNames(normalBridge.specs));
expect(specNames(nextNormalBridge.specs)).toEqual(specNames(normalBridge.specs));
});
it("disables Codex native tool surfaces when runtime toolsAllow is empty", async () => {
@@ -1745,7 +1763,7 @@ describe("runCodexAppServerAttempt", () => {
const startRequest = request.mock.calls.find(([method]) => method === "thread/start");
const startParams = startRequest?.[1] as
| {
dynamicTools?: Array<{ name?: string }>;
dynamicTools?: CodexDynamicToolSpec[];
environments?: unknown[];
developerInstructions?: string;
config?: {
@@ -5307,9 +5325,9 @@ describe("runCodexAppServerAttempt", () => {
const startRequest = requests.find((request) => request.method === "thread/start");
const startRequestParams = startRequest?.params as Record<string, unknown> | undefined;
const startConfig = startRequestParams?.config as Record<string, unknown> | undefined;
const dynamicToolNames = (
startRequestParams?.dynamicTools as Array<{ name?: string }> | undefined
)?.map((tool) => tool.name);
const dynamicToolNames = specNames(
(startRequestParams?.dynamicTools as CodexDynamicToolSpec[] | undefined) ?? [],
);
expect(startRequestParams?.model).toBe("local-model");
expect(startRequestParams?.modelProvider).toBe("lmstudio");
expect(startConfig?.web_search).toBe("disabled");
@@ -206,6 +206,7 @@ import {
readCodexDynamicToolCallParams,
} from "./protocol-validators.js";
import {
flattenCodexDynamicToolFunctions,
isJsonObject,
type CodexSandboxPolicy,
type CodexTurnEnvironmentParams,
@@ -920,7 +921,9 @@ export async function runCodexAppServerAttempt(
messages: historyMessages,
tokenBudget: params.contextTokenBudget,
availableTools: new Set(
toolBridge.availableSpecs.map((tool) => tool.name).filter(isNonEmptyString),
flattenCodexDynamicToolFunctions(toolBridge.availableSpecs)
.map((tool) => tool.name)
.filter(isNonEmptyString),
),
citationsMode: params.config?.memory?.citations,
modelId: params.modelId,
@@ -1355,7 +1358,7 @@ export async function runCodexAppServerAttempt(
threadId: thread.threadId,
authProfileId: startupAuthProfileId,
workspaceDir: effectiveWorkspace,
toolCount: toolBridge.specs.length,
toolCount: flattenCodexDynamicToolFunctions(toolBridge.specs).length,
});
recordCodexTrajectoryContext(trajectoryRecorder, {
attempt: params,
@@ -102,6 +102,7 @@ describe("Codex app-server dynamic tool schema boundary contract", () => {
const workspaceDir = path.join(tempDir, "workspace");
const parameterFreeTool = createParameterFreeTool("message");
const dynamicTool = {
type: "function" as const,
name: parameterFreeTool.name,
description: parameterFreeTool.description,
inputSchema: normalizedParameterFreeSchema(),
@@ -180,6 +181,7 @@ describe("Codex app-server dynamic tool schema boundary contract", () => {
cwd: workspaceDir,
dynamicTools: [
{
type: "function",
name: "message",
description: "Permissive test tool",
inputSchema: { type: "object" },
@@ -194,6 +196,7 @@ describe("Codex app-server dynamic tool schema boundary contract", () => {
cwd: workspaceDir,
dynamicTools: [
{
type: "function",
name: permissiveTool.name,
description: permissiveTool.description,
inputSchema: permissiveTool.parameters,
@@ -1,6 +1,7 @@
// Codex tests cover thread lifecycle.binding plugin behavior.
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { CodexDynamicToolFunctionSpec } from "./protocol.js";
import {
createParams as createRunAttemptParams,
setupRunAttemptTestHooks,
@@ -66,8 +67,9 @@ function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppS
function createMessageDynamicTool(
description: string,
actions: string[] = ["send"],
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
): CodexDynamicToolFunctionSpec {
return {
type: "function",
name: "message",
description,
inputSchema: {
@@ -84,10 +86,9 @@ function createMessageDynamicTool(
};
}
function createNamedDynamicTool(
name: string,
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
function createNamedDynamicTool(name: string): CodexDynamicToolFunctionSpec {
return {
type: "function",
name,
description: `${name} test tool`,
inputSchema: {
@@ -102,9 +103,10 @@ function createDeferredNamedDynamicTool(
name: string,
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
return {
...createNamedDynamicTool(name),
namespace: "openclaw",
deferLoading: true,
type: "namespace",
name: "openclaw",
description: "",
tools: [{ ...createNamedDynamicTool(name), deferLoading: true }],
};
}
@@ -196,23 +196,31 @@ describe("Codex app-server native code mode config", () => {
const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }), {
dynamicTools: [
{
type: "function",
name: "message",
description: "Send a message",
inputSchema: { type: "object" },
},
{
name: "music_generate",
description: "Create music",
inputSchema: { type: "object" },
namespace: "openclaw",
deferLoading: true,
},
{
name: "image_generate",
description: "Create images",
inputSchema: { type: "object" },
namespace: "openclaw",
deferLoading: true,
type: "namespace",
name: "openclaw",
description: "",
tools: [
{
type: "function",
name: "music_generate",
description: "Create music",
inputSchema: { type: "object" },
deferLoading: true,
},
{
type: "function",
name: "image_generate",
description: "Create images",
inputSchema: { type: "object" },
deferLoading: true,
},
],
},
],
});
@@ -228,11 +236,18 @@ describe("Codex app-server native code mode config", () => {
const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }), {
dynamicTools: [
{
name: "skill_workshop",
description: "Manage skill proposals",
inputSchema: { type: "object" },
namespace: "openclaw",
deferLoading: true,
type: "namespace",
name: "openclaw",
description: "",
tools: [
{
type: "function",
name: "skill_workshop",
description: "Manage skill proposals",
inputSchema: { type: "object" },
deferLoading: true,
},
],
},
],
});
@@ -250,6 +265,7 @@ describe("Codex app-server native code mode config", () => {
const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }), {
dynamicTools: [
{
type: "function",
name: "message",
description: "Send a message",
inputSchema: { type: "object" },
@@ -271,6 +287,7 @@ describe("Codex app-server native code mode config", () => {
};
const directFingerprint = codexDynamicToolsFingerprint([
{
type: "function",
name: "message",
description: "Send a visible message",
inputSchema,
@@ -278,11 +295,18 @@ describe("Codex app-server native code mode config", () => {
]);
const searchableFingerprint = codexDynamicToolsFingerprint([
{
name: "message",
description: "Load and send a visible message",
inputSchema,
namespace: "openclaw",
deferLoading: true,
type: "namespace",
name: "openclaw",
description: "",
tools: [
{
type: "function",
name: "message",
description: "Load and send a visible message",
inputSchema,
deferLoading: true,
},
],
},
]);
@@ -37,6 +37,7 @@ import {
assertCodexThreadStartResponse,
} from "./protocol-validators.js";
import {
flattenCodexDynamicToolFunctions,
isJsonObject,
type CodexDynamicToolSpec,
type CodexSandboxPolicy,
@@ -322,7 +323,7 @@ export async function startOrResumeThread(params: {
const dynamicToolsFingerprint = lifecycleTiming.measureSync("dynamic-tools-fingerprint", () =>
fingerprintDynamicTools(params.dynamicTools),
);
const dynamicToolsContainDeferred = params.dynamicTools.some(
const dynamicToolsContainDeferred = flattenCodexDynamicToolFunctions(params.dynamicTools).some(
(tool) => tool.deferLoading === true,
);
const webSearchPlan = lifecycleTiming.measureSync("web-search-plan", () =>
@@ -1489,17 +1490,25 @@ function fingerprintEnvironmentSelection(
}
function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
if (!isJsonObject(tool)) {
return stabilizeJsonValue(tool);
return stabilizeDynamicToolFingerprintValue(tool);
}
function stabilizeDynamicToolFingerprintValue(value: JsonValue): JsonValue {
if (Array.isArray(value)) {
return value.map(stabilizeDynamicToolFingerprintValue);
}
if (!isJsonObject(value)) {
return value;
}
const stable: JsonObject = {};
for (const [key, child] of Object.entries(tool).toSorted(([left], [right]) =>
for (const [key, child] of Object.entries(value).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (key === "description") {
continue;
}
stable[key] = stabilizeJsonValue(child);
stable[key] = stabilizeDynamicToolFingerprintValue(child);
}
return stable;
}
@@ -1574,7 +1583,7 @@ function buildDeferredDynamicToolManifest(
): string | undefined {
const deferredToolNames = [
...new Set(
(dynamicTools ?? [])
flattenCodexDynamicToolFunctions(dynamicTools)
.filter((tool) => tool.deferLoading === true)
.map((tool) => tool.name.trim())
.filter(Boolean),
@@ -1589,7 +1598,7 @@ function buildDeferredDynamicToolManifest(
function buildSkillWorkshopInstruction(
dynamicTools: readonly CodexDynamicToolSpec[] | undefined,
): string | undefined {
const hasSkillWorkshop = (dynamicTools ?? []).some(
const hasSkillWorkshop = flattenCodexDynamicToolFunctions(dynamicTools).some(
(tool) => tool.name.trim() === SKILL_WORKSHOP_TOOL_NAME,
);
if (!hasSkillWorkshop) {
@@ -1603,7 +1612,7 @@ function buildVisibleReplyInstruction(
dynamicTools: readonly CodexDynamicToolSpec[] | undefined,
): string {
const messageToolAvailable = dynamicTools
? dynamicTools.some((tool) => tool.name.trim() === "message")
? flattenCodexDynamicToolFunctions(dynamicTools).some((tool) => tool.name.trim() === "message")
: params.disableMessageTool !== true;
if (params.sourceReplyDeliveryMode === "message_tool_only" && messageToolAvailable) {
return "Visible source replies are not automatically delivered for this run. Use `message(action=send)` for user-visible source-channel output. Do not repeat that visible content in your final answer.";
@@ -5,6 +5,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createCodexTrajectoryRecorder,
recordCodexTrajectoryContext,
resolveCodexTrajectoryAppendFlags,
resolveCodexTrajectoryPointerFlags,
} from "./trajectory.js";
@@ -120,6 +121,55 @@ describe("Codex trajectory recorder", () => {
expect(parsed.modelId).toBe("gpt-5.5");
});
it("records namespace dynamic tools as callable trajectory tool definitions", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const init = {
cwd: tmpDir,
attempt: {
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never,
env: {},
tools: [
{
type: "namespace",
name: "openclaw",
description: "",
tools: [
{
type: "function",
name: "web_search",
description: "Search the web.",
inputSchema: { type: "object" },
deferLoading: true,
},
],
},
],
} satisfies Parameters<typeof createCodexTrajectoryRecorder>[0];
const recorder = createCodexTrajectoryRecorder(init);
recordCodexTrajectoryContext(expectTrajectoryRecorder(recorder), init);
await recorder?.flush();
const parsed = JSON.parse(
fs.readFileSync(path.join(tmpDir, "session.trajectory.jsonl"), "utf8"),
);
expect(parsed.data?.tools).toEqual([
{
name: "web_search",
description: "Search the web.",
parameters: { type: "object" },
},
]);
});
it("sanitizes session ids when resolving an override directory", async () => {
const tmpDir = makeTempDir();
const recorder = createCodexTrajectoryRecorder({
@@ -15,6 +15,7 @@ import {
resolveRegularFileAppendFlags,
} from "openclaw/plugin-sdk/security-runtime";
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
import { flattenCodexDynamicToolFunctions, type CodexDynamicToolSpec } from "./protocol.js";
/** Runtime trajectory recorder used by Codex run attempts and event projectors. */
export type CodexTrajectoryRecorder = {
@@ -28,7 +29,7 @@ type CodexTrajectoryInit = {
cwd: string;
developerInstructions?: string;
prompt?: string;
tools?: Array<{ name?: string; description?: string; inputSchema?: unknown }>;
tools?: CodexDynamicToolSpec[];
env?: NodeJS.ProcessEnv;
};
@@ -298,12 +299,12 @@ function resolveContainedPath(baseDir: string, fileName: string): string {
}
function toTrajectoryToolDefinitions(
tools: Array<{ name?: string; description?: string; inputSchema?: unknown }> | undefined,
tools: readonly CodexDynamicToolSpec[] | undefined,
): Array<{ name: string; description?: string; parameters?: unknown }> | undefined {
if (!tools || tools.length === 0) {
return undefined;
}
return tools
return flattenCodexDynamicToolFunctions(tools)
.flatMap((tool) => {
const name = tool.name?.trim();
if (!name) {
@@ -33,8 +33,21 @@ const checks: Array<{ file: string; snippets: string[] }> = [
},
{
file: "v2/DynamicToolSpec.ts",
snippets: [
'"function"',
"& DynamicToolFunctionSpec",
'"namespace"',
"& DynamicToolNamespaceSpec",
],
},
{
file: "v2/DynamicToolFunctionSpec.ts",
snippets: ["name: string", "description: string", "inputSchema: JsonValue"],
},
{
file: "v2/DynamicToolNamespaceSpec.ts",
snippets: ["name: string", "description: string", "tools: Array<DynamicToolNamespaceTool>"],
},
{
file: "v2/CommandExecutionApprovalDecision.ts",
snippets: ['"accept"', '"acceptForSession"', '"decline"', '"cancel"'],
@@ -69,12 +69,27 @@ const HAPPY_PATH_TOOL_NAMES = new Set([
"web_fetch",
]);
type CodexDynamicToolSpec = {
type CodexDynamicToolFunctionSpec = {
type?: "function";
name: string;
description?: string;
inputSchema?: unknown;
};
type CodexDynamicToolNamespaceSpec = {
type: "namespace";
name: string;
tools: CodexDynamicToolFunctionSpec[];
};
type CodexDynamicToolSpec = CodexDynamicToolFunctionSpec | CodexDynamicToolNamespaceSpec;
function flattenCodexDynamicToolSpecs(
specs: readonly CodexDynamicToolSpec[],
): CodexDynamicToolFunctionSpec[] {
return specs.flatMap((spec) => (spec.type === "namespace" ? spec.tools : [spec]));
}
type CodexPromptSnapshotApi = {
resolveCodexPromptSnapshotAppServerOptions: (pluginConfig?: unknown) => unknown;
buildCodexHarnessPromptSnapshot: (params: {
@@ -596,10 +611,8 @@ function selectedThreadStartParams(value: Record<string, unknown>): Record<strin
...value,
developerInstructions: "<see Reconstructed Model-Bound Prompt Layers>",
dynamicTools: Array.isArray(value.dynamicTools)
? value.dynamicTools.map((tool) =>
tool && typeof tool === "object" && "name" in tool
? (tool as { name?: unknown }).name
: tool,
? flattenCodexDynamicToolSpecs(value.dynamicTools as CodexDynamicToolSpec[]).map(
(tool) => tool.name,
)
: value.dynamicTools,
};
@@ -803,7 +816,8 @@ function renderScenarioSnapshot(
heartbeatCollaborationInstructions:
scenario.trigger === "heartbeat" ? CODEX_HEARTBEAT_COLLABORATION_INSTRUCTIONS : undefined,
});
const criticalToolSpecs = scenario.dynamicTools.filter((tool) =>
const dynamicToolFunctions = flattenCodexDynamicToolSpecs(scenario.dynamicTools);
const criticalToolSpecs = dynamicToolFunctions.filter((tool) =>
["message", "heartbeat_respond"].includes(tool.name),
);
const dynamicToolsJson = stableJson(scenario.dynamicTools);
@@ -863,7 +877,7 @@ function renderScenarioSnapshot(
...renderModelBoundPromptLayers({ scenario, codexSnapshot, dynamicToolsJson }),
"## Dynamic Tool Names",
"",
markdownFence("json", stableJson(scenario.dynamicTools.map((tool) => tool.name))),
markdownFence("json", stableJson(dynamicToolFunctions.map((tool) => tool.name))),
"",
"## Critical Visible-Reply Tool Specs",
"",