mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 11:56:06 +00:00
fix(xai): validate x_search handle filters (#103393)
This commit is contained in:
committed by
GitHub
parent
19111cf29d
commit
d7dc6ee845
@@ -478,13 +478,15 @@ legacy `tools.web.search.grok.baseUrl`, and finally the public xAI endpoint
|
||||
| Parameter | Description |
|
||||
| ---------------------------- | ------------------------------------------------------ |
|
||||
| `query` | Search query (required) |
|
||||
| `allowed_x_handles` | Restrict results to specific X handles |
|
||||
| `excluded_x_handles` | Exclude specific X handles |
|
||||
| `allowed_x_handles` | Restrict results to at most 20 X handles |
|
||||
| `excluded_x_handles` | Exclude at most 20 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 |
|
||||
|
||||
`allowed_x_handles` and `excluded_x_handles` are mutually exclusive.
|
||||
|
||||
### x_search example
|
||||
|
||||
```javascript
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
|
||||
import { Type } from "typebox";
|
||||
|
||||
export const X_SEARCH_HANDLE_LIMIT = 20;
|
||||
|
||||
export function buildMissingXSearchApiKeyPayload() {
|
||||
return {
|
||||
error: "missing_xai_api_key",
|
||||
@@ -26,12 +28,16 @@ export function createXSearchToolDefinition(
|
||||
}),
|
||||
allowed_x_handles: Type.Optional(
|
||||
Type.Array(Type.String({ minLength: 1 }), {
|
||||
description: "Only include posts from these X handles.",
|
||||
description:
|
||||
"Only include posts from these X handles (max 20). Cannot be combined with excluded_x_handles.",
|
||||
maxItems: X_SEARCH_HANDLE_LIMIT,
|
||||
}),
|
||||
),
|
||||
excluded_x_handles: Type.Optional(
|
||||
Type.Array(Type.String({ minLength: 1 }), {
|
||||
description: "Exclude posts from these X handles.",
|
||||
description:
|
||||
"Exclude posts from these X handles (max 20). Cannot be combined with allowed_x_handles.",
|
||||
maxItems: X_SEARCH_HANDLE_LIMIT,
|
||||
}),
|
||||
),
|
||||
from_date: Type.Optional(
|
||||
|
||||
@@ -3,6 +3,8 @@ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createXSearchTool } from "./x-search.js";
|
||||
|
||||
const XAI_DOCUMENTED_HANDLE_LIMIT = 20;
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
@@ -74,6 +76,28 @@ function parseFirstRequestBody(mockFetch: ReturnType<typeof installXSearchFetch>
|
||||
>;
|
||||
}
|
||||
|
||||
function createConfiguredXSearchTool() {
|
||||
const tool = createXSearchTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-config-test", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("expected x_search tool to be configured");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -107,6 +131,21 @@ describe("xai x_search tool", () => {
|
||||
expect(queryDescription).not.toContain("allowed_x_handles");
|
||||
});
|
||||
|
||||
it("publishes xAI handle-filter constraints in the tool schema", () => {
|
||||
const tool = createConfiguredXSearchTool();
|
||||
const parameters = tool.parameters as {
|
||||
properties?: Record<string, { description?: string; maxItems?: number }>;
|
||||
};
|
||||
|
||||
for (const [key, counterpart] of [
|
||||
["allowed_x_handles", "excluded_x_handles"],
|
||||
["excluded_x_handles", "allowed_x_handles"],
|
||||
] as const) {
|
||||
expect(parameters.properties?.[key]?.maxItems).toBe(XAI_DOCUMENTED_HANDLE_LIMIT);
|
||||
expect(parameters.properties?.[key]?.description).toContain(counterpart);
|
||||
}
|
||||
});
|
||||
|
||||
it("enables x_search when runtime config carries the shared xAI key", () => {
|
||||
const tool = createXSearchTool({
|
||||
config: {},
|
||||
@@ -178,9 +217,7 @@ describe("xai x_search tool", () => {
|
||||
webSearch: {
|
||||
apiKey: "xai-config-test", // pragma: allowlist secret
|
||||
},
|
||||
xSearch: {
|
||||
maxTurns: 2,
|
||||
},
|
||||
xSearch: { maxTurns: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -191,7 +228,6 @@ describe("xai x_search tool", () => {
|
||||
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,
|
||||
@@ -208,7 +244,6 @@ describe("xai x_search tool", () => {
|
||||
{
|
||||
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,
|
||||
@@ -219,6 +254,61 @@ describe("xai x_search tool", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects combined allow and exclude handle filters before calling xAI", async () => {
|
||||
const mockFetch = installXSearchFetch();
|
||||
const tool = createConfiguredXSearchTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("x-search:combined-handle-filters", {
|
||||
query: "dinner recipes",
|
||||
allowed_x_handles: ["openclaw"],
|
||||
excluded_x_handles: ["spam"],
|
||||
}),
|
||||
).rejects.toThrow("allowed_x_handles and excluded_x_handles cannot be used together");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["allowed_x_handles", "excluded_x_handles"] as const)(
|
||||
"accepts the xAI limit for %s",
|
||||
async (key) => {
|
||||
const mockFetch = installXSearchFetch();
|
||||
const tool = createConfiguredXSearchTool();
|
||||
const handles = Array.from(
|
||||
{ length: XAI_DOCUMENTED_HANDLE_LIMIT },
|
||||
(_, index) => `${key}-${index}`,
|
||||
);
|
||||
|
||||
await tool.execute(`x-search:${key}:limit`, {
|
||||
query: `${key} boundary`,
|
||||
[key]: handles,
|
||||
});
|
||||
|
||||
expect(parseFirstRequestBody(mockFetch).tools).toEqual([
|
||||
{ type: "x_search", [key]: handles },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["allowed_x_handles", "excluded_x_handles"] as const)(
|
||||
"rejects %s above the xAI limit before calling xAI",
|
||||
async (key) => {
|
||||
const mockFetch = installXSearchFetch();
|
||||
const tool = createConfiguredXSearchTool();
|
||||
const handles = Array.from(
|
||||
{ length: XAI_DOCUMENTED_HANDLE_LIMIT + 1 },
|
||||
(_, index) => `${key}-${index}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
tool.execute(`x-search:${key}:over-limit`, {
|
||||
query: `${key} over limit`,
|
||||
[key]: handles,
|
||||
}),
|
||||
).rejects.toThrow(`${key} cannot contain more than ${XAI_DOCUMENTED_HANDLE_LIMIT} handles`);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("routes x_search through plugin-owned xSearch.baseUrl", async () => {
|
||||
const mockFetch = installXSearchFetch();
|
||||
const tool = createXSearchTool({
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
buildMissingXSearchApiKeyPayload,
|
||||
createXSearchToolDefinition,
|
||||
X_SEARCH_HANDLE_LIMIT,
|
||||
} from "./x-search-tool-shared.js";
|
||||
|
||||
class PluginToolInputError extends Error {
|
||||
@@ -106,6 +107,27 @@ function normalizeOptionalIsoDate(value: string | undefined, label: string): str
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function validateXSearchHandleFilters(params: {
|
||||
allowedXHandles?: string[];
|
||||
excludedXHandles?: string[];
|
||||
}): void {
|
||||
if (params.allowedXHandles && params.excludedXHandles) {
|
||||
throw new PluginToolInputError(
|
||||
"allowed_x_handles and excluded_x_handles cannot be used together",
|
||||
);
|
||||
}
|
||||
for (const [label, handles] of [
|
||||
["allowed_x_handles", params.allowedXHandles],
|
||||
["excluded_x_handles", params.excludedXHandles],
|
||||
] as const) {
|
||||
if (handles && handles.length > X_SEARCH_HANDLE_LIMIT) {
|
||||
throw new PluginToolInputError(
|
||||
`${label} cannot contain more than ${X_SEARCH_HANDLE_LIMIT} handles`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildXSearchCacheKey(params: {
|
||||
query: string;
|
||||
model: string;
|
||||
@@ -161,6 +183,7 @@ export function createXSearchTool(options?: {
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
const allowedXHandles = readStringArrayParam(args, "allowed_x_handles");
|
||||
const excludedXHandles = readStringArrayParam(args, "excluded_x_handles");
|
||||
validateXSearchHandleFilters({ allowedXHandles, excludedXHandles });
|
||||
const fromDate = normalizeOptionalIsoDate(readStringParam(args, "from_date"), "from_date");
|
||||
const toDate = normalizeOptionalIsoDate(readStringParam(args, "to_date"), "to_date");
|
||||
if (fromDate && toDate && fromDate > toDate) {
|
||||
|
||||
Reference in New Issue
Block a user