From 21270f900b0f81d57a34f552f6c04d337a1188ba Mon Sep 17 00:00:00 2001 From: Christof Salis Date: Sun, 15 Mar 2026 11:20:05 +0100 Subject: [PATCH] Codex: add native web search for embedded Pi runs --- docs/tools/web.md | 35 ++ src/agents/codex-native-web-search.test.ts | 211 ++++++++++++ src/agents/codex-native-web-search.ts | 300 ++++++++++++++++++ .../pi-embedded-runner-extraparams.test.ts | 92 ++++++ src/agents/pi-embedded-runner/compact.ts | 1 + src/agents/pi-embedded-runner/extra-params.ts | 7 + .../openai-stream-wrappers.ts | 58 ++++ src/agents/pi-embedded-runner/run/attempt.ts | 2 + .../pi-tools.model-provider-collision.test.ts | 68 ++++ src/agents/pi-tools.ts | 36 ++- src/commands/auth-choice.apply.openai.ts | 9 + src/commands/auth-choice.test.ts | 7 +- src/commands/configure.wizard.test.ts | 119 ++++++- src/commands/configure.wizard.ts | 183 +++++++---- src/commands/models/auth.test.ts | 3 + src/commands/models/auth.ts | 3 + src/commands/onboard-search.test.ts | 94 +++++- src/commands/onboard-search.ts | 154 +++++++-- src/config/schema.help.ts | 19 +- src/config/schema.labels.ts | 8 + src/config/types.tools.ts | 20 +- src/config/web-search-codex-config.test.ts | 75 +++++ src/config/zod-schema.agent-runtime.ts | 41 +++ src/wizard/onboarding.finalize.test.ts | 50 +++ src/wizard/onboarding.finalize.ts | 22 ++ 25 files changed, 1523 insertions(+), 94 deletions(-) create mode 100644 src/agents/codex-native-web-search.test.ts create mode 100644 src/agents/codex-native-web-search.ts create mode 100644 src/config/web-search-codex-config.test.ts diff --git a/docs/tools/web.md b/docs/tools/web.md index a2aa1d37bfd..8b904083705 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -55,6 +55,41 @@ Runtime SecretRef behavior: - In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected. - If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast. +## Native Codex web search + +Codex-capable models can optionally use the provider-native Responses `web_search` tool instead of OpenClaw's managed `web_search` function. + +- Configure it under `tools.web.search.openaiCodex` +- It only activates for Codex-capable models (`openai-codex/*` or providers using `api: "openai-codex-responses"`) +- Managed `web_search` still applies to non-Codex models +- `mode: "cached"` is the default and recommended setting +- `tools.web.search.enabled: false` disables both managed and native search + +```json5 +{ + tools: { + web: { + search: { + enabled: true, + openaiCodex: { + enabled: true, + mode: "cached", + allowedDomains: ["example.com"], + contextSize: "high", + userLocation: { + country: "US", + city: "New York", + timezone: "America/New_York", + }, + }, + }, + }, + }, +} +``` + +If native Codex search is enabled but the current model is not Codex-capable, OpenClaw keeps the normal managed `web_search` behavior. + ## Setting up web search Use `openclaw configure --section web` to set up your API key and choose a provider. diff --git a/src/agents/codex-native-web-search.test.ts b/src/agents/codex-native-web-search.test.ts new file mode 100644 index 00000000000..a5cca3296cb --- /dev/null +++ b/src/agents/codex-native-web-search.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { + buildCodexNativeWebSearchTool, + patchCodexNativeWebSearchPayload, + resolveCodexNativeSearchActivation, + resolveCodexNativeWebSearchConfig, + shouldSuppressManagedWebSearchTool, +} from "./codex-native-web-search.js"; + +const baseConfig = { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { + enabled: true, + mode: "cached", + }, + }, + }, + }, +} as const; + +describe("resolveCodexNativeSearchActivation", () => { + it("returns managed_only when native Codex search is disabled", () => { + const result = resolveCodexNativeSearchActivation({ + config: { tools: { web: { search: { enabled: true } } } }, + modelProvider: "openai-codex", + modelApi: "openai-codex-responses", + }); + + expect(result.state).toBe("managed_only"); + expect(result.inactiveReason).toBe("codex_not_enabled"); + }); + + it("returns managed_only for non-eligible models", () => { + const result = resolveCodexNativeSearchActivation({ + config: baseConfig, + modelProvider: "openai", + modelApi: "openai-responses", + }); + + expect(result.state).toBe("managed_only"); + expect(result.inactiveReason).toBe("model_not_eligible"); + }); + + it("activates for direct openai-codex when auth exists", () => { + const result = resolveCodexNativeSearchActivation({ + config: { + ...baseConfig, + auth: { + profiles: { + "openai-codex:default": { + provider: "openai-codex", + mode: "oauth", + }, + }, + }, + }, + modelProvider: "openai-codex", + modelApi: "openai-codex-responses", + }); + + expect(result.state).toBe("native_active"); + expect(result.codexMode).toBe("cached"); + }); + + it("falls back to managed_only when direct openai-codex auth is missing", () => { + const result = resolveCodexNativeSearchActivation({ + config: baseConfig, + modelProvider: "openai-codex", + modelApi: "openai-codex-responses", + }); + + expect(result.state).toBe("managed_only"); + expect(result.inactiveReason).toBe("codex_auth_missing"); + }); + + it("activates for api-compatible openai-codex-responses providers without separate Codex auth", () => { + const result = resolveCodexNativeSearchActivation({ + config: baseConfig, + modelProvider: "gateway", + modelApi: "openai-codex-responses", + }); + + expect(result.state).toBe("native_active"); + }); + + it("keeps all search disabled when global web search is disabled", () => { + const result = resolveCodexNativeSearchActivation({ + config: { + tools: { + web: { + search: { + enabled: false, + openaiCodex: { enabled: true, mode: "live" }, + }, + }, + }, + }, + modelProvider: "openai-codex", + modelApi: "openai-codex-responses", + }); + + expect(result.state).toBe("managed_only"); + expect(result.inactiveReason).toBe("globally_disabled"); + }); +}); + +describe("Codex native web-search payload helpers", () => { + it("normalizes optional config values", () => { + const result = resolveCodexNativeWebSearchConfig({ + tools: { + web: { + search: { + openaiCodex: { + enabled: true, + allowedDomains: [" example.com ", "example.com", ""], + contextSize: "high", + userLocation: { + country: " US ", + city: " New York ", + timezone: "America/New_York", + }, + }, + }, + }, + }, + }); + + expect(result).toMatchObject({ + enabled: true, + mode: "cached", + allowedDomains: ["example.com"], + contextSize: "high", + userLocation: { + country: "US", + city: "New York", + timezone: "America/New_York", + }, + }); + }); + + it("builds the native Responses web_search tool", () => { + expect( + buildCodexNativeWebSearchTool({ + tools: { + web: { + search: { + openaiCodex: { + enabled: true, + mode: "live", + allowedDomains: ["example.com"], + contextSize: "medium", + userLocation: { country: "US" }, + }, + }, + }, + }, + }), + ).toEqual({ + type: "web_search", + external_web_access: true, + filters: { allowed_domains: ["example.com"] }, + search_context_size: "medium", + user_location: { + type: "approximate", + country: "US", + }, + }); + }); + + it("injects native web_search into provider payloads", () => { + const payload: Record = { tools: [{ type: "function", name: "read" }] }; + const result = patchCodexNativeWebSearchPayload({ payload, config: baseConfig }); + + expect(result.status).toBe("injected"); + expect(payload.tools).toEqual([ + { type: "function", name: "read" }, + { type: "web_search", external_web_access: false }, + ]); + }); + + it("does not inject a duplicate native web_search tool", () => { + const payload: Record = { tools: [{ type: "web_search" }] }; + const result = patchCodexNativeWebSearchPayload({ payload, config: baseConfig }); + + expect(result.status).toBe("native_tool_already_present"); + expect(payload.tools).toEqual([{ type: "web_search" }]); + }); +}); + +describe("shouldSuppressManagedWebSearchTool", () => { + it("suppresses managed web_search only when native Codex search is active", () => { + expect( + shouldSuppressManagedWebSearchTool({ + config: baseConfig, + modelProvider: "gateway", + modelApi: "openai-codex-responses", + }), + ).toBe(true); + + expect( + shouldSuppressManagedWebSearchTool({ + config: baseConfig, + modelProvider: "openai", + modelApi: "openai-responses", + }), + ).toBe(false); + }); +}); diff --git a/src/agents/codex-native-web-search.ts b/src/agents/codex-native-web-search.ts new file mode 100644 index 00000000000..4e367dd6d85 --- /dev/null +++ b/src/agents/codex-native-web-search.ts @@ -0,0 +1,300 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; +import { resolveDefaultModelForAgent } from "./model-selection.js"; + +export type CodexNativeSearchMode = "cached" | "live"; +export type CodexNativeSearchContextSize = "low" | "medium" | "high"; + +export type CodexNativeSearchUserLocation = { + country?: string; + region?: string; + city?: string; + timezone?: string; +}; + +export type ResolvedCodexNativeWebSearchConfig = { + enabled: boolean; + mode: CodexNativeSearchMode; + allowedDomains?: string[]; + contextSize?: CodexNativeSearchContextSize; + userLocation?: CodexNativeSearchUserLocation; +}; + +export type CodexNativeSearchActivation = { + globalWebSearchEnabled: boolean; + codexNativeEnabled: boolean; + codexMode: CodexNativeSearchMode; + nativeEligible: boolean; + hasRequiredAuth: boolean; + state: "managed_only" | "native_active"; + inactiveReason?: + | "globally_disabled" + | "codex_not_enabled" + | "model_not_eligible" + | "codex_auth_missing"; +}; + +export type CodexNativeSearchPayloadPatchResult = { + status: "payload_not_object" | "native_tool_already_present" | "injected"; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimToUndefined(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeAllowedDomains(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const deduped = [ + ...new Set( + value + .map((entry) => trimToUndefined(entry)) + .filter((entry): entry is string => typeof entry === "string"), + ), + ]; + return deduped.length > 0 ? deduped : undefined; +} + +function normalizeContextSize(value: unknown): CodexNativeSearchContextSize | undefined { + if (value === "low" || value === "medium" || value === "high") { + return value; + } + return undefined; +} + +function normalizeMode(value: unknown): CodexNativeSearchMode { + return value === "live" ? "live" : "cached"; +} + +function normalizeUserLocation(value: unknown): CodexNativeSearchUserLocation | undefined { + if (!isRecord(value)) { + return undefined; + } + const location = { + country: trimToUndefined(value.country), + region: trimToUndefined(value.region), + city: trimToUndefined(value.city), + timezone: trimToUndefined(value.timezone), + }; + return location.country || location.region || location.city || location.timezone + ? location + : undefined; +} + +export function resolveCodexNativeWebSearchConfig( + config: OpenClawConfig | undefined, +): ResolvedCodexNativeWebSearchConfig { + const nativeConfig = config?.tools?.web?.search?.openaiCodex; + return { + enabled: nativeConfig?.enabled === true, + mode: normalizeMode(nativeConfig?.mode), + allowedDomains: normalizeAllowedDomains(nativeConfig?.allowedDomains), + contextSize: normalizeContextSize(nativeConfig?.contextSize), + userLocation: normalizeUserLocation(nativeConfig?.userLocation), + }; +} + +export function isCodexNativeSearchEligibleModel(params: { + modelProvider?: string; + modelApi?: string; +}): boolean { + return params.modelProvider === "openai-codex" || params.modelApi === "openai-codex-responses"; +} + +export function hasCodexNativeWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some( + (tool) => isRecord(tool) && typeof tool.type === "string" && tool.type === "web_search", + ); +} + +export function hasAvailableCodexAuth(params: { + config?: OpenClawConfig; + agentDir?: string; +}): boolean { + if (params.agentDir) { + try { + if ( + listProfilesForProvider(ensureAuthProfileStore(params.agentDir), "openai-codex").length > 0 + ) { + return true; + } + } catch { + // Fall back to config-based detection below. + } + } + + return Object.values(params.config?.auth?.profiles ?? {}).some( + (profile) => isRecord(profile) && profile.provider === "openai-codex", + ); +} + +export function resolveCodexNativeSearchActivation(params: { + config?: OpenClawConfig; + modelProvider?: string; + modelApi?: string; + agentDir?: string; +}): CodexNativeSearchActivation { + const globalWebSearchEnabled = params.config?.tools?.web?.search?.enabled !== false; + const codexConfig = resolveCodexNativeWebSearchConfig(params.config); + const nativeEligible = isCodexNativeSearchEligibleModel(params); + const hasRequiredAuth = params.modelProvider !== "openai-codex" || hasAvailableCodexAuth(params); + + if (!globalWebSearchEnabled) { + return { + globalWebSearchEnabled, + codexNativeEnabled: codexConfig.enabled, + codexMode: codexConfig.mode, + nativeEligible, + hasRequiredAuth, + state: "managed_only", + inactiveReason: "globally_disabled", + }; + } + + if (!codexConfig.enabled) { + return { + globalWebSearchEnabled, + codexNativeEnabled: false, + codexMode: codexConfig.mode, + nativeEligible, + hasRequiredAuth, + state: "managed_only", + inactiveReason: "codex_not_enabled", + }; + } + + if (!nativeEligible) { + return { + globalWebSearchEnabled, + codexNativeEnabled: true, + codexMode: codexConfig.mode, + nativeEligible: false, + hasRequiredAuth, + state: "managed_only", + inactiveReason: "model_not_eligible", + }; + } + + if (!hasRequiredAuth) { + return { + globalWebSearchEnabled, + codexNativeEnabled: true, + codexMode: codexConfig.mode, + nativeEligible: true, + hasRequiredAuth: false, + state: "managed_only", + inactiveReason: "codex_auth_missing", + }; + } + + return { + globalWebSearchEnabled, + codexNativeEnabled: true, + codexMode: codexConfig.mode, + nativeEligible: true, + hasRequiredAuth: true, + state: "native_active", + }; +} + +export function buildCodexNativeWebSearchTool( + config: OpenClawConfig | undefined, +): Record { + const nativeConfig = resolveCodexNativeWebSearchConfig(config); + const tool: Record = { + type: "web_search", + external_web_access: nativeConfig.mode === "live", + }; + + if (nativeConfig.allowedDomains) { + tool.filters = { + allowed_domains: nativeConfig.allowedDomains, + }; + } + + if (nativeConfig.contextSize) { + tool.search_context_size = nativeConfig.contextSize; + } + + if (nativeConfig.userLocation) { + tool.user_location = { + type: "approximate", + ...nativeConfig.userLocation, + }; + } + + return tool; +} + +export function patchCodexNativeWebSearchPayload(params: { + payload: unknown; + config?: OpenClawConfig; +}): CodexNativeSearchPayloadPatchResult { + if (!isRecord(params.payload)) { + return { status: "payload_not_object" }; + } + + const payload = params.payload; + if (hasCodexNativeWebSearchTool(payload.tools)) { + return { status: "native_tool_already_present" }; + } + + const tools = Array.isArray(payload.tools) ? [...payload.tools] : []; + tools.push(buildCodexNativeWebSearchTool(params.config)); + payload.tools = tools; + return { status: "injected" }; +} + +export function shouldSuppressManagedWebSearchTool(params: { + config?: OpenClawConfig; + modelProvider?: string; + modelApi?: string; + agentDir?: string; +}): boolean { + return resolveCodexNativeSearchActivation(params).state === "native_active"; +} + +export function isCodexNativeWebSearchRelevant(params: { + config: OpenClawConfig; + agentId?: string; + agentDir?: string; +}): boolean { + if (resolveCodexNativeWebSearchConfig(params.config).enabled) { + return true; + } + if (hasAvailableCodexAuth(params)) { + return true; + } + + const defaultModel = resolveDefaultModelForAgent({ + cfg: params.config, + agentId: params.agentId, + }); + const configuredProvider = params.config.models?.providers?.[defaultModel.provider]; + return isCodexNativeSearchEligibleModel({ + modelProvider: defaultModel.provider, + modelApi: configuredProvider?.api, + }); +} + +export function describeCodexNativeWebSearch( + config: OpenClawConfig | undefined, +): string | undefined { + const nativeConfig = resolveCodexNativeWebSearchConfig(config); + if (!nativeConfig.enabled) { + return undefined; + } + return `Codex native search: ${nativeConfig.mode} for Codex-capable models`; +} diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 7a29f30f9eb..92bfc52325b 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -1193,6 +1193,98 @@ describe("applyExtraParamsToAgent", () => { expect(calls[0]?.openaiWsWarmup).toBe(false); }); + it("injects native Codex web_search for api-compatible Responses models", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "gateway", + applyModelId: "gpt-5.4", + cfg: { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { + enabled: true, + mode: "live", + allowedDomains: ["example.com"], + }, + }, + }, + }, + }, + model: { + api: "openai-codex-responses", + provider: "gateway", + id: "gpt-5.4", + } as Model<"openai-codex-responses">, + payload: { tools: [{ type: "function", name: "read" }] }, + }); + + expect(payload.tools).toEqual([ + { type: "function", name: "read" }, + { + type: "web_search", + external_web_access: true, + filters: { allowed_domains: ["example.com"] }, + }, + ]); + }); + + it("does not inject duplicate native Codex web_search tools", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "gateway", + applyModelId: "gpt-5.4", + cfg: { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { + enabled: true, + mode: "cached", + }, + }, + }, + }, + }, + model: { + api: "openai-codex-responses", + provider: "gateway", + id: "gpt-5.4", + } as Model<"openai-codex-responses">, + payload: { tools: [{ type: "web_search" }] }, + }); + + expect(payload.tools).toEqual([{ type: "web_search" }]); + }); + + it("keeps payload unchanged when Codex native search is inactive", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5", + cfg: { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { + enabled: true, + mode: "cached", + }, + }, + }, + }, + }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5", + } as Model<"openai-responses">, + payload: { tools: [{ type: "function", name: "read" }] }, + }); + + expect(payload.tools).toEqual([{ type: "function", name: "read" }]); + }); + it("lets runtime options override OpenAI default transport", () => { const { calls, agent } = createOptionsCaptureAgent(); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 63678333bed..622e3d92cd1 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -552,6 +552,7 @@ export async function compactEmbeddedPiSessionDirect( abortSignal: runAbortController.signal, modelProvider: model.provider, modelId, + modelApi: model.api, modelContextWindowTokens: ctxInfo.tokens, modelAuthMode: resolveModelAuthMode(model.provider, params.config), }); diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index a9d5085e013..1e6984fa451 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -23,6 +23,7 @@ import { } from "./moonshot-stream-wrappers.js"; import { createCodexDefaultTransportWrapper, + createCodexNativeWebSearchWrapper, createOpenAIDefaultTransportWrapper, createOpenAIFastModeWrapper, createOpenAIResponsesContextManagementWrapper, @@ -335,6 +336,7 @@ export function applyExtraParamsToAgent( extraParamsOverride?: Record, thinkingLevel?: ThinkLevel, agentId?: string, + agentDir?: string, ): void { const resolvedExtraParams = resolveExtraParams({ cfg, @@ -459,6 +461,11 @@ export function applyExtraParamsToAgent( agent.streamFn = createOpenAIServiceTierWrapper(agent.streamFn, openAIServiceTier); } + agent.streamFn = createCodexNativeWebSearchWrapper(agent.streamFn, { + config: cfg, + agentDir, + }); + // Work around upstream pi-ai hardcoding `store: false` for Responses API. // Force `store=true` for direct OpenAI Responses models and auto-enable // server-side compaction for compatible OpenAI Responses payloads. diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index 8542f329cbe..90e527a7efc 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -1,6 +1,11 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { SimpleStreamOptions } from "@mariozechner/pi-ai"; import { streamSimple } from "@mariozechner/pi-ai"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + patchCodexNativeWebSearchPayload, + resolveCodexNativeSearchActivation, +} from "../codex-native-web-search.js"; import { log } from "./logger.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; @@ -334,6 +339,59 @@ export function createOpenAIServiceTierWrapper( }; } +export function createCodexNativeWebSearchWrapper( + baseStreamFn: StreamFn | undefined, + params: { config?: OpenClawConfig; agentDir?: string }, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + const activation = resolveCodexNativeSearchActivation({ + config: params.config, + modelProvider: typeof model.provider === "string" ? model.provider : undefined, + modelApi: typeof model.api === "string" ? model.api : undefined, + agentDir: params.agentDir, + }); + + if (activation.state !== "native_active") { + if (activation.codexNativeEnabled) { + log.debug( + `skipping Codex native web search (${activation.inactiveReason ?? "inactive"}) for ${String( + model.provider ?? "unknown", + )}/${String(model.id ?? "unknown")}`, + ); + } + return underlying(model, context, options); + } + + log.debug( + `activating Codex native web search (${activation.codexMode}) for ${String( + model.provider ?? "unknown", + )}/${String(model.id ?? "unknown")}`, + ); + + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + const result = patchCodexNativeWebSearchPayload({ + payload, + config: params.config, + }); + if (result.status === "payload_not_object") { + log.debug( + "Skipping Codex native web search injection because provider payload is not an object", + ); + } else if (result.status === "native_tool_already_present") { + log.debug("Codex native web search tool already present in provider payload"); + } else if (result.status === "injected") { + log.debug("Injected Codex native web search tool into provider payload"); + } + return originalOnPayload?.(payload, model); + }, + }); + }; +} + export function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index a4cf2d75260..b0cfa8dddb2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1512,6 +1512,7 @@ export async function runEmbeddedAttempt( abortSignal: runAbortController.signal, modelProvider: params.model.provider, modelId: params.modelId, + modelApi: params.model.api, modelContextWindowTokens: params.model.contextWindow, modelAuthMode: resolveModelAuthMode(params.model.provider, params.config), currentChannelId: params.currentChannelId, @@ -1938,6 +1939,7 @@ export async function runEmbeddedAttempt( }, params.thinkLevel, sessionAgentId, + agentDir, ); if (cacheTrace) { diff --git a/src/agents/pi-tools.model-provider-collision.test.ts b/src/agents/pi-tools.model-provider-collision.test.ts index 7cbceac712e..052373a6ea2 100644 --- a/src/agents/pi-tools.model-provider-collision.test.ts +++ b/src/agents/pi-tools.model-provider-collision.test.ts @@ -39,4 +39,72 @@ describe("applyModelProviderToolPolicy", () => { expect(toolNames(filtered)).toEqual(["read", "exec"]); }); + + it("removes managed web_search when native Codex search is active", () => { + 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", + }); + + expect(toolNames(filtered)).toEqual(["read", "exec"]); + }); + + it("removes managed web_search for direct Codex models when auth is available", () => { + const filtered = __testing.applyModelProviderToolPolicy(baseTools, { + config: { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { enabled: true, mode: "cached" }, + }, + }, + }, + auth: { + profiles: { + "openai-codex:default": { + provider: "openai-codex", + mode: "oauth", + }, + }, + }, + }, + modelProvider: "openai-codex", + modelApi: "openai-codex-responses", + modelId: "gpt-5.4", + }); + + expect(toolNames(filtered)).toEqual(["read", "exec"]); + }); + + it("keeps managed web_search when Codex native search cannot activate", () => { + const filtered = __testing.applyModelProviderToolPolicy(baseTools, { + config: { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { enabled: true, mode: "cached" }, + }, + }, + }, + }, + modelProvider: "openai-codex", + modelApi: "openai-codex-responses", + modelId: "gpt-5.4", + }); + + expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]); + }); }); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 6536e9dfbb5..fbba7902647 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -15,6 +15,7 @@ import { type ProcessToolDefaults, } from "./bash-tools.js"; import { listChannelAgentTools } from "./channel-tools.js"; +import { shouldSuppressManagedWebSearchTool } from "./codex-native-web-search.js"; import { resolveImageSanitizationLimits } from "./image-sanitization.js"; import type { ModelAuthMode } from "./model-auth.js"; import { createOpenClawTools } from "./openclaw-tools.js"; @@ -92,14 +93,32 @@ function applyMessageProviderToolPolicy( function applyModelProviderToolPolicy( tools: AnyAgentTool[], - params?: { modelProvider?: string; modelId?: string }, + params?: { + config?: OpenClawConfig; + modelProvider?: string; + modelApi?: string; + modelId?: string; + agentDir?: string; + }, ): AnyAgentTool[] { - if (!isXaiProvider(params?.modelProvider, params?.modelId)) { - return tools; + if (isXaiProvider(params?.modelProvider, params?.modelId)) { + // xAI/Grok providers expose a native web_search tool; sending OpenClaw's + // web_search alongside it causes duplicate-name request failures. + return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name)); } - // xAI/Grok providers expose a native web_search tool; sending OpenClaw's - // web_search alongside it causes duplicate-name request failures. - return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name)); + + if ( + shouldSuppressManagedWebSearchTool({ + config: params?.config, + modelProvider: params?.modelProvider, + modelApi: params?.modelApi, + agentDir: params?.agentDir, + }) + ) { + return tools.filter((tool) => tool.name !== "web_search"); + } + + return tools; } function isApplyPatchAllowedForModel(params: { @@ -230,6 +249,8 @@ export function createOpenClawCodingTools(options?: { modelProvider?: string; /** Model id for the current provider (used for model-specific tool gating). */ modelId?: string; + /** Model API for the current provider (used for provider-native tool arbitration). */ + modelApi?: string; /** Model context window in tokens (used to scale read-tool output budget). */ modelContextWindowTokens?: number; /** @@ -562,8 +583,11 @@ export function createOpenClawCodingTools(options?: { options?.messageProvider, ); const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, { + config: options?.config, modelProvider: options?.modelProvider, + modelApi: options?.modelApi, modelId: options?.modelId, + agentDir: options?.agentDir, }); // Security: treat unknown/undefined as unauthorized (opt-in, not opt-out) const senderIsOwner = options?.senderIsOwner === true; diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts index 57059307920..5db055ff61b 100644 --- a/src/commands/auth-choice.apply.openai.ts +++ b/src/commands/auth-choice.apply.openai.ts @@ -115,6 +115,15 @@ export async function applyAuthChoiceOpenAI( agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL; await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL); } + await params.prompter.note( + [ + "Codex-capable models can optionally use native Codex web search.", + "Enable it with openclaw configure --section web.", + "Recommended mode: cached.", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); } return { config: nextConfig, agentModelOverride }; } diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index d5a59e48d46..d0a2d63fa58 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -164,7 +164,8 @@ describe("applyAuthChoice", () => { expires: Date.now() + 60_000, }); - const prompter = createPrompter({}); + const note = vi.fn(async () => {}); + const prompter = createPrompter({ note }); const runtime = createExitThrowingRuntime(); const result = await applyAuthChoice({ @@ -187,6 +188,10 @@ describe("applyAuthChoice", () => { access: "access-token", email: "user@example.com", }); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("native Codex web search"), + "Web search", + ); }); it("prompts and writes provider API key for common providers", async () => { diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 034a3fdf505..ad0348d1863 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const mocks = vi.hoisted(() => ({ @@ -99,6 +99,11 @@ import { WizardCancelledError } from "../wizard/prompts.js"; import { runConfigureWizard } from "./configure.wizard.js"; describe("runConfigureWizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); + }); + it("persists gateway.mode=local when only the run mode is selected", async () => { mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, @@ -158,4 +163,116 @@ describe("runConfigureWizard", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + + it("can enable native Codex search without configuring a managed provider", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: { + auth: { + profiles: { + "openai-codex:default": { + provider: "openai-codex", + mode: "oauth", + }, + }, + }, + }, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.clackSelect.mockResolvedValueOnce("local").mockResolvedValueOnce("cached"); + mocks.clackConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + mocks.clackText.mockResolvedValue(""); + + await runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); + + expect(mocks.writeConfigFile).toHaveBeenLastCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + web: expect.objectContaining({ + search: expect.objectContaining({ + enabled: true, + openaiCodex: expect.objectContaining({ + enabled: true, + mode: "cached", + }), + }), + }), + }), + }), + ); + }); + + it("persists openaiCodex.enabled=false when the toggle is disabled", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { + enabled: true, + mode: "live", + }, + }, + }, + }, + }, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.clackSelect.mockResolvedValueOnce("local").mockResolvedValueOnce("brave"); + mocks.clackConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + mocks.clackText.mockResolvedValue(""); + + await runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); + + expect(mocks.writeConfigFile).toHaveBeenLastCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + web: expect.objectContaining({ + search: expect.objectContaining({ + enabled: true, + openaiCodex: expect.objectContaining({ + enabled: false, + mode: "live", + }), + }), + }), + }), + }), + ); + }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 80af67043ab..fb5b6568bfd 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -173,6 +173,8 @@ async function promptWebToolsConfig( applySearchKey, hasKeyInEnv, } = await import("./onboard-search.js"); + const { describeCodexNativeWebSearch, isCodexNativeWebSearchRelevant } = + await import("../agents/codex-native-web-search.js"); type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"]; const hasKeyForProvider = (provider: string): boolean => { @@ -197,7 +199,7 @@ async function promptWebToolsConfig( note( [ "Web search lets your agent look things up online using the `web_search` tool.", - "Choose a provider and paste your API key.", + "Choose a managed provider now, and Codex-capable models can also use native Codex web search.", "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", @@ -218,62 +220,137 @@ async function promptWebToolsConfig( }; if (enableSearch) { - const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => { - const configured = hasKeyForProvider(entry.value); - return { - value: entry.value, - label: entry.label, - hint: configured ? `${entry.hint} · configured` : entry.hint, - }; - }); - - const providerChoice = guardCancel( - await select({ - message: "Choose web search provider", - options: providerOptions, - initialValue: existingProvider, - }), - runtime, - ); - - nextSearch = { ...nextSearch, provider: providerChoice }; - - const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; - const existingKey = resolveExistingKey(nextConfig, providerChoice as SP); - const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP); - const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); - const envVarNames = entry.envKeys.join(" / "); - - const keyInput = guardCancel( - await text({ - message: keyConfigured - ? envAvailable - ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` - : `${entry.label} API key (leave blank to keep current)` - : envAvailable - ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` - : `${entry.label} API key`, - placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, - }), - runtime, - ); - const key = String(keyInput ?? "").trim(); - - if (key || existingKey) { - const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!); - nextSearch = { ...applied.tools?.web?.search }; - } else if (keyConfigured || envAvailable) { - nextSearch = { ...nextSearch }; - } else { + const codexRelevant = isCodexNativeWebSearchRelevant({ config: nextConfig }); + let configureManagedProvider = true; + if (codexRelevant) { note( [ - "No key stored yet — web_search won't work until a key is available.", - `Store a key here or set ${envVarNames} in the Gateway environment.`, - `Get your API key at: ${entry.signupUrl}`, - "Docs: https://docs.openclaw.ai/tools/web", + "Codex-capable models can optionally use native Codex web search.", + "Managed web_search still controls non-Codex models.", + "If no managed provider is configured, non-Codex models still rely on provider auto-detect and may have no search available.", + ...(describeCodexNativeWebSearch(nextConfig) + ? [describeCodexNativeWebSearch(nextConfig)!] + : ["Recommended mode: cached."]), ].join("\n"), - "Web search", + "Codex native search", ); + const enableCodexNative = guardCancel( + await confirm({ + message: "Enable native Codex web search for Codex-capable models?", + initialValue: existingSearch?.openaiCodex?.enabled === true, + }), + runtime, + ); + if (enableCodexNative) { + const codexMode = guardCancel( + await select({ + message: "Codex native web search mode", + options: [ + { + value: "cached", + label: "cached (recommended)", + hint: "Uses cached web content", + }, + { + value: "live", + label: "live", + hint: "Allows live external web access", + }, + ], + initialValue: existingSearch?.openaiCodex?.mode ?? "cached", + }), + runtime, + ); + nextSearch = { + ...nextSearch, + openaiCodex: { + ...existingSearch?.openaiCodex, + enabled: true, + mode: codexMode, + }, + }; + configureManagedProvider = guardCancel( + await confirm({ + message: "Configure or change a managed web search provider now?", + initialValue: Boolean(existingProvider), + }), + runtime, + ); + } else { + nextSearch = { + ...nextSearch, + openaiCodex: { + ...existingSearch?.openaiCodex, + enabled: false, + }, + }; + } + } + + if (configureManagedProvider) { + const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => { + const configured = hasKeyForProvider(entry.value); + return { + value: entry.value, + label: entry.label, + hint: configured ? `${entry.hint} · configured` : entry.hint, + }; + }); + + const providerChoice = guardCancel( + await select({ + message: "Choose web search provider", + options: providerOptions, + initialValue: existingProvider, + }), + runtime, + ); + + nextSearch = { ...nextSearch, provider: providerChoice }; + + const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!; + const existingKey = resolveExistingKey(nextConfig, providerChoice as SP); + const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP); + const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim())); + const envVarNames = entry.envKeys.join(" / "); + + const keyInput = guardCancel( + await text({ + message: keyConfigured + ? envAvailable + ? `${entry.label} API key (leave blank to keep current or use ${envVarNames})` + : `${entry.label} API key (leave blank to keep current)` + : envAvailable + ? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})` + : `${entry.label} API key`, + placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder, + }), + runtime, + ); + const key = String(keyInput ?? "").trim(); + + if (key || existingKey) { + const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!); + nextSearch = { + ...applied.tools?.web?.search, + openaiCodex: { + ...existingSearch?.openaiCodex, + ...(nextSearch.openaiCodex as Record | undefined), + }, + }; + } else if (keyConfigured || envAvailable) { + nextSearch = { ...nextSearch }; + } else { + note( + [ + "No key stored yet — web_search won't work until a key is available.", + `Store a key here or set ${envVarNames} in the Gateway environment.`, + `Get your API key at: ${entry.signupUrl}`, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); + } } } diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index e59e7fd021e..a20da2d06d1 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -185,6 +185,9 @@ describe("modelsAuthLoginCommand", () => { expect(runtime.log).toHaveBeenCalledWith( "Default model available: openai-codex/gpt-5.4 (use --set-default to apply)", ); + expect(runtime.log).toHaveBeenCalledWith( + "Tip: Codex-capable models can use native Codex web search. Enable it with openclaw configure --section web (recommended mode: cached). Docs: https://docs.openclaw.ai/tools/web", + ); }); it("applies openai-codex default model when --set-default is used", async () => { diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 56946d590a7..b2834c48811 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -340,6 +340,9 @@ async function runBuiltInOpenAICodexLogin(params: { `Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`, ); } + params.runtime.log( + "Tip: Codex-capable models can use native Codex web search. Enable it with openclaw configure --section web (recommended mode: cached). Docs: https://docs.openclaw.ai/tools/web", + ); } export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) { diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 10e2df9f81b..6fefd563292 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -12,11 +12,21 @@ const runtime: RuntimeEnv = { }) as RuntimeEnv["exit"], }; -function createPrompter(params: { selectValue?: string; textValue?: string }): { +function createPrompter(params: { + selectValue?: string; + selectValues?: string[]; + textValue?: string; + textValues?: string[]; + confirmValue?: boolean; + confirmValues?: boolean[]; +}): { prompter: WizardPrompter; notes: Array<{ title?: string; message: string }>; } { const notes: Array<{ title?: string; message: string }> = []; + const selectQueue = [...(params.selectValues ?? [])]; + const textQueue = [...(params.textValues ?? [])]; + const confirmQueue = [...(params.confirmValues ?? [])]; const prompter: WizardPrompter = { intro: vi.fn(async () => {}), outro: vi.fn(async () => {}), @@ -24,11 +34,11 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): { notes.push({ title, message }); }), select: vi.fn( - async () => params.selectValue ?? "perplexity", + async () => selectQueue.shift() ?? params.selectValue ?? "perplexity", ) as unknown as WizardPrompter["select"], multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"], - text: vi.fn(async () => params.textValue ?? ""), - confirm: vi.fn(async () => true), + text: vi.fn(async () => textQueue.shift() ?? params.textValue ?? ""), + confirm: vi.fn(async () => confirmQueue.shift() ?? params.confirmValue ?? true), progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), }; return { prompter, notes }; @@ -73,11 +83,12 @@ async function runQuickstartPerplexitySetup( } describe("setupSearch", () => { - it("returns config unchanged when user skips", async () => { + it("lets users skip provider setup after enabling web_search", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ selectValue: "__skip__" }); const result = await setupSearch(cfg, runtime, prompter); - expect(result).toBe(cfg); + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.provider).toBeUndefined(); }); it("sets provider and key for perplexity", async () => { @@ -151,7 +162,7 @@ describe("setupSearch", () => { }); const result = await setupSearch(cfg, runtime, prompter); expect(result.tools?.web?.search?.provider).toBe("brave"); - expect(result.tools?.web?.search?.enabled).toBeUndefined(); + expect(result.tools?.web?.search?.enabled).toBe(true); const missingNote = notes.find((n) => n.message.includes("No API key stored")); expect(missingNote).toBeDefined(); } finally { @@ -171,11 +182,13 @@ describe("setupSearch", () => { expect(result.tools?.web?.search?.enabled).toBe(true); }); - it("advanced preserves enabled:false when keeping existing key", async () => { - const result = await runBlankPerplexityKeyEntry( + it("keeps search disabled when the onboarding toggle is declined", async () => { + const cfg = createPerplexityConfig( "existing-key", // pragma: allowlist secret false, ); + const { prompter } = createPrompter({ confirmValue: false }); + const result = await setupSearch(cfg, runtime, prompter); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key"); expect(result.tools?.web?.search?.enabled).toBe(false); }); @@ -190,11 +203,15 @@ describe("setupSearch", () => { expect(prompter.text).not.toHaveBeenCalled(); }); - it("quickstart preserves enabled:false when search was intentionally disabled", async () => { - const { result, prompter } = await runQuickstartPerplexitySetup( + it("quickstart keeps search disabled when the onboarding toggle is declined", async () => { + const cfg = createPerplexityConfig( "stored-pplx-key", // pragma: allowlist secret false, ); + const { prompter } = createPrompter({ confirmValue: false }); + const result = await setupSearch(cfg, runtime, prompter, { + quickstartDefaults: true, + }); expect(result.tools?.web?.search?.provider).toBe("perplexity"); expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key"); expect(result.tools?.web?.search?.enabled).toBe(false); @@ -212,7 +229,7 @@ describe("setupSearch", () => { }); expect(prompter.text).toHaveBeenCalled(); expect(result.tools?.web?.search?.provider).toBe("grok"); - expect(result.tools?.web?.search?.enabled).toBeUndefined(); + expect(result.tools?.web?.search?.enabled).toBe(true); } finally { if (original === undefined) { delete process.env.XAI_API_KEY; @@ -283,6 +300,59 @@ describe("setupSearch", () => { expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain"); }); + it("can enable native Codex search without forcing managed provider setup", async () => { + const cfg: OpenClawConfig = { + auth: { + profiles: { + "openai-codex:default": { + provider: "openai-codex", + mode: "oauth", + }, + }, + }, + }; + const { prompter } = createPrompter({ + confirmValues: [true, true, false], + selectValues: ["cached"], + }); + + const result = await setupSearch(cfg, runtime, prompter); + + expect(result.tools?.web?.search?.enabled).toBe(true); + expect(result.tools?.web?.search?.openaiCodex).toEqual({ + enabled: true, + mode: "cached", + }); + expect(result.tools?.web?.search?.provider).toBeUndefined(); + }); + + it("still supports managed provider setup for Codex users", async () => { + const cfg: OpenClawConfig = { + auth: { + profiles: { + "openai-codex:default": { + provider: "openai-codex", + mode: "oauth", + }, + }, + }, + }; + const { prompter } = createPrompter({ + confirmValues: [true, true, true], + selectValues: ["live", "brave"], + textValue: "BSA-test-key", + }); + + const result = await setupSearch(cfg, runtime, prompter); + + expect(result.tools?.web?.search?.provider).toBe("brave"); + expect(result.tools?.web?.search?.apiKey).toBe("BSA-test-key"); + expect(result.tools?.web?.search?.openaiCodex).toEqual({ + enabled: true, + mode: "live", + }); + }); + it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => { expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5); const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value); diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index df2f4643b60..17b6b0e60ac 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -1,3 +1,7 @@ +import { + describeCodexNativeWebSearch, + isCodexNativeWebSearchRelevant, +} from "../agents/codex-native-web-search.js"; import type { OpenClawConfig } from "../config/config.js"; import { DEFAULT_SECRET_PROVIDER_ALIAS, @@ -184,7 +188,33 @@ function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig) }; } +function applyCodexNativeSearchConfig( + config: OpenClawConfig, + params: { enabled: boolean; mode?: "cached" | "live" }, +): OpenClawConfig { + return { + ...config, + tools: { + ...config.tools, + web: { + ...config.tools?.web, + search: { + ...config.tools?.web?.search, + enabled: params.enabled ? true : config.tools?.web?.search?.enabled, + openaiCodex: { + ...config.tools?.web?.search?.openaiCodex, + enabled: params.enabled, + ...(params.mode ? { mode: params.mode } : {}), + }, + }, + }, + }, + }; +} + export type SetupSearchOptions = { + agentId?: string; + agentDir?: string; quickstartDefaults?: boolean; secretInputMode?: SecretInputMode; }; @@ -198,16 +228,102 @@ export async function setupSearch( await prompter.note( [ "Web search lets your agent look things up online.", - "Choose a provider and paste your API key.", + "You can configure a managed provider now, and Codex-capable models can also use native Codex web search.", "Docs: https://docs.openclaw.ai/tools/web", ].join("\n"), "Web search", ); - const existingProvider = config.tools?.web?.search?.provider; + const enableSearch = await prompter.confirm({ + message: "Enable web_search?", + initialValue: config.tools?.web?.search?.enabled !== false, + }); + if (!enableSearch) { + return { + ...config, + tools: { + ...config.tools, + web: { + ...config.tools?.web, + search: { + ...config.tools?.web?.search, + enabled: false, + }, + }, + }, + }; + } + + let nextConfig: OpenClawConfig = { + ...config, + tools: { + ...config.tools, + web: { + ...config.tools?.web, + search: { + ...config.tools?.web?.search, + enabled: true, + }, + }, + }, + }; + const codexRelevant = isCodexNativeWebSearchRelevant({ + config: nextConfig, + agentId: opts?.agentId, + agentDir: opts?.agentDir, + }); + if (codexRelevant) { + const currentNativeSummary = describeCodexNativeWebSearch(nextConfig); + await prompter.note( + [ + "Codex-capable models can optionally use native Codex web search.", + "This does not replace managed web_search for other models.", + "If you skip managed provider setup, non-Codex models still rely on provider auto-detect and may have no search available.", + ...(currentNativeSummary ? [currentNativeSummary] : ["Recommended mode: cached."]), + ].join("\n"), + "Codex native search", + ); + const enableCodexNative = await prompter.confirm({ + message: "Enable native Codex web search for Codex-capable models?", + initialValue: config.tools?.web?.search?.openaiCodex?.enabled === true, + }); + if (enableCodexNative) { + const codexMode = await prompter.select<"cached" | "live">({ + message: "Codex native web search mode", + options: [ + { + value: "cached", + label: "cached (recommended)", + hint: "Uses cached web content", + }, + { + value: "live", + label: "live", + hint: "Allows live external web access", + }, + ], + initialValue: config.tools?.web?.search?.openaiCodex?.mode ?? "cached", + }); + nextConfig = applyCodexNativeSearchConfig(nextConfig, { + enabled: true, + mode: codexMode, + }); + const configureManagedProvider = await prompter.confirm({ + message: "Configure a managed web search provider now?", + initialValue: Boolean(config.tools?.web?.search?.provider), + }); + if (!configureManagedProvider) { + return nextConfig; + } + } else { + nextConfig = applyCodexNativeSearchConfig(nextConfig, { enabled: false }); + } + } + + const existingProvider = nextConfig.tools?.web?.search?.provider; const options = SEARCH_PROVIDER_OPTIONS.map((entry) => { - const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry); + const configured = hasExistingKey(nextConfig, entry.value) || hasKeyInEnv(entry); const hint = configured ? `${entry.hint} · configured` : entry.hint; return { value: entry.value, label: entry.label, hint }; }); @@ -217,7 +333,7 @@ export async function setupSearch( return existingProvider; } const detected = SEARCH_PROVIDER_OPTIONS.find( - (e) => hasExistingKey(config, e.value) || hasKeyInEnv(e), + (e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e), ); if (detected) { return detected.value; @@ -240,25 +356,25 @@ export async function setupSearch( }); if (choice === "__skip__") { - return config; + return nextConfig; } const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!; - const existingKey = resolveExistingKey(config, choice); - const keyConfigured = hasExistingKey(config, choice); + const existingKey = resolveExistingKey(nextConfig, choice); + const keyConfigured = hasExistingKey(nextConfig, choice); const envAvailable = hasKeyInEnv(entry); if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) { const result = existingKey - ? applySearchKey(config, choice, existingKey) - : applyProviderOnly(config, choice); - return preserveDisabledState(config, result); + ? applySearchKey(nextConfig, choice, existingKey) + : applyProviderOnly(nextConfig, choice); + return preserveDisabledState(nextConfig, result); } const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret if (useSecretRefMode) { if (keyConfigured) { - return preserveDisabledState(config, applyProviderOnly(config, choice)); + return preserveDisabledState(nextConfig, applyProviderOnly(nextConfig, choice)); } const ref = buildSearchEnvRef(choice); await prompter.note( @@ -270,7 +386,7 @@ export async function setupSearch( ].join("\n"), "Web search", ); - return applySearchKey(config, choice, ref); + return applySearchKey(nextConfig, choice, ref); } const keyInput = await prompter.text({ @@ -285,15 +401,15 @@ export async function setupSearch( const key = keyInput?.trim() ?? ""; if (key) { const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode); - return applySearchKey(config, choice, secretInput); + return applySearchKey(nextConfig, choice, secretInput); } if (existingKey) { - return preserveDisabledState(config, applySearchKey(config, choice, existingKey)); + return preserveDisabledState(nextConfig, applySearchKey(nextConfig, choice, existingKey)); } if (keyConfigured || envAvailable) { - return preserveDisabledState(config, applyProviderOnly(config, choice)); + return preserveDisabledState(nextConfig, applyProviderOnly(nextConfig, choice)); } await prompter.note( @@ -306,13 +422,13 @@ export async function setupSearch( ); return { - ...config, + ...nextConfig, tools: { - ...config.tools, + ...nextConfig.tools, web: { - ...config.tools?.web, + ...nextConfig.tools?.web, search: { - ...config.tools?.web?.search, + ...nextConfig.tools?.web?.search, provider: choice, }, }, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 555ee02b8eb..6f499b61821 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -659,13 +659,30 @@ export const FIELD_HELP: Record = { "tools.message.crossContext.marker.suffix": 'Text suffix for cross-context markers (supports "{channel}").', "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", - "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", + "tools.web.search.enabled": + "Enable managed web_search and optional Codex-native search for eligible models.", "tools.web.search.provider": 'Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.openaiCodex.enabled": + "Enable native Codex web search for Codex-capable models.", + "tools.web.search.openaiCodex.mode": + 'Native Codex web search mode: "cached" (default) or "live".', + "tools.web.search.openaiCodex.allowedDomains": + "Optional domain allowlist passed to the native Codex web_search tool.", + "tools.web.search.openaiCodex.contextSize": + 'Native Codex search context size hint: "low", "medium", or "high".', + "tools.web.search.openaiCodex.userLocation.country": + "Approximate country sent to native Codex web search.", + "tools.web.search.openaiCodex.userLocation.region": + "Approximate region/state sent to native Codex web search.", + "tools.web.search.openaiCodex.userLocation.city": + "Approximate city sent to native Codex web search.", + "tools.web.search.openaiCodex.userLocation.timezone": + "Approximate timezone sent to native Codex web search.", "tools.web.search.brave.mode": 'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).', "tools.web.search.gemini.apiKey": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 9b1fdb73445..9673add7e82 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -218,6 +218,14 @@ export const FIELD_LABELS: Record = { "tools.web.search.maxResults": "Web Search Max Results", "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", + "tools.web.search.openaiCodex.enabled": "Enable Native Codex Web Search", + "tools.web.search.openaiCodex.mode": "Codex Web Search Mode", + "tools.web.search.openaiCodex.allowedDomains": "Codex Allowed Domains", + "tools.web.search.openaiCodex.contextSize": "Codex Search Context Size", + "tools.web.search.openaiCodex.userLocation.country": "Codex User Country", + "tools.web.search.openaiCodex.userLocation.region": "Codex User Region", + "tools.web.search.openaiCodex.userLocation.city": "Codex User City", + "tools.web.search.openaiCodex.userLocation.timezone": "Codex User Timezone", "tools.web.search.brave.mode": "Brave Search Mode", "tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret "tools.web.search.gemini.model": "Gemini Search Model", diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 43d39285b57..a6aaa703a0d 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -455,7 +455,7 @@ export type ToolsConfig = { byProvider?: Record; web?: { search?: { - /** Enable web search tool (default: true when API key is present). */ + /** Enable managed web_search and optional Codex-native web search. */ enabled?: boolean; /** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */ provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity"; @@ -467,6 +467,24 @@ export type ToolsConfig = { timeoutSeconds?: number; /** Cache TTL in minutes for search results. */ cacheTtlMinutes?: number; + /** Optional native Codex web search for Codex-capable models. */ + openaiCodex?: { + /** Enable native Codex web search for eligible models. */ + enabled?: boolean; + /** Use cached or live external web access. Default: "cached". */ + mode?: "cached" | "live"; + /** Optional allowlist of domains passed to the native Codex tool. */ + allowedDomains?: string[]; + /** Optional Codex native search context size hint. */ + contextSize?: "low" | "medium" | "high"; + /** Optional approximate user location passed to the native Codex tool. */ + userLocation?: { + country?: string; + region?: string; + city?: string; + timezone?: string; + }; + }; /** Brave-specific configuration (used when provider="brave"). */ brave?: { /** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */ diff --git a/src/config/web-search-codex-config.test.ts b/src/config/web-search-codex-config.test.ts new file mode 100644 index 00000000000..6921b1ad06e --- /dev/null +++ b/src/config/web-search-codex-config.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObjectRaw } from "./validation.js"; + +describe("web search Codex native config validation", () => { + it("accepts tools.web.search.openaiCodex", () => { + const result = validateConfigObjectRaw({ + tools: { + web: { + search: { + enabled: true, + openaiCodex: { + enabled: true, + mode: "cached", + allowedDomains: ["example.com"], + contextSize: "medium", + userLocation: { + country: "US", + city: "New York", + timezone: "America/New_York", + }, + }, + }, + }, + }, + }); + + expect(result.ok).toBe(true); + }); + + it("rejects invalid openaiCodex.mode", () => { + const result = validateConfigObjectRaw({ + tools: { + web: { + search: { + openaiCodex: { + enabled: true, + mode: "realtime", + }, + }, + }, + }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + const issue = result.issues.find( + (entry) => entry.path === "tools.web.search.openaiCodex.mode", + ); + expect(issue?.allowedValues).toEqual(["cached", "live"]); + } + }); + + it("rejects invalid openaiCodex.contextSize", () => { + const result = validateConfigObjectRaw({ + tools: { + web: { + search: { + openaiCodex: { + enabled: true, + contextSize: "huge", + }, + }, + }, + }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + const issue = result.issues.find( + (entry) => entry.path === "tools.web.search.openaiCodex.contextSize", + ); + expect(issue?.allowedValues).toEqual(["low", "medium", "high"]); + } + }); +}); diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7a87440a768..d0a2d62a989 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -259,6 +259,37 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => } }).optional(); +const TrimmedOptionalConfigStringSchema = z.preprocess((value) => { + if (typeof value !== "string") { + return value; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}, z.string().optional()); + +const CodexAllowedDomainsSchema = z + .array(z.string()) + .transform((values) => { + const deduped = [ + ...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)), + ]; + return deduped.length > 0 ? deduped : undefined; + }) + .optional(); + +const CodexUserLocationSchema = z + .object({ + country: TrimmedOptionalConfigStringSchema, + region: TrimmedOptionalConfigStringSchema, + city: TrimmedOptionalConfigStringSchema, + timezone: TrimmedOptionalConfigStringSchema, + }) + .strict() + .transform((value) => { + return value.country || value.region || value.city || value.timezone ? value : undefined; + }) + .optional(); + export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), @@ -275,6 +306,16 @@ export const ToolsWebSearchSchema = z maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(), + openaiCodex: z + .object({ + enabled: z.boolean().optional(), + mode: z.union([z.literal("cached"), z.literal("live")]).optional(), + allowedDomains: CodexAllowedDomainsSchema, + contextSize: z.union([z.literal("low"), z.literal("medium"), z.literal("high")]).optional(), + userLocation: CodexUserLocationSchema, + }) + .strict() + .optional(), perplexity: z .object({ apiKey: SecretInputSchema.optional().register(sensitive), diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts index 0fa67d16a8f..c057bb2a73f 100644 --- a/src/wizard/onboarding.finalize.test.ts +++ b/src/wizard/onboarding.finalize.test.ts @@ -307,4 +307,54 @@ describe("finalizeOnboardingWizard", () => { expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…"); expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled."); }); + + it("shows a Codex native search summary when configured", async () => { + const note = vi.fn(async () => {}); + const prompter = buildWizardPrompter({ + note, + select: vi.fn(async () => "later") as never, + confirm: vi.fn(async () => false), + }); + + await finalizeOnboardingWizard({ + flow: "advanced", + opts: { + acceptRisk: true, + authChoice: "skip", + installDaemon: false, + skipHealth: true, + skipUi: true, + }, + baseConfig: {}, + nextConfig: { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { + enabled: true, + mode: "cached", + }, + }, + }, + }, + }, + workspaceDir: "/tmp", + settings: { + port: 18789, + bind: "loopback", + authMode: "token", + gatewayToken: undefined, + tailscaleMode: "off", + tailscaleResetOnExit: false, + }, + prompter, + runtime: createRuntime(), + }); + + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Codex native search: cached for Codex-capable models"), + "Codex native search", + ); + }); }); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index b218e160ed5..5973c1443c3 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -481,6 +481,8 @@ export async function finalizeOnboardingWizard( ); } + const { describeCodexNativeWebSearch } = await import("../agents/codex-native-web-search.js"); + const codexNativeSummary = describeCodexNativeWebSearch(nextConfig); const webSearchProvider = nextConfig.tools?.web?.search?.provider; const webSearchEnabled = nextConfig.tools?.web?.search?.enabled; if (webSearchProvider) { @@ -549,6 +551,15 @@ export async function finalizeOnboardingWizard( ].join("\n"), "Web search", ); + } else if (codexNativeSummary) { + await prompter.note( + [ + "Managed web search provider was skipped.", + codexNativeSummary, + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Web search", + ); } else { await prompter.note( [ @@ -562,6 +573,17 @@ export async function finalizeOnboardingWizard( } } + if (codexNativeSummary) { + await prompter.note( + [ + codexNativeSummary, + "Used only for Codex-capable models.", + "Docs: https://docs.openclaw.ai/tools/web", + ].join("\n"), + "Codex native search", + ); + } + await prompter.note( 'What now: https://openclaw.ai/showcase ("What People Are Building").', "What now",