diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 95e8e878bc7..8c4960569ea 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -7,6 +7,7 @@ const { resolvePerplexityBaseUrl, isDirectPerplexityBaseUrl, resolvePerplexityRequestModel, + normalizeBraveLanguageParams, normalizeFreshness, freshnessToPerplexityRecency, resolveGrokApiKey, @@ -93,6 +94,28 @@ describe("web_search perplexity model normalization", () => { }); }); +describe("web_search brave language param normalization", () => { + it("normalizes and auto-corrects swapped Brave language params", () => { + expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({ + search_lang: "tr", + ui_lang: "tr-TR", + }); + expect(normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({ + search_lang: "en", + ui_lang: "en-US", + }); + }); + + it("flags invalid Brave language formats", () => { + expect(normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({ + invalidField: "search_lang", + }); + expect(normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({ + invalidField: "ui_lang", + }); + }); +}); + describe("web_search freshness normalization", () => { it("accepts Brave shortcut values", () => { expect(normalizeFreshness("pd")).toBe("pd"); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 0c299f6be0f..321f41e4d11 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -43,6 +43,8 @@ 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_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i; const TRUSTED_NETWORK_SSRF_POLICY = { dangerouslyAllowPrivateNetwork: true } as const; const WebSearchSchema = Type.Object({ @@ -62,12 +64,14 @@ const WebSearchSchema = Type.Object({ ), search_lang: Type.Optional( Type.String({ - description: "ISO language code for search results (e.g., 'de', 'en', 'fr').", + description: + "Short ISO language code for search results (e.g., 'de', 'en', 'fr', 'tr'). Must be a 2-letter code, NOT a locale.", }), ), ui_lang: Type.Optional( Type.String({ - description: "ISO language code for UI elements.", + description: + "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", }), ), freshness: Type.Optional( @@ -705,6 +709,62 @@ function resolveSearchCount(value: unknown, fallback: number): number { return clamped; } +function normalizeBraveSearchLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed || !BRAVE_SEARCH_LANG_CODE.test(trimmed)) { + return undefined; + } + return trimmed.toLowerCase(); +} + +function normalizeBraveUiLang(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + const match = trimmed.match(BRAVE_UI_LANG_LOCALE); + if (!match) { + return undefined; + } + const [, language, region] = match; + return `${language.toLowerCase()}-${region.toUpperCase()}`; +} + +function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): { + search_lang?: string; + ui_lang?: string; + invalidField?: "search_lang" | "ui_lang"; +} { + const rawSearchLang = params.search_lang?.trim() || undefined; + const rawUiLang = params.ui_lang?.trim() || undefined; + let searchLangCandidate = rawSearchLang; + let uiLangCandidate = rawUiLang; + + // Recover common LLM mix-up: locale in search_lang + short code in ui_lang. + if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) { + searchLangCandidate = rawUiLang; + uiLangCandidate = rawSearchLang; + } + + const search_lang = normalizeBraveSearchLang(searchLangCandidate); + if (searchLangCandidate && !search_lang) { + return { invalidField: "search_lang" }; + } + + const ui_lang = normalizeBraveUiLang(uiLangCandidate); + if (uiLangCandidate && !ui_lang) { + return { invalidField: "ui_lang" }; + } + + return { search_lang, ui_lang }; +} + function normalizeFreshness(value: string | undefined): string | undefined { if (!value) { return undefined; @@ -1289,8 +1349,29 @@ export function createWebSearchTool(options?: { const count = readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined; const country = readStringParam(params, "country"); - const search_lang = readStringParam(params, "search_lang"); - const ui_lang = readStringParam(params, "ui_lang"); + const rawSearchLang = readStringParam(params, "search_lang"); + const rawUiLang = readStringParam(params, "ui_lang"); + const normalizedBraveLanguageParams = + provider === "brave" + ? normalizeBraveLanguageParams({ search_lang: rawSearchLang, ui_lang: rawUiLang }) + : { search_lang: rawSearchLang, ui_lang: rawUiLang }; + if (normalizedBraveLanguageParams.invalidField === "search_lang") { + 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').", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + if (normalizedBraveLanguageParams.invalidField === "ui_lang") { + return jsonResult({ + error: "invalid_ui_lang", + message: "ui_lang must be a language-region locale like 'en-US'.", + docs: "https://docs.openclaw.ai/tools/web", + }); + } + const search_lang = normalizedBraveLanguageParams.search_lang; + const ui_lang = normalizedBraveLanguageParams.ui_lang; const rawFreshness = readStringParam(params, "freshness"); if (rawFreshness && provider !== "brave" && provider !== "perplexity") { return jsonResult({ @@ -1342,6 +1423,7 @@ export const __testing = { resolvePerplexityBaseUrl, isDirectPerplexityBaseUrl, resolvePerplexityRequestModel, + normalizeBraveLanguageParams, normalizeFreshness, freshnessToPerplexityRecency, resolveGrokApiKey,