mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 10:20:21 +00:00
refactor(xai): move x_search into plugin
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -18599,6 +18599,7 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [
|
||||
},
|
||||
contracts: {
|
||||
webSearchProviders: ["grok"],
|
||||
tools: ["x_search"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user