diff --git a/CHANGELOG.md b/CHANGELOG.md index cf81a987c57..1c43a94e26b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Tools/web_search Brave language-code validation: align `search_lang` handling with Brave-supported codes (including `zh-hans`, `zh-hant`, `en-gb`, and `pt-br`), map common alias inputs (`zh`, `ja`) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming. - Models/openai-completions streaming compatibility: force `compat.supportsUsageInStreaming=false` for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering `choices[0]` parser crashes in provider streams. (#8714) Thanks @nonanon1. - Tools/xAI native web-search collision guard: drop OpenClaw `web_search` from tool registration when routing to xAI/Grok model providers (including OpenRouter `x-ai/*`) to avoid duplicate tool-name request failures against provider-native `web_search`. (#14749) Thanks @realsamrat. - TUI/token copy-safety rendering: treat long credential-like mixed alphanumeric tokens (including quoted forms) as copy-sensitive in render sanitization so formatter hard-wrap guards no longer inject visible spaces into auth-style values before display. (#26710) Thanks @jasonthane. diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index ee15b9c0773..eb7dc225ce9 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -40,7 +40,67 @@ const KIMI_WEB_SEARCH_TOOL = { const SEARCH_CACHE = new Map>>(); const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; -const BRAVE_SEARCH_LANG_CODE = /^[a-z]{2}$/i; +const BRAVE_SEARCH_LANG_CODES = new Set([ + "ar", + "eu", + "bn", + "bg", + "ca", + "zh-hans", + "zh-hant", + "hr", + "cs", + "da", + "nl", + "en", + "en-gb", + "et", + "fi", + "fr", + "gl", + "de", + "el", + "gu", + "he", + "hi", + "hu", + "is", + "it", + "jp", + "kn", + "ko", + "lv", + "lt", + "ms", + "ml", + "mr", + "nb", + "pl", + "pt-br", + "pt-pt", + "pa", + "ro", + "ru", + "sr", + "sk", + "sl", + "es", + "sv", + "ta", + "te", + "th", + "tr", + "uk", + "vi", +]); +const BRAVE_SEARCH_LANG_ALIASES: Record = { + ja: "jp", + zh: "zh-hans", + "zh-cn": "zh-hans", + "zh-hk": "zh-hant", + "zh-sg": "zh-hans", + "zh-tw": "zh-hant", +}; const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]); @@ -127,7 +187,7 @@ function createWebSearchSchema(provider: (typeof SEARCH_PROVIDERS)[number]) { search_lang: Type.Optional( Type.String({ description: - "Short ISO language code for search results (e.g., 'de', 'en', 'fr', 'tr'). Must be a 2-letter code, NOT a locale.", + "Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').", }), ), ui_lang: Type.Optional( @@ -731,10 +791,14 @@ function normalizeBraveSearchLang(value: string | undefined): string | undefined return undefined; } const trimmed = value.trim(); - if (!trimmed || !BRAVE_SEARCH_LANG_CODE.test(trimmed)) { + if (!trimmed) { return undefined; } - return trimmed.toLowerCase(); + const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase(); + if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) { + return undefined; + } + return canonical; } function normalizeBraveUiLang(value: string | undefined): string | undefined { @@ -1473,7 +1537,7 @@ export function createWebSearchTool(options?: { return jsonResult({ error: "invalid_search_lang", message: - "search_lang must be a 2-letter ISO language code like 'en' (not a locale like 'en-US').", + "search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.", docs: "https://docs.openclaw.ai/tools/web", }); } diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index c42fb680002..53af4a5c8f3 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -155,6 +155,8 @@ describe("web_search country and language parameters", () => { async function runBraveSearchAndGetUrl( params: Partial<{ country: string; + language: string; + search_lang: string; ui_lang: string; freshness: string; }>, @@ -185,6 +187,30 @@ describe("web_search country and language parameters", () => { expect(url.searchParams.get("search_lang")).toBe("de"); }); + it("maps legacy zh language code to Brave zh-hans search_lang", async () => { + const url = await runBraveSearchAndGetUrl({ language: "zh" }); + expect(url.searchParams.get("search_lang")).toBe("zh-hans"); + }); + + it("maps ja language code to Brave jp search_lang", async () => { + const url = await runBraveSearchAndGetUrl({ language: "ja" }); + expect(url.searchParams.get("search_lang")).toBe("jp"); + }); + + it("passes Brave extended language code variants unchanged", async () => { + const url = await runBraveSearchAndGetUrl({ search_lang: "zh-hant" }); + expect(url.searchParams.get("search_lang")).toBe("zh-hant"); + }); + + it("rejects unsupported Brave search_lang values before upstream request", async () => { + const mockFetch = installMockFetch({ web: { results: [] } }); + const tool = createWebSearchTool({ config: undefined, sandboxed: true }); + const result = await tool?.execute?.("call-1", { query: "test", search_lang: "xx" }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result?.details).toMatchObject({ error: "invalid_search_lang" }); + }); + it("rejects invalid freshness values", async () => { const mockFetch = installMockFetch({ web: { results: [] } }); const tool = createWebSearchTool({ config: undefined, sandboxed: true });