refactor(xai): move x_search into plugin

This commit is contained in:
Peter Steinberger
2026-03-28 19:13:09 +00:00
parent 396bf20cc6
commit 2a950157b1
13 changed files with 550 additions and 430 deletions

View File

@@ -1,7 +1,10 @@
import type { OpenClawConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
import {
getActiveSecretsRuntimeSnapshot,
getActiveRuntimeWebToolsMetadata,
} from "../secrets/runtime.js";
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
@@ -31,7 +34,6 @@ import {
createCodeExecutionTool,
createWebFetchTool,
createWebSearchTool,
createXSearchTool,
} from "./tools/web-tools.js";
import { resolveWorkspaceRoot } from "./workspace-dir.js";
@@ -125,6 +127,7 @@ export function createOpenClawTools(
threadId: options?.agentThreadId,
});
const runtimeWebTools = getActiveRuntimeWebToolsMetadata();
const runtimeSnapshot = getActiveSecretsRuntimeSnapshot();
const sandbox =
options?.sandboxRoot && options?.sandboxFsBridge
? { root: options.sandboxRoot, bridge: options.sandboxFsBridge }
@@ -160,10 +163,6 @@ export function createOpenClawTools(
sandboxed: options?.sandboxed,
runtimeWebSearch: runtimeWebTools?.search,
});
const xSearchTool = createXSearchTool({
config: options?.config,
runtimeXSearch: runtimeWebTools?.xSearch,
});
const codeExecutionTool = createCodeExecutionTool({
config: options?.config,
});
@@ -263,7 +262,6 @@ export function createOpenClawTools(
sandboxed: options?.sandboxed,
}),
...(webSearchTool ? [webSearchTool] : []),
...(xSearchTool ? [xSearchTool] : []),
...(codeExecutionTool ? [codeExecutionTool] : []),
...(webFetchTool ? [webFetchTool] : []),
...(imageTool ? [imageTool] : []),
@@ -273,6 +271,7 @@ export function createOpenClawTools(
const pluginTools = resolvePluginTools({
context: {
config: options?.config,
runtimeConfig: runtimeSnapshot?.config,
workspaceDir,
agentDir: options?.agentDir,
agentId: sessionAgentId,

View File

@@ -1,5 +1,7 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeWebFirecrawlMetadata } from "../secrets/runtime-web-tools.types.js";
import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
const mockedModuleIds = [
@@ -24,11 +26,13 @@ const mockedModuleIds = [
"./tools/tts-tool.js",
] as const;
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
copyPluginToolMeta: () => undefined,
getPluginToolMeta: () => undefined,
}));
vi.mock("../plugins/tools.js", async () => {
const actual = await vi.importActual<typeof import("../plugins/tools.js")>("../plugins/tools.js");
return {
...actual,
copyPluginToolMeta: () => undefined,
};
});
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(),
@@ -104,14 +108,34 @@ function asConfig(value: unknown): OpenClawConfig {
}
let secretsRuntime: typeof import("../secrets/runtime.js");
let createOpenClawTools: typeof import("./openclaw-tools.js").createOpenClawTools;
let createWebSearchTool: typeof import("./tools/web-tools.js").createWebSearchTool;
let createWebFetchTool: typeof import("./tools/web-tools.js").createWebFetchTool;
function findTool(name: string, config: OpenClawConfig) {
const allTools = createOpenClawTools({ config, sandboxed: true });
const tool = allTools.find((candidate) => candidate.name === name);
function requireWebSearchTool(config: OpenClawConfig, runtimeWebSearch?: RuntimeWebSearchMetadata) {
const tool = createWebSearchTool({
config,
sandboxed: true,
runtimeWebSearch,
});
expect(tool).toBeDefined();
if (!tool) {
throw new Error(`missing ${name} tool`);
throw new Error("missing web_search tool");
}
return tool;
}
function requireWebFetchTool(
config: OpenClawConfig,
runtimeFirecrawl?: RuntimeWebFirecrawlMetadata,
) {
const tool = createWebFetchTool({
config,
sandboxed: true,
runtimeFirecrawl,
});
expect(tool).toBeDefined();
if (!tool) {
throw new Error("missing web_fetch tool");
}
return tool;
}
@@ -139,7 +163,7 @@ describe("openclaw tools runtime web metadata wiring", () => {
beforeEach(async () => {
vi.resetModules();
secretsRuntime = await import("../secrets/runtime.js");
({ createOpenClawTools } = await import("./openclaw-tools.js"));
({ createWebFetchTool, createWebSearchTool } = await import("./tools/web-tools.js"));
});
afterEach(() => {
@@ -190,7 +214,7 @@ describe("openclaw tools runtime web metadata wiring", () => {
);
global.fetch = withFetchPreconnect(mockFetch);
const webSearch = findTool("web_search", snapshot.config);
const webSearch = requireWebSearchTool(snapshot.config, snapshot.webTools.search);
const result = await webSearch.execute("call-runtime-search", { query: "runtime search" });
expect(mockFetch).toHaveBeenCalled();
@@ -227,105 +251,11 @@ describe("openclaw tools runtime web metadata wiring", () => {
);
global.fetch = withFetchPreconnect(mockFetch);
const webFetch = findTool("web_fetch", snapshot.config);
const webFetch = requireWebFetchTool(snapshot.config, snapshot.webTools.fetch.firecrawl);
await webFetch.execute("call-runtime-fetch", { url: "https://example.com/runtime-off" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/runtime-off");
expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("api.firecrawl.dev");
});
it("resolves x_search SecretRef from the active runtime snapshot", async () => {
const snapshot = await prepareAndActivate({
config: asConfig({
tools: {
web: {
x_search: {
apiKey: { source: "env", provider: "default", id: "X_SEARCH_RUNTIME_REF" },
},
},
},
}),
env: {
X_SEARCH_RUNTIME_REF: "x-search-runtime-key",
},
});
expect(snapshot.webTools.xSearch.active).toBe(true);
expect(snapshot.webTools.xSearch.apiKeySource).toBe("secretRef");
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
output_text: "runtime x search ok",
citations: ["https://x.com/openclaw/status/1"],
}),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const xSearch = findTool("x_search", snapshot.config);
const result = await xSearch.execute("call-runtime-x-search", {
query: "runtime search",
});
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("api.x.ai/v1/responses");
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
const body = JSON.parse(typeof request?.body === "string" ? request.body : "{}") as {
tools?: Array<Record<string, unknown>>;
};
expect(body.tools).toEqual([{ type: "x_search" }]);
expect((result.details as { citations?: string[] }).citations).toEqual([
"https://x.com/openclaw/status/1",
]);
});
it("resolves code_execution SecretRef from the active runtime snapshot", async () => {
const snapshot = await prepareAndActivate({
config: asConfig({
tools: {
code_execution: {
apiKey: { source: "env", provider: "default", id: "CODE_EXECUTION_RUNTIME_REF" },
},
},
}),
env: {
CODE_EXECUTION_RUNTIME_REF: "code-execution-runtime-key",
},
});
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
output: [
{ type: "code_interpreter_call" },
{
type: "message",
content: [{ type: "output_text", text: "runtime code execution ok" }],
},
],
}),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const codeExecution = findTool("code_execution", snapshot.config);
const result = await codeExecution.execute("call-runtime-code-execution", {
task: "Add 20 + 22",
});
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("api.x.ai/v1/responses");
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
const body = JSON.parse(typeof request?.body === "string" ? request.body : "{}") as {
tools?: Array<Record<string, unknown>>;
};
expect(body.tools).toEqual([{ type: "code_interpreter" }]);
expect((result.details as { usedCodeExecution?: boolean }).usedCodeExecution).toBe(true);
});
});

View File

@@ -1,4 +1,3 @@
export { createCodeExecutionTool } from "./code-execution.js";
export { createWebFetchTool, extractReadableContent, fetchFirecrawlContent } from "./web-fetch.js";
export { createWebSearchTool } from "./web-search.js";
export { createXSearchTool } from "./x-search.js";
export { createCodeExecutionTool } from "./code-execution.js";

View File

@@ -1,202 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { createXSearchTool } from "./x-search.js";
function installXSearchFetch(payload?: Record<string, unknown>) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve(
payload ?? {
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "Found X posts",
annotations: [{ type: "url_citation", url: "https://x.com/openclaw/status/1" }],
},
],
},
],
citations: ["https://x.com/openclaw/status/1"],
},
),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
return mockFetch;
}
function parseFirstRequestBody(mockFetch: ReturnType<typeof installXSearchFetch>) {
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
const requestBody = request?.body;
return JSON.parse(typeof requestBody === "string" ? requestBody : "{}") as Record<
string,
unknown
>;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("x_search tool", () => {
it("enables x_search when runtime metadata marks an xAI key active", () => {
const tool = createXSearchTool({
config: {},
runtimeXSearch: {
active: true,
apiKeySource: "env",
diagnostics: [],
},
});
expect(tool?.name).toBe("x_search");
});
it("enables x_search when the xAI plugin web search key is configured", () => {
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
expect(tool?.name).toBe("x_search");
});
it("uses the xAI Responses x_search tool with structured filters", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
apiKey: "xai-config-test", // pragma: allowlist secret
model: "grok-4-1-fast-non-reasoning",
maxTurns: 2,
},
},
},
},
});
const result = await tool?.execute?.("x-search:1", {
query: "dinner recipes",
allowed_x_handles: ["openclaw"],
excluded_x_handles: ["spam"],
from_date: "2026-03-01",
to_date: "2026-03-20",
enable_image_understanding: true,
});
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("api.x.ai/v1/responses");
const body = parseFirstRequestBody(mockFetch);
expect(body.model).toBe("grok-4-1-fast-non-reasoning");
expect(body.max_turns).toBe(2);
expect(body.tools).toEqual([
{
type: "x_search",
allowed_x_handles: ["openclaw"],
excluded_x_handles: ["spam"],
from_date: "2026-03-01",
to_date: "2026-03-20",
enable_image_understanding: true,
},
]);
expect((result?.details as { citations?: string[] } | undefined)?.citations).toEqual([
"https://x.com/openclaw/status/1",
]);
});
it("reuses the xAI plugin web search key for x_search requests", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
await tool?.execute?.("x-search:plugin-key", {
query: "latest post from huntharo",
});
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((request?.headers as Record<string, string> | undefined)?.Authorization).toBe(
"Bearer xai-plugin-key",
);
});
it("reuses the legacy grok web search key for x_search requests", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
search: {
grok: {
apiKey: "xai-legacy-key", // pragma: allowlist secret
},
},
},
},
},
});
await tool?.execute?.("x-search:legacy-key", {
query: "latest legacy-key post from huntharo",
});
const request = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined;
expect((request?.headers as Record<string, string> | undefined)?.Authorization).toBe(
"Bearer xai-legacy-key",
);
});
it("rejects invalid date ordering before calling xAI", async () => {
const mockFetch = installXSearchFetch();
const tool = createXSearchTool({
config: {
tools: {
web: {
x_search: {
apiKey: "xai-config-test", // pragma: allowlist secret
},
},
},
},
});
await expect(
tool?.execute?.("x-search:bad-dates", {
query: "dinner recipes",
from_date: "2026-03-20",
to_date: "2026-03-01",
}),
).rejects.toThrow(/from_date must be on or before to_date/i);
expect(mockFetch).not.toHaveBeenCalled();
});
});

View File

@@ -1,270 +0,0 @@
import { Type } from "@sinclair/typebox";
import {
buildXaiXSearchPayload,
requestXaiXSearch,
resolveXaiXSearchInlineCitations,
resolveXaiXSearchMaxTurns,
resolveXaiXSearchModel,
type XaiXSearchOptions,
} from "../../../extensions/xai/x-search.js";
import type { OpenClawConfig } from "../../config/config.js";
import { resolveProviderWebSearchPluginConfig } from "../../plugin-sdk/provider-web-search.js";
import type { RuntimeWebXSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { jsonResult, readStringArrayParam, readStringParam, ToolInputError } from "./common.js";
import {
readConfiguredSecretString,
readProviderEnvValue,
SEARCH_CACHE,
} from "./web-search-provider-common.js";
import { readCache, resolveCacheTtlMs, resolveTimeoutSeconds, writeCache } from "./web-shared.js";
type XSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { x_search?: infer XSearch }
? XSearch
: undefined
: undefined;
function readLegacyGrokApiKey(cfg?: OpenClawConfig): string | undefined {
const search = cfg?.tools?.web?.search;
if (!search || typeof search !== "object") {
return undefined;
}
const grok = (search as Record<string, unknown>).grok;
return readConfiguredSecretString(
grok && typeof grok === "object" ? (grok as Record<string, unknown>).apiKey : undefined,
"tools.web.search.grok.apiKey",
);
}
function readPluginXaiWebSearchApiKey(cfg?: OpenClawConfig): string | undefined {
return readConfiguredSecretString(
resolveProviderWebSearchPluginConfig(cfg as Record<string, unknown> | undefined, "xai")?.apiKey,
"plugins.entries.xai.config.webSearch.apiKey",
);
}
function resolveFallbackXaiApiKey(cfg?: OpenClawConfig): string | undefined {
return readPluginXaiWebSearchApiKey(cfg) ?? readLegacyGrokApiKey(cfg);
}
function resolveXSearchConfig(cfg?: OpenClawConfig): XSearchConfig {
const xSearch = cfg?.tools?.web?.x_search;
if (!xSearch || typeof xSearch !== "object") {
return undefined;
}
return xSearch as XSearchConfig;
}
function resolveXSearchEnabled(params: {
cfg?: OpenClawConfig;
config?: XSearchConfig;
runtimeXSearch?: RuntimeWebXSearchMetadata;
}): boolean {
if (params.config?.enabled === false) {
return false;
}
if (params.runtimeXSearch?.active) {
return true;
}
const configuredApiKey = readConfiguredSecretString(
params.config?.apiKey,
"tools.web.x_search.apiKey",
);
return Boolean(
configuredApiKey ||
resolveFallbackXaiApiKey(params.cfg) ||
readProviderEnvValue(["XAI_API_KEY"]),
);
}
function resolveXSearchApiKey(config?: XSearchConfig, cfg?: OpenClawConfig): string | undefined {
return (
readConfiguredSecretString(config?.apiKey, "tools.web.x_search.apiKey") ??
resolveFallbackXaiApiKey(cfg) ??
readProviderEnvValue(["XAI_API_KEY"])
);
}
function normalizeOptionalIsoDate(value: string | undefined, label: string): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
throw new ToolInputError(`${label} must use YYYY-MM-DD`);
}
const [year, month, day] = trimmed.split("-").map((entry) => Number.parseInt(entry, 10));
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
throw new ToolInputError(`${label} must be a valid calendar date`);
}
return trimmed;
}
function buildXSearchCacheKey(params: {
query: string;
model: string;
inlineCitations: boolean;
maxTurns?: number;
options: Omit<XaiXSearchOptions, "query">;
}) {
return JSON.stringify([
"x_search",
params.model,
params.query,
params.inlineCitations,
params.maxTurns ?? null,
params.options.allowedXHandles ?? null,
params.options.excludedXHandles ?? null,
params.options.fromDate ?? null,
params.options.toDate ?? null,
params.options.enableImageUnderstanding ?? false,
params.options.enableVideoUnderstanding ?? false,
]);
}
export function createXSearchTool(options?: {
config?: OpenClawConfig;
runtimeXSearch?: RuntimeWebXSearchMetadata;
}) {
const xSearchConfig = resolveXSearchConfig(options?.config);
if (
!resolveXSearchEnabled({
cfg: options?.config,
config: xSearchConfig,
runtimeXSearch: options?.runtimeXSearch,
})
) {
return null;
}
return {
label: "X Search",
name: "x_search",
description:
"Search X (formerly Twitter) using xAI, including targeted post or thread lookups. For per-post stats like reposts, replies, bookmarks, or views, prefer the exact post URL or status ID.",
parameters: Type.Object({
query: Type.String({ description: "X search query string." }),
allowed_x_handles: Type.Optional(
Type.Array(Type.String({ minLength: 1 }), {
description: "Only include posts from these X handles.",
}),
),
excluded_x_handles: Type.Optional(
Type.Array(Type.String({ minLength: 1 }), {
description: "Exclude posts from these X handles.",
}),
),
from_date: Type.Optional(
Type.String({ description: "Only include posts on or after this date (YYYY-MM-DD)." }),
),
to_date: Type.Optional(
Type.String({ description: "Only include posts on or before this date (YYYY-MM-DD)." }),
),
enable_image_understanding: Type.Optional(
Type.Boolean({ description: "Allow xAI to inspect images attached to matching posts." }),
),
enable_video_understanding: Type.Optional(
Type.Boolean({ description: "Allow xAI to inspect videos attached to matching posts." }),
),
}),
execute: async (_toolCallId: string, args: Record<string, unknown>) => {
const apiKey = resolveXSearchApiKey(xSearchConfig, options?.config);
if (!apiKey) {
return jsonResult({
error: "missing_xai_api_key",
message:
"x_search needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.x_search.apiKey or plugins.entries.xai.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const query = readStringParam(args, "query", { required: true });
const allowedXHandles = readStringArrayParam(args, "allowed_x_handles");
const excludedXHandles = readStringArrayParam(args, "excluded_x_handles");
const fromDate = normalizeOptionalIsoDate(readStringParam(args, "from_date"), "from_date");
const toDate = normalizeOptionalIsoDate(readStringParam(args, "to_date"), "to_date");
if (fromDate && toDate && fromDate > toDate) {
throw new ToolInputError("from_date must be on or before to_date");
}
const xSearchOptions: XaiXSearchOptions = {
query,
allowedXHandles,
excludedXHandles,
fromDate,
toDate,
enableImageUnderstanding: args.enable_image_understanding === true,
enableVideoUnderstanding: args.enable_video_understanding === true,
};
const xSearchConfigRecord = xSearchConfig as Record<string, unknown> | undefined;
const model = resolveXaiXSearchModel(xSearchConfigRecord);
const inlineCitations = resolveXaiXSearchInlineCitations(xSearchConfigRecord);
const maxTurns = resolveXaiXSearchMaxTurns(xSearchConfigRecord);
const cacheKey = buildXSearchCacheKey({
query,
model,
inlineCitations,
maxTurns,
options: {
allowedXHandles,
excludedXHandles,
fromDate,
toDate,
enableImageUnderstanding: xSearchOptions.enableImageUnderstanding,
enableVideoUnderstanding: xSearchOptions.enableVideoUnderstanding,
},
});
const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) {
return jsonResult({ ...cached.value, cached: true });
}
const startedAt = Date.now();
const result = await requestXaiXSearch({
apiKey,
model,
timeoutSeconds: resolveTimeoutSeconds(xSearchConfig?.timeoutSeconds, 30),
inlineCitations,
maxTurns,
options: xSearchOptions,
});
const payload = buildXaiXSearchPayload({
query,
model,
tookMs: Date.now() - startedAt,
content: result.content,
citations: result.citations,
inlineCitations: result.inlineCitations,
options: xSearchOptions,
});
writeCache(
SEARCH_CACHE,
cacheKey,
payload,
resolveCacheTtlMs(xSearchConfig?.cacheTtlMinutes, 15),
);
return jsonResult(payload);
},
};
}
export const __testing = {
buildXSearchCacheKey,
buildXaiXSearchPayload,
normalizeOptionalIsoDate,
requestXaiXSearch,
resolveXaiXSearchInlineCitations,
resolveXaiXSearchMaxTurns,
resolveXaiXSearchModel,
resolveXSearchApiKey,
resolveXSearchConfig,
resolveXSearchEnabled,
} as const;

View File

@@ -359,6 +359,46 @@ describe("applyPluginAutoEnable", () => {
expect(result.config.plugins?.entries?.google?.enabled).toBe(true);
});
it("auto-enables bundled provider plugins when plugin-owned web search config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-config-key",
},
},
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
expect(result.changes).toContain("xai web search configured, enabled automatically.");
});
it("auto-enables xai when the plugin-owned x_search tool is configured", () => {
const result = applyPluginAutoEnable({
config: {
tools: {
web: {
x_search: {
apiKey: "x-search-runtime-key",
},
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.xai?.enabled).toBe(true);
expect(result.changes).toContain("xai tool configured, enabled automatically.");
});
it("auto-enables minimax when minimax-portal profiles exist", () => {
const result = applyPluginAutoEnable({
config: {
@@ -422,6 +462,48 @@ describe("applyPluginAutoEnable", () => {
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
});
it("auto-enables third-party provider plugins when manifest-owned web search config exists", () => {
const result = applyPluginAutoEnable({
config: {
plugins: {
entries: {
acme: {
config: {
webSearch: {
apiKey: "acme-search-key",
},
},
},
},
},
},
env: {},
manifestRegistry: {
plugins: [
{
id: "acme",
channels: [],
providers: ["acme-ai"],
cliBackends: [],
skills: [],
hooks: [],
origin: "config" as const,
rootDir: "/fake/acme",
source: "/fake/acme/index.js",
manifestPath: "/fake/acme/openclaw.plugin.json",
contracts: {
webSearchProviders: ["acme-search"],
},
},
],
diagnostics: [],
},
});
expect(result.config.plugins?.entries?.acme?.enabled).toBe(true);
expect(result.changes).toContain("acme web search configured, enabled automatically.");
});
it("auto-enables acpx plugin when ACP is configured", () => {
const result = applyPluginAutoEnable({
config: {

View File

@@ -6,7 +6,10 @@ import {
listChatChannels,
normalizeChatChannelId,
} from "../channels/registry.js";
import { BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS } from "../plugins/bundled-capability-metadata.js";
import {
BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS,
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS,
} from "../plugins/bundled-capability-metadata.js";
import {
loadPluginManifestRegistry,
type PluginManifestRegistry,
@@ -137,6 +140,40 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean
return false;
}
function hasPluginOwnedWebSearchConfig(cfg: OpenClawConfig, pluginId: string): boolean {
const pluginConfig = cfg.plugins?.entries?.[pluginId]?.config;
if (!isRecord(pluginConfig)) {
return false;
}
return isRecord(pluginConfig.webSearch);
}
function hasPluginOwnedToolConfig(cfg: OpenClawConfig, pluginId: string): boolean {
// x_search is now plugin-owned by xAI, but the persisted config shape still
// lives under tools.web.x_search for backward compatibility. Treat that as
// plugin configuration so tool/runtime loading can activate xAI generically.
if (pluginId === "xai") {
return isRecord(cfg.tools?.web?.x_search as Record<string, unknown> | undefined);
}
return false;
}
function resolveProviderPluginsWithOwnedWebSearch(
registry: PluginManifestRegistry,
): ReadonlySet<string> {
const pluginIds = new Set(
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS.filter(
(entry) => entry.providerIds.length > 0 && entry.webSearchProviderIds.length > 0,
).map((entry) => entry.pluginId),
);
for (const plugin of registry.plugins) {
if (plugin.providers.length > 0 && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0) {
pluginIds.add(plugin.id);
}
}
return pluginIds;
}
function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map<string, string> {
const map = new Map<string, string>();
for (const record of registry.plugins) {
@@ -306,6 +343,22 @@ function resolveConfiguredPlugins(
});
}
}
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(registry)) {
if (hasPluginOwnedWebSearchConfig(cfg, pluginId)) {
changes.push({
pluginId,
reason: `${pluginId} web search configured`,
});
}
}
for (const pluginId of resolveProviderPluginsWithOwnedWebSearch(registry)) {
if (hasPluginOwnedToolConfig(cfg, pluginId)) {
changes.push({
pluginId,
reason: `${pluginId} tool configured`,
});
}
}
const backendRaw =
typeof cfg.acp?.backend === "string" ? cfg.acp.backend.trim().toLowerCase() : "";
const acpConfigured =

View File

@@ -18599,6 +18599,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
},
contracts: {
webSearchProviders: ["grok"],
tools: ["x_search"],
},
},
},

View File

@@ -114,6 +114,8 @@ export type OpenClawPluginConfigSchema = {
/** Trusted execution context passed to plugin-owned agent tool factories. */
export type OpenClawPluginToolContext = {
config?: OpenClawConfig;
/** Active runtime-resolved config snapshot when one is available. */
runtimeConfig?: OpenClawConfig;
workspaceDir?: string;
agentDir?: string;
agentId?: string;