Tools: add x_search via xAI Responses

This commit is contained in:
huntharo
2026-03-27 12:41:02 -04:00
committed by Peter Steinberger
parent 5ed8ee6832
commit 38e4b77e60
34 changed files with 1111 additions and 40 deletions

View File

@@ -27,6 +27,9 @@ openclaw onboard --auth-choice xai-api-key
}
```
OpenClaw now uses the xAI Responses API as the bundled xAI transport. The same
`XAI_API_KEY` can also power Grok-backed `web_search` and first-class `x_search`.
## Current bundled model catalog
OpenClaw now includes these xAI model families out of the box:
@@ -52,9 +55,9 @@ openclaw config set tools.web.search.provider grok
- Auth is API-key only today. There is no xAI OAuth/device-code flow in OpenClaw yet.
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the normal xAI provider path because it requires a different upstream API surface than the standard OpenClaw xAI transport.
- Native xAI server-side tools such as `x_search` and `code_execution` are not yet first-class model-provider features in the bundled plugin.
## Notes
- OpenClaw applies xAI-specific tool-schema and tool-call compatibility fixes automatically on the shared runner path.
- `web_search` and `x_search` are exposed as OpenClaw tools. OpenClaw enables the specific xAI built-in it needs inside each tool request instead of attaching both search tools to every chat turn.
- For the broader provider overview, see [Model providers](/providers/index).

View File

@@ -12,6 +12,9 @@ OpenClaw supports Grok as a `web_search` provider, using xAI web-grounded
responses to produce AI-synthesized answers backed by live search results
with citations.
The same `XAI_API_KEY` can also power the built-in `x_search` tool for X
(formerly Twitter) post search.
## Get an API key
<Steps>
@@ -69,4 +72,5 @@ Provider-specific filters are not currently supported.
## Related
- [Web Search overview](/tools/web) -- all providers and auto-detection
- [x_search in Web Search](/tools/web#x_search) -- first-class X search via xAI
- [Gemini Search](/tools/gemini-search) -- AI-synthesized answers via Google grounding

View File

@@ -52,19 +52,19 @@ OpenClaw has three layers that work together:
These tools ship with OpenClaw and are available without installing any plugins:
| Tool | What it does | Page |
| ---------------------------- | -------------------------------------------------------- | --------------------------------- |
| `exec` / `process` | Run shell commands, manage background processes | [Exec](/tools/exec) |
| `browser` | Control a Chromium browser (navigate, click, screenshot) | [Browser](/tools/browser) |
| `web_search` / `web_fetch` | Search the web, fetch page content | [Web](/tools/web) |
| `read` / `write` / `edit` | File I/O in the workspace | |
| `apply_patch` | Multi-hunk file patches | [Apply Patch](/tools/apply-patch) |
| `message` | Send messages across all channels | [Agent Send](/tools/agent-send) |
| `canvas` | Drive node Canvas (present, eval, snapshot) | |
| `nodes` | Discover and target paired devices | |
| `cron` / `gateway` | Manage scheduled jobs, restart gateway | |
| `image` / `image_generate` | Analyze or generate images | |
| `sessions_*` / `agents_list` | Session management, sub-agents | [Sub-agents](/tools/subagents) |
| Tool | What it does | Page |
| --------------------------------------- | -------------------------------------------------------- | --------------------------------- |
| `exec` / `process` | Run shell commands, manage background processes | [Exec](/tools/exec) |
| `browser` | Control a Chromium browser (navigate, click, screenshot) | [Browser](/tools/browser) |
| `web_search` / `x_search` / `web_fetch` | Search the web, search X posts, fetch page content | [Web](/tools/web) |
| `read` / `write` / `edit` | File I/O in the workspace | |
| `apply_patch` | Multi-hunk file patches | [Apply Patch](/tools/apply-patch) |
| `message` | Send messages across all channels | [Agent Send](/tools/agent-send) |
| `canvas` | Drive node Canvas (present, eval, snapshot) | |
| `nodes` | Discover and target paired devices | |
| `cron` / `gateway` | Manage scheduled jobs, restart gateway | |
| `image` / `image_generate` | Analyze or generate images | |
| `sessions_*` / `agents_list` | Session management, sub-agents | [Sub-agents](/tools/subagents) |
For image work, use `image` for analysis and `image_generate` for generation or editing. If you target `openai/*`, `google/*`, `fal/*`, or another non-default image provider, configure that provider's auth/API key first.
@@ -115,7 +115,7 @@ Use `group:*` shorthands in allow/deny lists:
| `group:fs` | read, write, edit, apply_patch |
| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, sessions_yield, subagents, session_status |
| `group:memory` | memory_search, memory_get |
| `group:web` | web_search, web_fetch |
| `group:web` | web_search, x_search, web_fetch |
| `group:ui` | browser, canvas |
| `group:automation` | cron, gateway |
| `group:messaging` | message |

View File

@@ -1,11 +1,12 @@
---
summary: "web_search tool -- search the web with Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, or Tavily"
read_when:
- You want to enable or configure web_search
- You need to choose a search provider
- You want to understand auto-detection and provider fallback
title: "Web Search"
sidebarTitle: "Web Search"
summary: "web_search, x_search, and web_fetch -- search the web, search X posts, or fetch page content"
read_when:
- You want to enable or configure web_search
- You want to enable or configure x_search
- You need to choose a search provider
- You want to understand auto-detection and provider fallback
---
# Web Search
@@ -13,6 +14,10 @@ sidebarTitle: "Web Search"
The `web_search` tool searches the web using your configured provider and
returns results. Results are cached by query for 15 minutes (configurable).
OpenClaw also includes `x_search` for X (formerly Twitter) posts and
`web_fetch` for lightweight URL fetching. In this phase, `web_fetch` stays
local while `web_search` and `x_search` can use xAI Responses under the hood.
<Info>
`web_search` is a lightweight HTTP tool, not browser automation. For
JS-heavy sites or logins, use the [Web Browser](/tools/browser). For
@@ -40,6 +45,12 @@ returns results. Results are cached by query for 15 minutes (configurable).
await web_search({ query: "OpenClaw plugin SDK" });
```
For X posts, use:
```javascript
await x_search({ query: "dinner recipes" });
```
</Step>
</Steps>
@@ -136,6 +147,9 @@ Provider-specific config (API keys, base URLs, modes) lives under
`plugins.entries.<plugin>.config.webSearch.*`. See the provider pages for
examples.
For `x_search`, configure `tools.web.x_search.*` directly. It uses the same
`XAI_API_KEY` fallback as Grok web search.
### Storing API keys
<Tabs>
@@ -195,6 +209,55 @@ examples.
-- use their dedicated tools for advanced options.
</Warning>
## x_search
`x_search` queries X (formerly Twitter) posts using xAI and returns
AI-synthesized answers with citations. It accepts natural-language queries and
optional structured filters. OpenClaw only enables the built-in xAI `x_search`
tool on the request that serves this tool call.
### x_search config
```json5
{
tools: {
web: {
x_search: {
enabled: true,
apiKey: "xai-...", // optional if XAI_API_KEY is set
model: "grok-4-1-fast-non-reasoning",
inlineCitations: false,
maxTurns: 2,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
},
},
},
}
```
### x_search parameters
| Parameter | Description |
| ---------------------------- | ------------------------------------------------------ |
| `query` | Search query (required) |
| `allowed_x_handles` | Restrict results to specific X handles |
| `excluded_x_handles` | Exclude specific X handles |
| `from_date` | Only include posts on or after this date (YYYY-MM-DD) |
| `to_date` | Only include posts on or before this date (YYYY-MM-DD) |
| `enable_image_understanding` | Let xAI inspect images attached to matching posts |
| `enable_video_understanding` | Let xAI inspect videos attached to matching posts |
### x_search example
```javascript
await x_search({
query: "dinner recipes",
allowed_x_handles: ["nytfood"],
from_date: "2026-03-01",
});
```
## Examples
```javascript
@@ -223,13 +286,13 @@ await web_search({
## Tool profiles
If you use tool profiles or allowlists, add `web_search` or `group:web`:
If you use tool profiles or allowlists, add `web_search`, `x_search`, or `group:web`:
```json5
{
tools: {
allow: ["web_search"],
// or: allow: ["group:web"] (includes both web_search and web_fetch)
allow: ["web_search", "x_search"],
// or: allow: ["group:web"] (includes web_search, x_search, and web_fetch)
},
}
```
@@ -238,3 +301,4 @@ If you use tool profiles or allowlists, add `web_search` or `group:web`:
- [Web Fetch](/tools/web-fetch) -- fetch a URL and extract readable content
- [Web Browser](/tools/browser) -- full browser automation for JS-heavy sites
- [Grok Search](/tools/grok-search) -- Grok as the `web_search` provider

View File

@@ -0,0 +1,156 @@
import { normalizeXaiModelId } from "openclaw/plugin-sdk/provider-models";
import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search";
import { extractXaiWebSearchContent, type XaiWebSearchResponse } from "./web-search-shared.js";
export const XAI_X_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses";
export const XAI_DEFAULT_X_SEARCH_MODEL = "grok-4-1-fast-non-reasoning";
export type XaiXSearchConfig = {
apiKey?: unknown;
model?: unknown;
inlineCitations?: unknown;
maxTurns?: unknown;
};
export type XaiXSearchOptions = {
query: string;
allowedXHandles?: string[];
excludedXHandles?: string[];
fromDate?: string;
toDate?: string;
enableImageUnderstanding?: boolean;
enableVideoUnderstanding?: boolean;
};
export type XaiXSearchResult = {
content: string;
citations: string[];
inlineCitations?: XaiWebSearchResponse["inline_citations"];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function resolveXaiXSearchConfig(config?: Record<string, unknown>): XaiXSearchConfig {
return isRecord(config) ? (config as XaiXSearchConfig) : {};
}
export function resolveXaiXSearchModel(config?: Record<string, unknown>): string {
const resolved = resolveXaiXSearchConfig(config);
return typeof resolved.model === "string" && resolved.model.trim()
? normalizeXaiModelId(resolved.model.trim())
: XAI_DEFAULT_X_SEARCH_MODEL;
}
export function resolveXaiXSearchInlineCitations(config?: Record<string, unknown>): boolean {
return resolveXaiXSearchConfig(config).inlineCitations === true;
}
export function resolveXaiXSearchMaxTurns(config?: Record<string, unknown>): number | undefined {
const raw = resolveXaiXSearchConfig(config).maxTurns;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
const normalized = Math.trunc(raw);
return normalized > 0 ? normalized : undefined;
}
function buildXSearchTool(options: XaiXSearchOptions): Record<string, unknown> {
return {
type: "x_search",
...(options.allowedXHandles?.length ? { allowed_x_handles: options.allowedXHandles } : {}),
...(options.excludedXHandles?.length ? { excluded_x_handles: options.excludedXHandles } : {}),
...(options.fromDate ? { from_date: options.fromDate } : {}),
...(options.toDate ? { to_date: options.toDate } : {}),
...(options.enableImageUnderstanding ? { enable_image_understanding: true } : {}),
...(options.enableVideoUnderstanding ? { enable_video_understanding: true } : {}),
};
}
export function buildXaiXSearchPayload(params: {
query: string;
model: string;
tookMs: number;
content: string;
citations: string[];
inlineCitations?: XaiWebSearchResponse["inline_citations"];
options?: XaiXSearchOptions;
}): Record<string, unknown> {
return {
query: params.query,
provider: "xai",
model: params.model,
tookMs: params.tookMs,
externalContent: {
untrusted: true,
source: "x_search",
provider: "xai",
wrapped: true,
},
content: wrapWebContent(params.content, "web_search"),
citations: params.citations,
...(params.inlineCitations ? { inlineCitations: params.inlineCitations } : {}),
...(params.options?.allowedXHandles?.length
? { allowedXHandles: params.options.allowedXHandles }
: {}),
...(params.options?.excludedXHandles?.length
? { excludedXHandles: params.options.excludedXHandles }
: {}),
...(params.options?.fromDate ? { fromDate: params.options.fromDate } : {}),
...(params.options?.toDate ? { toDate: params.options.toDate } : {}),
...(params.options?.enableImageUnderstanding ? { enableImageUnderstanding: true } : {}),
...(params.options?.enableVideoUnderstanding ? { enableVideoUnderstanding: true } : {}),
};
}
export async function requestXaiXSearch(params: {
apiKey: string;
model: string;
timeoutSeconds: number;
inlineCitations: boolean;
maxTurns?: number;
options: XaiXSearchOptions;
}): Promise<XaiXSearchResult> {
return await postTrustedWebToolsJson(
{
url: XAI_X_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
apiKey: params.apiKey,
body: {
model: params.model,
input: [{ role: "user", content: params.options.query }],
tools: [buildXSearchTool(params.options)],
...(params.maxTurns ? { max_turns: params.maxTurns } : {}),
},
errorLabel: "xAI",
},
async (response) => {
const data = (await response.json()) as XaiWebSearchResponse;
const { text, annotationCitations } = extractXaiWebSearchContent(data);
const citations =
Array.isArray(data.citations) && data.citations.length > 0
? data.citations
: annotationCitations;
return {
content: text ?? "No response",
citations,
inlineCitations:
params.inlineCitations && Array.isArray(data.inline_citations)
? data.inline_citations
: undefined,
};
},
);
}
export const __testing = {
buildXSearchTool,
buildXaiXSearchPayload,
requestXaiXSearch,
resolveXaiXSearchConfig,
resolveXaiXSearchInlineCitations,
resolveXaiXSearchMaxTurns,
resolveXaiXSearchModel,
XAI_DEFAULT_X_SEARCH_MODEL,
} as const;

View File

@@ -5,6 +5,8 @@ import { createOpenClawTools } from "./openclaw-tools.js";
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
copyPluginToolMeta: () => undefined,
getPluginToolMeta: () => undefined,
}));
function asConfig(value: unknown): OpenClawConfig {

View File

@@ -27,7 +27,7 @@ import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSessionsYieldTool } from "./tools/sessions-yield-tool.js";
import { createSubagentsTool } from "./tools/subagents-tool.js";
import { createTtsTool } from "./tools/tts-tool.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import { createWebFetchTool, createWebSearchTool, createXSearchTool } from "./tools/web-tools.js";
import { resolveWorkspaceRoot } from "./workspace-dir.js";
type OpenClawToolsDeps = {
@@ -155,6 +155,10 @@ export function createOpenClawTools(
sandboxed: options?.sandboxed,
runtimeWebSearch: runtimeWebTools?.search,
});
const xSearchTool = createXSearchTool({
config: options?.config,
runtimeXSearch: runtimeWebTools?.xSearch,
});
const webFetchTool = createWebFetchTool({
config: options?.config,
sandboxed: options?.sandboxed,
@@ -251,6 +255,7 @@ export function createOpenClawTools(
sandboxed: options?.sandboxed,
}),
...(webSearchTool ? [webSearchTool] : []),
...(xSearchTool ? [xSearchTool] : []),
...(webFetchTool ? [webFetchTool] : []),
...(imageTool ? [imageTool] : []),
...(pdfTool ? [pdfTool] : []),

View File

@@ -1,9 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
const mockedModuleIds = [
"../plugins/tools.js",
"../gateway/call.js",
"./tools/agents-list-tool.js",
"./tools/canvas-tool.js",
"./tools/cron-tool.js",
"./tools/gateway-tool.js",
"./tools/image-generate-tool.js",
"./tools/image-tool.js",
"./tools/message-tool.js",
"./tools/nodes-tool.js",
"./tools/pdf-tool.js",
"./tools/session-status-tool.js",
"./tools/sessions-history-tool.js",
"./tools/sessions-list-tool.js",
"./tools/sessions-send-tool.js",
"./tools/sessions-spawn-tool.js",
"./tools/sessions-yield-tool.js",
"./tools/subagents-tool.js",
"./tools/tts-tool.js",
] as const;
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
copyPluginToolMeta: () => undefined,
getPluginToolMeta: () => undefined,
}));
vi.mock("../gateway/call.js", () => ({
@@ -123,6 +147,12 @@ describe("openclaw tools runtime web metadata wiring", () => {
secretsRuntime.clearSecretsRuntimeSnapshot();
});
afterAll(() => {
for (const id of mockedModuleIds) {
vi.doUnmock(id);
}
});
it("uses runtime-selected provider when higher-precedence provider ref is unresolved", async () => {
const snapshot = await prepareAndActivate({
config: asConfig({
@@ -204,4 +234,52 @@ describe("openclaw tools runtime web metadata wiring", () => {
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",
]);
});
});

View File

@@ -158,6 +158,7 @@ const TRUSTED_TOOL_RESULT_MEDIA = new Set([
"tts",
"web_fetch",
"web_search",
"x_search",
"write",
]);
const HTTP_URL_RE = /^https?:\/\//i;

View File

@@ -458,7 +458,7 @@ describe("createOpenClawCodingTools", () => {
senderIsOwner: true,
});
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false);
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(true);
for (const tool of xaiTools) {
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
expect(

View File

@@ -25,7 +25,7 @@ describe("applyModelProviderToolPolicy", () => {
expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]);
});
it("removes web_search for OpenRouter xAI model ids", () => {
it("keeps web_search for OpenRouter xAI model ids so OpenClaw tool routing stays authoritative", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
modelCompat: {
toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE,
@@ -34,10 +34,10 @@ describe("applyModelProviderToolPolicy", () => {
},
});
expect(toolNames(filtered)).toEqual(["read", "exec"]);
expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]);
});
it("removes web_search for direct xai-capable models too", () => {
it("keeps web_search for direct xai-capable models too", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
modelCompat: {
toolSchemaProfile: XAI_TOOL_SCHEMA_PROFILE,
@@ -45,6 +45,6 @@ describe("applyModelProviderToolPolicy", () => {
},
});
expect(toolNames(filtered)).toEqual(["read", "exec"]);
expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]);
});
});

View File

@@ -67,7 +67,6 @@ function isOpenAIProvider(provider?: string) {
const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly<Record<string, readonly string[]>> = {
voice: ["tts"],
};
const TOOL_DENY_FOR_XAI_PROVIDERS = new Set(["web_search"]);
const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]);
function normalizeMessageProvider(messageProvider?: string): string | undefined {
@@ -95,12 +94,8 @@ function applyModelProviderToolPolicy(
tools: AnyAgentTool[],
params?: { modelCompat?: ModelCompatConfig },
): AnyAgentTool[] {
if (!hasNativeWebSearchTool(params?.modelCompat)) {
return tools;
}
// Models with a native web_search tool cannot receive OpenClaw's
// web_search at the same time or the request will collide.
return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name));
void params;
return tools;
}
function isApplyPatchAllowedForModel(params: {

View File

@@ -47,6 +47,7 @@ function buildCommonSystemParams(workspaceDir: string) {
"memory_search",
"memory_get",
"web_search",
"x_search",
"web_fetch",
];
const toolSummaries = buildToolSummaryMap(
@@ -158,6 +159,7 @@ function buildToolRichSystemPrompt(params: {
"memory_search",
"memory_get",
"web_search",
"x_search",
"web_fetch",
].map((name) => ({ ...createStubTool(name), description: `${name} tool` }));
return buildEmbeddedSystemPrompt({

View File

@@ -233,7 +233,8 @@ export function buildAgentSystemPrompt(params: {
ls: "List directory contents",
exec: "Run shell commands (pty available for TTY-required CLIs)",
process: "Manage background exec sessions",
web_search: "Search the web (Brave API)",
web_search: "Search the web",
x_search: "Search X (formerly Twitter) posts with xAI",
web_fetch: "Fetch and extract readable content from a URL",
// Channel docking: add login tools here when a channel needs interactive linking.
browser: "Control web browser",

View File

@@ -2,10 +2,11 @@ import { describe, expect, it } from "vitest";
import { resolveCoreToolProfilePolicy } from "./tool-catalog.js";
describe("tool-catalog", () => {
it("includes web_search and web_fetch in the coding profile policy", () => {
it("includes web_search, x_search, and web_fetch in the coding profile policy", () => {
const policy = resolveCoreToolProfilePolicy("coding");
expect(policy).toBeDefined();
expect(policy!.allow).toContain("web_search");
expect(policy!.allow).toContain("x_search");
expect(policy!.allow).toContain("web_fetch");
expect(policy!.allow).toContain("image_generate");
});

View File

@@ -97,6 +97,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
profiles: ["coding"],
includeInOpenClawGroup: true,
},
{
id: "x_search",
label: "x_search",
description: "Search X posts",
sectionId: "web",
profiles: ["coding"],
includeInOpenClawGroup: true,
},
{
id: "memory_search",
label: "memory_search",

View File

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

View File

@@ -0,0 +1,128 @@
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("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("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

@@ -0,0 +1,231 @@
import { Type } from "@sinclair/typebox";
import {
__testing as xaiXSearchTesting,
buildXaiXSearchPayload,
requestXaiXSearch,
resolveXaiXSearchInlineCitations,
resolveXaiXSearchMaxTurns,
resolveXaiXSearchModel,
type XaiXSearchOptions,
} from "../../../extensions/xai/src/x-search-shared.js";
import type { OpenClawConfig } from "../../config/config.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 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: {
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 || readProviderEnvValue(["XAI_API_KEY"]));
}
function resolveXSearchApiKey(config?: XSearchConfig): string | undefined {
return (
readConfiguredSecretString(config?.apiKey, "tools.web.x_search.apiKey") ??
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({ config: xSearchConfig, runtimeXSearch: options?.runtimeXSearch })) {
return null;
}
return {
label: "X Search",
name: "x_search",
description:
"Search X (formerly Twitter) using xAI. Returns AI-synthesized answers with citations from real-time X post search.",
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);
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.",
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,
normalizeOptionalIsoDate,
resolveXSearchApiKey,
resolveXSearchConfig,
resolveXSearchEnabled,
...xaiXSearchTesting,
} as const;

View File

@@ -57,10 +57,12 @@ type GatewaySecretsResolveResult = {
const WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES = [
"tools.web.search",
"tools.web.fetch.firecrawl",
"tools.web.x_search",
] as const;
const WEB_RUNTIME_SECRET_PATH_PREFIXES = [
"tools.web.search.",
"tools.web.fetch.firecrawl.",
"tools.web.x_search.",
] as const;
function normalizeCommandSecretResolutionMode(
@@ -106,6 +108,10 @@ function classifyRuntimeWebTargetPathState(params: {
return fetch?.enabled !== false && fetch?.firecrawl?.enabled !== false ? "active" : "inactive";
}
if (params.path === "tools.web.x_search.apiKey") {
return params.config.tools?.web?.x_search?.enabled !== false ? "active" : "inactive";
}
if (params.path === "tools.web.search.apiKey") {
return params.config.tools?.web?.search?.enabled !== false ? "active" : "inactive";
}
@@ -144,6 +150,12 @@ function describeInactiveRuntimeWebTargetPath(params: {
return undefined;
}
if (params.path === "tools.web.x_search.apiKey") {
return params.config.tools?.web?.x_search?.enabled === false
? "tools.web.x_search is disabled."
: undefined;
}
if (params.path === "tools.web.search.apiKey") {
return params.config.tools?.web?.search?.enabled === false
? "tools.web.search is disabled."
@@ -316,7 +328,9 @@ function isUnsupportedSecretsResolveError(err: unknown): boolean {
function isDirectRuntimeWebTargetPath(path: string): boolean {
return (
path === "tools.web.fetch.firecrawl.apiKey" || /^tools\.web\.search\.[^.]+\.apiKey$/.test(path)
path === "tools.web.fetch.firecrawl.apiKey" ||
path === "tools.web.x_search.apiKey" ||
/^tools\.web\.search\.[^.]+\.apiKey$/.test(path)
);
}

View File

@@ -11,6 +11,7 @@ describe("command secret target ids", () => {
expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true);
expect(ids.has("tools.web.fetch.firecrawl.apiKey")).toBe(true);
expect(ids.has("tools.web.x_search.apiKey")).toBe(true);
});
it("includes gateway auth and channel targets for security audit", () => {

View File

@@ -25,6 +25,7 @@ const COMMAND_SECRET_TARGETS = {
"messages.tts.",
"tools.web.search",
"tools.web.fetch.firecrawl.",
"tools.web.x_search",
]),
status: idsByPrefix([
"channels.",

View File

@@ -5636,6 +5636,101 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
},
additionalProperties: false,
},
x_search: {
type: "object",
properties: {
enabled: {
type: "boolean",
},
apiKey: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
model: {
type: "string",
},
inlineCitations: {
type: "boolean",
},
maxTurns: {
type: "integer",
minimum: -9007199254740991,
maximum: 9007199254740991,
},
timeoutSeconds: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
cacheTtlMinutes: {
type: "number",
minimum: 0,
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
@@ -12844,6 +12939,42 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
help: "Timeout in seconds for Firecrawl requests.",
tags: ["performance", "tools"],
},
"tools.web.x_search.enabled": {
label: "Enable X Search Tool",
help: "Enable the x_search tool (requires XAI_API_KEY or tools.web.x_search.apiKey).",
tags: ["tools"],
},
"tools.web.x_search.apiKey": {
label: "xAI API Key",
help: "xAI API key for X search (fallback: XAI_API_KEY env var).",
tags: ["security", "auth", "tools"],
sensitive: true,
},
"tools.web.x_search.model": {
label: "X Search Model",
help: 'Model to use for X search (default: "grok-4-1-fast-non-reasoning").',
tags: ["models", "tools"],
},
"tools.web.x_search.inlineCitations": {
label: "X Search Inline Citations",
help: "Keep inline citations from xAI in x_search responses when available (default: false).",
tags: ["tools"],
},
"tools.web.x_search.maxTurns": {
label: "X Search Max Turns",
help: "Optional max internal search/tool turns xAI may use per x_search request. Omit to let xAI choose.",
tags: ["performance", "tools"],
},
"tools.web.x_search.timeoutSeconds": {
label: "X Search Timeout (sec)",
help: "Timeout in seconds for x_search requests.",
tags: ["performance", "tools"],
},
"tools.web.x_search.cacheTtlMinutes": {
label: "X Search Cache TTL (min)",
help: "Cache TTL in minutes for x_search results.",
tags: ["performance", "storage", "tools"],
},
"gateway.controlUi.basePath": {
label: "Control UI Base Path",
help: "Optional URL prefix where the Control UI is served (e.g. /openclaw).",

View File

@@ -714,6 +714,16 @@ export const FIELD_HELP: Record<string, string> = {
"tools.web.fetch.firecrawl.maxAgeMs":
"Firecrawl maxAge (ms) for cached results when supported by the API.",
"tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.",
"tools.web.x_search.enabled":
"Enable the x_search tool (requires XAI_API_KEY or tools.web.x_search.apiKey).",
"tools.web.x_search.apiKey": "xAI API key for X search (fallback: XAI_API_KEY env var).",
"tools.web.x_search.model": 'Model to use for X search (default: "grok-4-1-fast-non-reasoning").',
"tools.web.x_search.inlineCitations":
"Keep inline citations from xAI in x_search responses when available (default: false).",
"tools.web.x_search.maxTurns":
"Optional max internal search/tool turns xAI may use per x_search request. Omit to let xAI choose.",
"tools.web.x_search.timeoutSeconds": "Timeout in seconds for x_search requests.",
"tools.web.x_search.cacheTtlMinutes": "Cache TTL in minutes for x_search results.",
models:
"Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.",
"models.mode":

View File

@@ -247,6 +247,13 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.web.fetch.firecrawl.onlyMainContent": "Firecrawl Main Content Only",
"tools.web.fetch.firecrawl.maxAgeMs": "Firecrawl Cache Max Age (ms)",
"tools.web.fetch.firecrawl.timeoutSeconds": "Firecrawl Timeout (sec)",
"tools.web.x_search.enabled": "Enable X Search Tool",
"tools.web.x_search.apiKey": "xAI API Key", // pragma: allowlist secret
"tools.web.x_search.model": "X Search Model",
"tools.web.x_search.inlineCitations": "X Search Inline Citations",
"tools.web.x_search.maxTurns": "X Search Max Turns",
"tools.web.x_search.timeoutSeconds": "X Search Timeout (sec)",
"tools.web.x_search.cacheTtlMinutes": "X Search Cache TTL (min)",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",

View File

@@ -459,6 +459,23 @@ type WebSearchLegacyProviderConfig = {
inlineCitations?: boolean;
};
type XSearchToolConfig = {
/** Enable X search tool (default: true when an xAI API key is available). */
enabled?: boolean;
/** API key for xAI (defaults to XAI_API_KEY env var). Supports SecretRef. */
apiKey?: SecretInput;
/** Model id to use for X search. */
model?: string;
/** Keep inline citations in the xAI response payload when available. */
inlineCitations?: boolean;
/** Optional max search/tool turns for xAI to use internally. */
maxTurns?: number;
/** Timeout in seconds for X search requests. */
timeoutSeconds?: number;
/** Cache TTL in minutes for X search results. */
cacheTtlMinutes?: number;
};
export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
@@ -495,6 +512,8 @@ export type ToolsConfig = {
/** @deprecated Legacy Perplexity scoped config. */
perplexity?: WebSearchLegacyProviderConfig;
} & Record<string, unknown>;
/** X (formerly Twitter) search tool configuration using xAI Grok. */
x_search?: XSearchToolConfig;
fetch?: {
/** Enable web fetch tool (default: true). */
enabled?: boolean;

View File

@@ -348,10 +348,24 @@ export const ToolsWebFetchSchema = z
.strict()
.optional();
export const ToolsWebXSearchSchema = z
.object({
enabled: z.boolean().optional(),
apiKey: SecretInputSchema.optional().register(sensitive),
model: z.string().optional(),
inlineCitations: z.boolean().optional(),
maxTurns: z.number().int().optional(),
timeoutSeconds: z.number().int().positive().optional(),
cacheTtlMinutes: z.number().nonnegative().optional(),
})
.strict()
.optional();
export const ToolsWebSchema = z
.object({
search: ToolsWebSearchSchema,
fetch: ToolsWebFetchSchema,
x_search: ToolsWebXSearchSchema,
})
.strict()
.optional();

View File

@@ -11,6 +11,8 @@ export type SecretResolverWarningCode =
| "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT"
| "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
| "WEB_X_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_X_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK";

View File

@@ -798,4 +798,57 @@ describe("runtime web tools resolution", () => {
]),
);
});
it("resolves x_search SecretRef and writes the resolved key into runtime config", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
x_search: {
apiKey: { source: "env", provider: "default", id: "X_SEARCH_REF" },
},
},
},
}),
env: {
X_SEARCH_REF: "x-search-runtime-key",
},
});
expect(metadata.xSearch.active).toBe(true);
expect(metadata.xSearch.apiKeySource).toBe("secretRef");
expect(resolvedConfig.tools?.web?.x_search?.apiKey).toBe("x-search-runtime-key");
expect(context.warnings.map((warning) => warning.code)).not.toContain(
"WEB_X_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
);
});
it("uses env fallback for unresolved x_search SecretRef when active", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
x_search: {
apiKey: { source: "env", provider: "default", id: "MISSING_X_SEARCH_REF" },
},
},
},
}),
env: {
XAI_API_KEY: "x-search-fallback-key", // pragma: allowlist secret
},
});
expect(metadata.xSearch.active).toBe(true);
expect(metadata.xSearch.apiKeySource).toBe("env");
expect(resolvedConfig.tools?.web?.x_search?.apiKey).toBe("x-search-fallback-key");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "WEB_X_SEARCH_KEY_UNRESOLVED_FALLBACK_USED",
path: "tools.web.x_search.apiKey",
}),
]),
);
});
});

View File

@@ -24,6 +24,7 @@ import type {
RuntimeWebFetchFirecrawlMetadata,
RuntimeWebSearchMetadata,
RuntimeWebToolsMetadata,
RuntimeWebXSearchMetadata,
} from "./runtime-web-tools.types.js";
type WebSearchProvider = string;
@@ -34,6 +35,7 @@ export type {
RuntimeWebFetchFirecrawlMetadata,
RuntimeWebSearchMetadata,
RuntimeWebToolsMetadata,
RuntimeWebXSearchMetadata,
};
type FetchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
@@ -262,6 +264,13 @@ function setResolvedFirecrawlApiKey(params: {
firecrawl.apiKey = params.value;
}
function setResolvedXSearchApiKey(params: { resolvedConfig: OpenClawConfig; value: string }): void {
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
const web = ensureObject(tools, "web");
const xSearch = ensureObject(web, "x_search");
xSearch.apiKey = params.value;
}
function keyPathForProvider(provider: PluginWebSearchProviderEntry): string {
return provider.credentialPath;
}
@@ -574,6 +583,103 @@ export async function resolveRuntimeWebTools(params: {
}
}
const xSearch = isRecord(web?.x_search) ? web.x_search : undefined;
const xSearchEnabled = xSearch?.enabled !== false;
const xSearchPath = "tools.web.x_search.apiKey";
let xSearchResolution: SecretResolutionResult = {
source: "missing",
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
const xSearchDiagnostics: RuntimeWebDiagnostic[] = [];
if (xSearchEnabled) {
xSearchResolution = await resolveSecretInputWithEnvFallback({
sourceConfig: params.sourceConfig,
context: params.context,
defaults,
value: xSearch?.apiKey,
path: xSearchPath,
envVars: ["XAI_API_KEY"],
});
if (xSearchResolution.value) {
setResolvedXSearchApiKey({
resolvedConfig: params.resolvedConfig,
value: xSearchResolution.value,
});
}
if (xSearchResolution.secretRefConfigured) {
if (xSearchResolution.fallbackUsedAfterRefFailure) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_X_SEARCH_KEY_UNRESOLVED_FALLBACK_USED",
message:
`${xSearchPath} SecretRef could not be resolved; using ${xSearchResolution.fallbackEnvVar ?? "env fallback"}. ` +
(xSearchResolution.unresolvedRefReason ?? "").trim(),
path: xSearchPath,
};
diagnostics.push(diagnostic);
xSearchDiagnostics.push(diagnostic);
pushWarning(params.context, {
code: "WEB_X_SEARCH_KEY_UNRESOLVED_FALLBACK_USED",
path: xSearchPath,
message: diagnostic.message,
});
}
if (!xSearchResolution.value && xSearchResolution.unresolvedRefReason) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_X_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
message: xSearchResolution.unresolvedRefReason,
path: xSearchPath,
};
diagnostics.push(diagnostic);
xSearchDiagnostics.push(diagnostic);
pushWarning(params.context, {
code: "WEB_X_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
path: xSearchPath,
message: xSearchResolution.unresolvedRefReason,
});
throw new Error(
`[WEB_X_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${xSearchResolution.unresolvedRefReason}`,
);
}
}
} else if (hasConfiguredSecretRef(xSearch?.apiKey, defaults)) {
pushInactiveSurfaceWarning({
context: params.context,
path: xSearchPath,
details: "tools.web.x_search is disabled.",
});
xSearchResolution = {
source: "secretRef",
secretRefConfigured: true,
fallbackUsedAfterRefFailure: false,
};
} else {
const configuredInlineValue = normalizeSecretInput(xSearch?.apiKey);
if (configuredInlineValue) {
xSearchResolution = {
value: configuredInlineValue,
source: "config",
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
} else {
const envFallback = readNonEmptyEnvValue(params.context.env, ["XAI_API_KEY"]);
if (envFallback.value) {
xSearchResolution = {
value: envFallback.value,
source: "env",
fallbackEnvVar: envFallback.envVar,
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
}
}
}
const fetch = isRecord(web?.fetch) ? (web.fetch as FetchConfig) : undefined;
const firecrawl = isRecord(fetch?.firecrawl) ? fetch.firecrawl : undefined;
const fetchEnabled = fetch?.enabled !== false;
@@ -681,6 +787,11 @@ export async function resolveRuntimeWebTools(params: {
return {
search: searchMetadata,
xSearch: {
active: Boolean(xSearchEnabled && xSearchResolution.value),
apiKeySource: xSearchResolution.source,
diagnostics: xSearchDiagnostics,
},
fetch: {
firecrawl: {
active: firecrawlActive,

View File

@@ -3,6 +3,8 @@ export type RuntimeWebDiagnosticCode =
| "WEB_SEARCH_AUTODETECT_SELECTED"
| "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
| "WEB_X_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_X_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK";
@@ -27,8 +29,15 @@ export type RuntimeWebFetchFirecrawlMetadata = {
diagnostics: RuntimeWebDiagnostic[];
};
export type RuntimeWebXSearchMetadata = {
active: boolean;
apiKeySource: "config" | "secretRef" | "env" | "missing";
diagnostics: RuntimeWebDiagnostic[];
};
export type RuntimeWebToolsMetadata = {
search: RuntimeWebSearchMetadata;
xSearch: RuntimeWebXSearchMetadata;
fetch: {
firecrawl: RuntimeWebFetchFirecrawlMetadata;
};

View File

@@ -208,6 +208,9 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string)
if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily");
}
if (entry.id === "tools.web.x_search.apiKey") {
setPathCreateStrict(config, ["tools", "web", "x_search", "enabled"], true);
}
return config;
}

View File

@@ -714,6 +714,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.x_search.apiKey",
targetType: "tools.web.x_search.apiKey",
configFile: "openclaw.json",
pathPattern: "tools.web.x_search.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.apiKey",
targetType: "tools.web.search.apiKey",

View File

@@ -274,6 +274,11 @@ describe("web search runtime", () => {
selectedProvider: "beta",
diagnostics: [],
},
xSearch: {
active: false,
apiKeySource: "missing",
diagnostics: [],
},
fetch: {
firecrawl: {
active: false,