fix(codex): prefer native dynamic tools

This commit is contained in:
Peter Steinberger
2026-05-01 00:42:06 +01:00
committed by pashpashpash
parent ec1b96cdfa
commit 6eb5ce5de7
8 changed files with 153 additions and 3 deletions

View File

@@ -579,6 +579,19 @@ If a deployment needs additional environment isolation, add those variables to
`appServer.clearEnv` only affects the spawned Codex app-server child process.
Codex dynamic tools default to the `native-first` profile. In that mode,
OpenClaw does not expose dynamic tools that duplicate Codex-native workspace
operations: `read`, `write`, `edit`, `apply_patch`, `exec`, `process`, and
`update_plan`. OpenClaw integration tools such as messaging, sessions, media,
cron, browser, nodes, gateway, and `web_search` remain available.
Supported top-level Codex plugin fields:
| Field | Default | Meaning |
| -------------------------- | ---------------- | ----------------------------------------------------------------------------------------- |
| `codexDynamicToolsProfile` | `"native-first"` | Use `"openclaw-compat"` to expose the full OpenClaw dynamic tool set to Codex app-server. |
| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. |
Supported `appServer` fields:
| Field | Default | Meaning |

View File

@@ -33,6 +33,16 @@
"type": "object",
"additionalProperties": false,
"properties": {
"codexDynamicToolsProfile": {
"type": "string",
"enum": ["native-first", "openclaw-compat"],
"default": "native-first"
},
"codexDynamicToolsExclude": {
"type": "array",
"items": { "type": "string" },
"default": []
},
"discovery": {
"type": "object",
"additionalProperties": false,
@@ -141,6 +151,16 @@
}
},
"uiHints": {
"codexDynamicToolsProfile": {
"label": "Dynamic Tools Profile",
"help": "Select which OpenClaw dynamic tools are exposed to Codex app-server. native-first omits tools Codex already owns.",
"advanced": true
},
"codexDynamicToolsExclude": {
"label": "Dynamic Tool Excludes",
"help": "Additional OpenClaw dynamic tool names to omit from Codex app-server turns.",
"advanced": true
},
"discovery": {
"label": "Model Discovery",
"help": "Plugin-owned controls for discovering Codex app-server models."

View File

@@ -138,6 +138,18 @@ describe("Codex app-server config", () => {
);
});
it("parses dynamic tool profile controls", () => {
expect(
readCodexPluginConfig({
codexDynamicToolsProfile: "openclaw-compat",
codexDynamicToolsExclude: ["custom_tool"],
}),
).toMatchObject({
codexDynamicToolsProfile: "openclaw-compat",
codexDynamicToolsExclude: ["custom_tool"],
});
});
it("treats configured and environment commands as explicit overrides", () => {
expect(
resolveCodexAppServerRuntimeOptions({

View File

@@ -10,6 +10,7 @@ export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure"
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent";
export type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env";
export type CodexDynamicToolsProfile = "native-first" | "openclaw-compat";
export type CodexComputerUseConfig = {
enabled?: boolean;
@@ -55,6 +56,8 @@ export type CodexAppServerRuntimeOptions = {
};
export type CodexPluginConfig = {
codexDynamicToolsProfile?: CodexDynamicToolsProfile;
codexDynamicToolsExclude?: string[];
discovery?: {
enabled?: boolean;
timeoutMs?: number;
@@ -120,6 +123,7 @@ const codexAppServerApprovalPolicySchema = z.enum([
]);
const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]);
const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]);
const codexDynamicToolsProfileSchema = z.enum(["native-first", "openclaw-compat"]);
const codexAppServerServiceTierSchema = z
.preprocess(
(value) => (value === null ? null : resolveServiceTier(value)),
@@ -129,6 +133,8 @@ const codexAppServerServiceTierSchema = z
const codexPluginConfigSchema = z
.object({
codexDynamicToolsProfile: codexDynamicToolsProfileSchema.optional(),
codexDynamicToolsExclude: z.array(z.string()).optional(),
discovery: z
.object({
enabled: z.boolean().optional(),

View File

@@ -334,6 +334,40 @@ describe("runCodexAppServerAttempt", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("defaults Codex dynamic tools to the native-first profile", () => {
const tools = [
"read",
"write",
"edit",
"apply_patch",
"exec",
"process",
"update_plan",
"web_search",
"message",
"sessions_spawn",
].map((name) => ({ name }));
expect(__testing.applyCodexDynamicToolProfile(tools, {}).map((tool) => tool.name)).toEqual([
"web_search",
"message",
"sessions_spawn",
]);
});
it("allows Codex dynamic tool filtering to opt back into OpenClaw compatibility", () => {
const tools = ["read", "exec", "message", "custom_tool"].map((name) => ({ name }));
expect(
__testing
.applyCodexDynamicToolProfile(tools, {
codexDynamicToolsProfile: "openclaw-compat",
codexDynamicToolsExclude: ["custom_tool"],
})
.map((tool) => tool.name),
).toEqual(["read", "exec", "message"]);
});
it("returns a failed dynamic tool response when an app-server tool call exceeds the deadline", async () => {
vi.useFakeTimers();
let capturedSignal: AbortSignal | undefined;

View File

@@ -44,7 +44,11 @@ import {
} from "./client-factory.js";
import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js";
import { ensureCodexComputerUse } from "./computer-use.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import {
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
type CodexPluginConfig,
} from "./config.js";
import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js";
import { createCodexDynamicToolBridge, type CodexDynamicToolBridge } from "./dynamic-tools.js";
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
@@ -89,6 +93,15 @@ const CODEX_DYNAMIC_TOOL_TIMEOUT_MS = 30_000;
const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
const CODEX_NATIVE_FIRST_DYNAMIC_TOOL_EXCLUDES = [
"read",
"write",
"edit",
"apply_patch",
"exec",
"process",
"update_plan",
] as const;
type OpenClawCodingToolsOptions = NonNullable<
Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0]
@@ -231,7 +244,8 @@ export async function runCodexAppServerAttempt(
} = {},
): Promise<EmbeddedRunAttemptResult> {
const attemptStartedAt = Date.now();
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
@@ -281,6 +295,7 @@ export async function runCodexAppServerAttempt(
sandbox,
runAbortController,
sessionAgentId,
pluginConfig,
onYieldDetected: () => {
yieldDetected = true;
},
@@ -1232,6 +1247,7 @@ type DynamicToolBuildParams = {
sandbox: Awaited<ReturnType<typeof resolveSandboxContext>>;
runAbortController: AbortController;
sessionAgentId: string | undefined;
pluginConfig: CodexPluginConfig;
onYieldDetected: () => void;
};
@@ -1287,6 +1303,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config, undefined, {
workspaceDir: input.effectiveWorkspace,
}),
suppressManagedWebSearch: false,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
@@ -1305,7 +1322,8 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
input.runAbortController.abort("sessions_yield");
},
});
const visionFilteredTools = filterToolsForVisionInputs(allTools, {
const profiledTools = applyCodexDynamicToolProfile(allTools, input.pluginConfig);
const visionFilteredTools = filterToolsForVisionInputs(profiledTools, {
modelHasVision,
hasInboundImages: (params.images?.length ?? 0) > 0,
});
@@ -1326,6 +1344,26 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
});
}
function applyCodexDynamicToolProfile<T extends { name: string }>(
tools: T[],
config: CodexPluginConfig,
): T[] {
const excludes = new Set<string>();
const profile = config.codexDynamicToolsProfile ?? "native-first";
if (profile === "native-first") {
for (const name of CODEX_NATIVE_FIRST_DYNAMIC_TOOL_EXCLUDES) {
excludes.add(name);
}
}
for (const name of config.codexDynamicToolsExclude ?? []) {
const trimmed = name.trim();
if (trimmed) {
excludes.add(trimmed);
}
}
return excludes.size === 0 ? tools : tools.filter((tool) => !excludes.has(tool.name));
}
async function withCodexStartupTimeout<T>(params: {
timeoutMs: number;
timeoutFloorMs?: number;
@@ -1499,6 +1537,7 @@ export const __testing = {
CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS,
CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS,
buildCodexNativeHookRelayId,
applyCodexDynamicToolProfile,
filterToolsForVisionInputs,
handleDynamicToolCallWithTimeout,
...createCodexAppServerClientFactoryTestHooks((factory) => {

View File

@@ -68,6 +68,27 @@ describe("applyModelProviderToolPolicy", () => {
expect(toolNames(filtered)).toEqual(["read", "exec"]);
});
it("can keep managed web_search for Codex app-server dynamic tools", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
config: {
tools: {
web: {
search: {
enabled: true,
openaiCodex: { enabled: true, mode: "cached" },
},
},
},
},
modelProvider: "gateway",
modelApi: "openai-codex-responses",
modelId: "gpt-5.4",
suppressManagedWebSearch: false,
});
expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]);
});
it("removes managed web_search for direct Codex models when auth is available", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
config: {

View File

@@ -143,6 +143,7 @@ function applyModelProviderToolPolicy(
modelId?: string;
agentDir?: string;
modelCompat?: ModelCompatConfig;
suppressManagedWebSearch?: boolean;
},
): AnyAgentTool[] {
if (params?.config?.agents?.defaults?.experimental?.localModelLean === true) {
@@ -151,6 +152,7 @@ function applyModelProviderToolPolicy(
}
if (
params?.suppressManagedWebSearch !== false &&
shouldSuppressManagedWebSearchTool({
config: params?.config,
modelProvider: params?.modelProvider,
@@ -302,6 +304,8 @@ export function createOpenClawCodingTools(options?: {
modelContextWindowTokens?: number;
/** Resolved runtime model compatibility hints. */
modelCompat?: ModelCompatConfig;
/** If false, keep OpenClaw web_search even when a provider-native search tool is active. */
suppressManagedWebSearch?: boolean;
/**
* Auth mode for the current provider. We only need this for Anthropic OAuth
* tool-name blocking quirks.
@@ -685,6 +689,7 @@ export function createOpenClawCodingTools(options?: {
modelId: options?.modelId,
agentDir: options?.agentDir,
modelCompat: options?.modelCompat,
suppressManagedWebSearch: options?.suppressManagedWebSearch,
});
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
const senderIsOwner = options?.senderIsOwner === true;